Contactos
Las personas de tu audiencia: datos, propiedades, segmentos y suscripción a temas.
Los contactos son las personas de tu audiencia de marketing: correo electrónico, nombre, propiedades personalizadas, segmentos y la suscripción a temas. Cada correo electrónico aparece una vez por equipo. El número de contactos depende del plan de marketing (Gratis: 1.000, Profesional: 5.000, Escala: 50.000, Empresarial: sin límite). En los endpoints con {id}, puedes usar el ID o el propio correo electrónico del contacto. Para traer muchos contactos de una vez, usa la importación de CSV. Las direcciones que están en la lista de supresión nunca se convierten en contactos (consulta Crear un contacto).
Cada contacto también guarda el external_id (alias user_id): el ID de la persona en tu sistema, único en el equipo. En cualquier endpoint con {id}, puedes usar external_id:user_42 (o user_id:user_42) en lugar del ID y del correo electrónico. Las etiquetas marcan contactos sin necesidad de un segmento, y el lote crea o actualiza hasta 10.000 contactos en una llamada. Todos los endpoints de esta página exigen una clave de API de acceso total (una clave solo de envío recibe 401 restricted_api_key).
Quien recibe un correo transaccional tuyo también se convierte en contacto, solo, pero sin confirmar para marketing: mira Contactos guardados de los correos transaccionales y los campos marketing_consent, origin y consent_confirmed_at.
#Origen de los cambios
Cada cambio de un contacto guarda de dónde vino en last_change_source: api, dashboard, form, import, automation, partner, system, transactional, postal_lifecycle, crm:{id da conexão} o una etiqueta tuya. Para ponerle una etiqueta al cambio, envía la cabecera X-Postal-Source o el campo source en el cuerpo (si llegan los dos, vale el campo): hasta 64 caracteres entre letras minúsculas, números, ., _, : y -. Los nombres que usa Postal y el prefijo crm: están reservados y generan 422. Los webhooks contact.* traen el campo source, y un endpoint de webhook puede ignorar orígenes (skip_sources), lo que evita el eco entre dos sistemas que se sincronizan en ambos sentidos. En la lista, ?source= filtra por origen.
#Contactos guardados de los correos transaccionales
Toda dirección que recibe un correo transaccional tuyo queda guardada, sola, en la audiencia del equipo, para que puedas enviar marketing después a quien ya interactuó con tu empresa (quien rellenó un formulario de contacto, quien se registró…). Entran los destinatarios de POST /emails, de POST /emails/batch, de SMTP y de los correos de autenticación de las conexiones de Supabase, solo en to y cc (nunca bcc). No entran los destinatarios de campañas, automatizaciones, pruebas de plantilla y de campaña, reenvíos, respuestas de la inbox y envíos a las direcciones de prueba. El contacto se crea en segundo plano, después de que el proveedor aceptó el correo, sin retrasar el envío. El nombre para mostrar (Ana Souza <[email protected]>) pasa a ser first_name y last_name, cuando existe.
| Campo | Valores | Qué dice |
|---|---|---|
marketing_consent | confirmed, unconfirmed | Si el contacto puede recibir marketing (campañas y automatizaciones). Todo contacto que ya existía y todo el que se creó de otra forma (POST /contacts, lote, eventos, importación, formulario, CRM) es confirmed. El contacto guardado de un correo transaccional nace unconfirmed. |
origin | transactional, null | De dónde vino el contacto: transactional para los guardados de los correos transaccionales; null para los demás. No cambia cuando el contacto se confirma. |
consent_confirmed_at | fecha o null | Cuándo se confirmó el contacto por la declaración del propietario o de un administrador (acción masiva confirm_consent) o por una acción explícita. null en los que nunca se confirmaron de esa forma. |
- Fuera de campañas y automatizaciones. Un contacto
unconfirmedno entra en la lista de envío de una campaña (ni enrecipients_countyGET /broadcasts/audience), no inicia automatizaciones y el paso de enviar correo se salta a quien dejó de estar confirmado. Tus envíos transaccionales a ese contacto no cambian. Está en la audiencia de verdad: cuenta en el límite de contactos y aparece enGET /contacts, en los contadores y en los segmentos. - Silenciosa. La captura no dispara el webhook
contact.created, ninguna automatización (contact.created,segment.added…) y no empuja el contacto a un CRM conectado. El origen del cambio estransactional. - Nunca toca lo que ya existe. Un contacto que ya existe (con su consentimiento, sus temas, su baja y sus datos) queda como está, y una dirección de la lista de supresión nunca se convierte en contacto.
- Nunca supera el límite del plan. La captura usa el límite de contactos del plan de marketing y se detiene al llegar a él, incluso con el excedente activado (el excedente cobra contactos que añadiste a propósito; la captura automática no cobra a nadie). Se pausa en silencio y se reanuda sola cuando haya espacio. El bloque
audiencedeGET /usagedice si está en pausa. - Segmento predeterminado. En la primera captura, si el plan incluye segmentos dinámicos y tiene espacio para uno más, nace el segmento dinámico Envíos transaccionales (
originestransactional), una sola vez: si lo eliminas, no vuelve. - Qué confirma un contacto. La confirmación masiva y las acciones explícitas: reimportar la dirección por CSV,
POST /contacts/batchyPOST /eventscon el contacto,PATCH /contacts/{id}y la suscripción de la persona en un formulario. Confirmar no disparacontact.created, y una captura nunca rebaja un contacto ya confirmado. - Desactivar. La captura viene activada. El interruptor del equipo,
capture_transactional_contacts, cambia conPATCH /teamy{"capture_transactional_contacts": false}, pero solo el propietario y los administradores, y solo desde el panel (Configuración › Equipo): la ruta usa la sesión del panel y no acepta clave de API. Los correos que ya estaban en la cola antes de que la función se activara no se capturan.
Para listar solo esos contactos, usa los filtros consent=unconfirmed y origin=transactional en GET /contacts; también valen en las selecciones de las acciones masivas, en las exportaciones y (como las condiciones consent y origin) en los segmentos dinámicos. Para ver cuántos esperan confirmación, usa GET /contacts/consent-summary.
#Listar contactos
GET/contacts
De los más recientes a los más antiguos. La lista no trae los segmentos de cada contacto.
Parámetros de consulta
limitintegerDe 1 a 100. Predeterminado: 20.afterstringCursor de la página siguiente.beforestringCursor de la página anterior.searchstringBusca en el correo electrónico, en elexternal_idy en el nombre.segment_idstringSolo contactos de un segmento: los de la lista, si el segmento es estático, o los que coinciden ahora con los filtros, si es dinámico.topic_idstringSolo contactos suscritos al tema: los que eligieron recibirlo y, si el tema esopt_inpor defecto, los que no eligieron nada.tagstringSolo contactos con la etiqueta (ID o nombre, sin distinguir mayúsculas).unsubscribedbooleantruepara los que cancelaron la suscripción;falsepara los demás.email_statusstringResultado de la verificación de correos, separado por comas:valid,invalid,riskyyunverified(nunca verificado).external_idstringSolo el contacto con esteexternal_id(también aceptado comouser_id).sourcestringSolo contactos cuyo último cambio vino de este origen.consentstringconfirmedounconfirmed: solo contactos que pueden (o que todavía no pueden) recibir marketing. Mira Contactos guardados de los correos transaccionales.originstringtransactional: solo los contactos guardados de los correos transaccionales.
Errores
- 422
validation_errorAlgún campo no es válido. El mensaje indica cuál.
cURL
curl 'https://api.postal.so/contacts?segment_id=019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a&unsubscribed=false&tag=Cliente+VIP' \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/contacts?segment_id=019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a&unsubscribed=false&tag=Cliente+VIP', {
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.get(
"https://api.postal.so/contacts",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
params={
"segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"unsubscribed": "false",
"tag": "Cliente VIP",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('contacts', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'query' => [
'segment_id' => '019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a',
'unsubscribed' => 'false',
'tag' => 'Cliente VIP',
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "list",
"has_more": true,
"data": [
{
"object": "contact",
"id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"email": "[email protected]",
"external_id": "user_42",
"user_id": "user_42",
"first_name": "Ana",
"last_name": "Souza",
"unsubscribed": false,
"marketing_consent": "confirmed",
"origin": null,
"consent_confirmed_at": null,
"email_status": "valid",
"email_verified_at": "2026-10-20T09:30:00.000000Z",
"properties": {
"plan": "pro",
"city": "Recife",
"customer_since": "2024-03-18"
},
"last_change_source": "api",
"created_at": "2026-10-24T14:00:00.000000Z",
"updated_at": "2026-10-24T14:00:00.000000Z",
"tags": [
{
"id": "019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f",
"name": "Cliente VIP",
"color": "purple"
}
]
}
]
}
#Crear un contacto
POST/contacts
Crea el contacto y, si quieres, ya lo pone en segmentos estáticos, aplica etiquetas y registra su elección en los temas. Dispara el webhook contact.created.
Una dirección que está en la lista de supresión del equipo nunca se convierte en contacto: la respuesta es 422 con email_suppressed. Para registrarla, quítala antes de la lista de supresión. Los contactos que ya existen no se ven afectados.
¿Quieres crear o actualizar sin saber si el contacto ya existe? Usa el lote, que busca por ID, external_id y correo electrónico.
Cuerpo de la solicitud
emailstringobligatorioCorreo electrónico, hasta 320 caracteres. Se guarda en minúsculas.external_idstring | nullID de la persona en tu sistema, hasta 255 caracteres, único en el equipo. También aceptado comouser_id.first_namestring | nullNombre, hasta 255 caracteres.last_namestring | nullApellido, hasta 255 caracteres.unsubscribedbooleanDado de baja de todos los envíos de marketing. Por defecto:false.propertiesobjectValores de las propiedades, como{"plan": "pro"}. Cada clave debe existir antes; los valores se convierten al tipo de la propiedad.segmentsstring[]IDs de hasta 100 segmentos estáticos (u objetos{"id": "…"}). Los segmentos dinámicos no aceptan contactos a mano.topicsobject[]Hasta 100 elecciones de temas.idstringobligatorioID del tema.subscriptionstringobligatorioopt_in(quiere recibir) uopt_out(no quiere).
tagsstring[]Hasta 50 etiquetas, por ID o por nombre. Los nombres que aún no existen se crean.sourcestringEtiqueta del origen del cambio. También aceptada en la cabeceraX-Postal-Source.
Errores
- 422
validation_errorCorreo electrónico no válido o ya registrado,external_idya usado por otro contacto, propiedad desconocida, valor con el tipo equivocado, o segmento dinámico ensegments. - 422
email_suppressedEl correo electrónico está en la lista de supresión del equipo, así que no puede convertirse en contacto. - 403
plan_limit_reachedEl plan no permite más contactos, contando los paquetes del Adicional de contactos (el mensaje indica cómo seguir por encima del límite o añadir un paquete, si el plan lo permite). - 404
not_foundAlgún segmento, tema o etiqueta (dada por ID) no existe.
cURL
curl -X POST https://api.postal.so/contacts \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"external_id": "user_42",
"first_name": "Ana",
"last_name": "Souza",
"properties": {
"plan": "pro",
"city": "Recife"
},
"segments": ["019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a"],
"topics": [
{
"id": "019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b",
"subscription": "opt_in"
}
],
"tags": ["Cliente VIP"]
}'
Node.js
const response = await fetch('https://api.postal.so/contacts', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: '[email protected]',
external_id: 'user_42',
first_name: 'Ana',
last_name: 'Souza',
properties: {
plan: 'pro',
city: 'Recife',
},
segments: ['019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a'],
topics: [
{
id: '019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b',
subscription: 'opt_in',
},
],
tags: ['Cliente VIP'],
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/contacts",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"email": "[email protected]",
"external_id": "user_42",
"first_name": "Ana",
"last_name": "Souza",
"properties": {
"plan": "pro",
"city": "Recife",
},
"segments": ["019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a"],
"topics": [
{
"id": "019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b",
"subscription": "opt_in",
},
],
"tags": ["Cliente VIP"],
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('contacts', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'email' => '[email protected]',
'external_id' => 'user_42',
'first_name' => 'Ana',
'last_name' => 'Souza',
'properties' => [
'plan' => 'pro',
'city' => 'Recife',
],
'segments' => ['019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a'],
'topics' => [
[
'id' => '019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b',
'subscription' => 'opt_in',
],
],
'tags' => ['Cliente VIP'],
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "contact",
"id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f"
}
#Obtener un contacto
GET/contacts/{id}
Devuelve el contacto con los segmentos de los que forma parte y las etiquetas. email_status y email_verified_at vienen de la última verificación de correos de la dirección y son null si nunca se verificó.
Parámetros de ruta
idstringobligatorioID o correo electrónico del contacto.
Errores
- 404
not_foundEl recurso no existe o es de otro equipo.
cURL
curl https://api.postal.so/contacts/[email protected] \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/contacts/[email protected]', {
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.get(
"https://api.postal.so/contacts/[email protected]",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('contacts/[email protected]', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "contact",
"id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"email": "[email protected]",
"external_id": "user_42",
"user_id": "user_42",
"first_name": "Ana",
"last_name": "Souza",
"unsubscribed": false,
"marketing_consent": "confirmed",
"origin": null,
"consent_confirmed_at": null,
"email_status": "valid",
"email_verified_at": "2026-10-20T09:30:00.000000Z",
"properties": {
"plan": "pro",
"city": "Recife",
"customer_since": "2024-03-18"
},
"last_change_source": "api",
"created_at": "2026-10-24T14:00:00.000000Z",
"updated_at": "2026-10-24T14:00:00.000000Z",
"tags": [
{
"id": "019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f",
"name": "Cliente VIP",
"color": "purple"
}
],
"segments": [
{
"id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"name": "Clientes ativos"
}
]
}
#Actualizar un contacto
PATCH/contacts/{id}
Solo cambian los campos enviados. En properties, las claves enviadas se actualizan y las demás quedan como están; envía null o "" para borrar un valor. Los segmentos y los temas tienen endpoints propios (más abajo). Si algo cambió, dispara contact.updated.
En tags, la lista enviada reemplaza las etiquetas del contacto; para solo añadir o quitar una, usa los endpoints de etiquetas del contacto. En el lote, quien canceló la suscripción solo se vuelve a suscribir con la prueba del nuevo consentimiento (resubscribe).
Actualizar un contacto unconfirmed (guardado de un correo transaccional) es una acción explícita: pasa a confirmed.
Parámetros de ruta
idstringobligatorioID o correo electrónico del contacto.
Cuerpo de la solicitud (todos opcionales)
emailstringNuevo correo electrónico.external_idstring | nullNuevoexternal_id(ouser_id).first_namestring | nullNome.last_namestring | nullSobrenome.unsubscribedbooleanDa de baja (true) o vuelve a suscribir (false).propertiesobjectValores a cambiar.tagsstring[]Hasta 50 etiquetas (ID o nombre) que pasan a ser las únicas del contacto.sourcestringEtiqueta del origen del cambio.
Errores
- 422
validation_errorCorreo electrónico oexternal_idya usado por otro contacto, propiedad desconocida, valor no válido, osegments/topicsen el cuerpo. - 404
not_foundEl recurso no existe o es de otro equipo.
cURL
curl -X PATCH https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"properties": {
"plan": "scale",
"city": null
}
}'
Node.js
const response = await fetch('https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
properties: {
plan: 'scale',
city: null,
},
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.patch(
"https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"properties": {
"plan": "scale",
"city": None,
},
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->patch('contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'properties' => [
'plan' => 'scale',
'city' => null,
],
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "contact",
"id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f"
}
#Eliminar un contacto
DELETE/contacts/{id}
Quita el contacto de la audiencia, con los segmentos y las elecciones de temas. Dispara contact.deleted. Para solo dejar de enviarle, es preferible unsubscribed: true.
Parámetros de ruta
idstringobligatorioID o correo electrónico del contacto.
Errores
- 404
not_foundEl recurso no existe o es de otro equipo.
cURL
curl -X DELETE https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f', {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.delete(
"https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->delete('contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "contact",
"id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"deleted": true
}
#Segmentos de un contacto
GET/contacts/{id}/segments
Los segmentos de los que forma parte el contacto: los estáticos a los que se añadió y los dinámicos cuyos filtros cumple (según la última comprobación). Acepta limit, after y before.
Parámetros de ruta
idstringobligatorioID o correo electrónico del contacto.
Errores
- 404
not_foundEl recurso no existe o es de otro equipo.
cURL
curl https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/segments \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/segments', {
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.get(
"https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/segments",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/segments', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "list",
"has_more": false,
"data": [
{
"object": "segment",
"id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"paused_by_plan": false,
"name": "Clientes ativos",
"description": "Compraram nos últimos 90 dias.",
"type": "static",
"created_at": "2026-10-20T10:00:00.000000Z",
"updated_at": "2026-10-20T10:00:00.000000Z"
}
]
}
#Añadir a un segmento
POST/contacts/{id}/segments/{segment_id}
Pone el contacto en el segmento. Si ya estaba, no cambia nada. Solo los segmentos estáticos aceptan contactos a mano.
Parámetros de ruta
idstringobligatorioID o correo electrónico del contacto.segment_idstringobligatorioID del segmento.
Errores
- 404
not_foundEl contacto o el segmento no existe. - 422
validation_errorEl segmento es dinámico: guarda a quien cumple los filtros y no acepta contactos a mano.
cURL
curl -X POST https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/segments/019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/segments/019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/segments/019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/segments/019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "contact_segment",
"contact_id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a"
}
#Quitar de un segmento
DELETE/contacts/{id}/segments/{segment_id}
Parámetros de ruta
idstringobligatorioID o correo electrónico del contacto.segment_idstringobligatorioID del segmento.
Errores
- 404
not_foundEl contacto o el segmento no existe. - 422
validation_errorEl segmento es dinámico: guarda a quien cumple los filtros y no acepta contactos a mano.
cURL
curl -X DELETE https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/segments/019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/segments/019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a', {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.delete(
"https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/segments/019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->delete('contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/segments/019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "contact_segment",
"contact_id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"deleted": true
}
#Temas de un contacto
GET/contacts/{id}/topics
Todos los temas del equipo, en orden alfabético, con la suscripción del contacto: su elección o, si no eligió, el valor por defecto del tema.
Parámetros de ruta
idstringobligatorioID o correo electrónico del contacto.
Errores
- 404
not_foundEl recurso no existe o es de otro equipo.
cURL
curl https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/topics \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/topics', {
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.get(
"https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/topics",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/topics', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "list",
"has_more": false,
"data": [
{
"id": "019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b",
"name": "Novidades",
"description": "Lançamentos e promoções, uma vez por semana.",
"visibility": "public",
"default_subscription": "opt_in",
"subscription": "opt_in"
},
{
"id": "019a1d95-4c5e-7f70-9b8c-9d0e1f2a3b4c",
"name": "Pesquisas",
"description": null,
"visibility": "private",
"default_subscription": "opt_out",
"subscription": "opt_out"
}
]
}
#Cambiar la suscripción a temas
PATCH/contacts/{id}/topics
Registra la elección del contacto en uno o más temas. Los temas no enviados quedan como están.
Parámetros de ruta
idstringobligatorioID o correo electrónico del contacto.
Cuerpo de la solicitud
topicsobject[]obligatorioDe 1 a 100 elecciones, sin temas repetidos.idstringobligatorioID del tema.subscriptionstringobligatorioopt_inuopt_out.
Errores
- 422
validation_errorAlgún campo no es válido. El mensaje indica cuál. - 404
not_foundEl contacto o algún tema no existe.
cURL
curl -X PATCH https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/topics \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"topics": [
{
"id": "019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b",
"subscription": "opt_out"
}
]
}'
Node.js
const response = await fetch('https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/topics', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
topics: [
{
id: '019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b',
subscription: 'opt_out',
},
],
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.patch(
"https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/topics",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"topics": [
{
"id": "019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b",
"subscription": "opt_out",
},
],
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->patch('contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/topics', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'topics' => [
[
'id' => '019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b',
'subscription' => 'opt_out',
],
],
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "contact",
"id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f"
}
#Crear o actualizar contactos en lote
POST/contacts/batch
Crea o actualiza varios contactos a la vez (upsert). Cada elemento busca el contacto por id, luego por external_id y luego por e-mail; si no lo encuentra y hay correo electrónico, lo crea. Si se encuentra por external_id, un correo electrónico nuevo reemplaza al antiguo; si se encuentra por correo electrónico, el external_id se guarda si el contacto aún no tenía uno.
Hasta 1.000 elementos se procesan en la propia solicitud y la respuesta trae un resultado por elemento (200): un elemento con error no tumba a los demás. Por encima de eso (hasta 10.000), el lote pasa a la cola y la respuesta es 202 con el id del lote, para seguirlo en GET /contacts/batches/{id}. Puedes repetir la llamada con seguridad: usa la cabecera Idempotency-Key.
Reglas por elemento: las etiquetas y los segmentos solo se añaden (nunca se quitan); los temas se guardan; las propiedades se comprueban con las definiciones del equipo y se combinan; las direcciones de la lista de supresión no se convierten en contactos (el elemento falla con code: "email_suppressed"), pero los contactos que ya existen pueden actualizarse. Quien canceló la suscripción no vuelve a recibir solo porque el elemento traiga unsubscribed: false: el elemento recibe el aviso kept_unsubscribed. Para volver a suscribir, envía resubscribe con la fecha y la prueba del nuevo consentimiento.
Cabeceras
Idempotency-KeystringOpcional. Repetir la llamada con la misma clave devuelve la respuesta guardada.
Cuerpo de la solicitud
contactsobject[]obligatorioDe 1 a 10.000 elementos.idstringID del contacto (se busca primero).external_idstringID de la persona en tu sistema (también aceptado comouser_id).emailstringCorreo electrónico. Obligatorio para crear un contacto; sinidniexternal_id, es la clave de búsqueda.first_namestring | nullNome.last_namestring | nullSobrenome.unsubscribedbooleantrueda de baja;falsesolo vale para contactos nuevos o que ya estaban suscritos.resubscribeobjectVuelve a suscribir a quien canceló la suscripción, junto conunsubscribed: false:consented_at(fecha, no puede ser futura) yproof(cómo dio su consentimiento la persona, hasta 500 caracteres).propertiesobjectValores de las propiedades.tagsstring[]Hasta 50 etiquetas (ID o nombre), solo añadidas.segmentsstring[]Hasta 100 segmentos estáticos, solo añadidos.topicsobject[]Hasta 100 elecciones{"id", "subscription"}.
sourcestringEtiqueta del origen de los cambios. También aceptada en la cabeceraX-Postal-Source.
En cada elemento de results, status es created, updated, unchanged o failed. Los elementos que fallaron traen error (y code, cuando lo hay) e id: null; los elementos con avisos traen warnings. index es la posición del elemento en la lista enviada.
Errores
- 422
validation_errorcontactsausente, vacío o con más de 10.000 elementos. Los errores de un elemento no generan este error: quedan enresults. - 403
plan_limit_reachedEl plan no permite más contactos: los elementos que crearían contactos fallan con este mensaje enresults.
cURL
curl -X POST https://api.postal.so/contacts/batch \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Idempotency-Key: sync-2026-10-24-01" \
-H "Content-Type: application/json" \
-d '{
"contacts": [
{
"external_id": "user_42",
"email": "[email protected]",
"first_name": "Ana",
"properties": {
"plan": "pro"
},
"tags": ["Cliente VIP"]
},
{
"external_id": "user_43",
"email": "[email protected]",
"topics": [
{
"id": "019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b",
"subscription": "opt_in"
}
]
}
]
}'
Node.js
import { Postal } from 'postal-so'
const postal = new Postal() // lê POSTAL_API_KEY
const batch = await postal.contacts.batch([
{ external_id: 'user_42', email: '[email protected]', first_name: 'Ana', properties: { plan: 'pro' }, tags: ['Cliente VIP'] },
{ external_id: 'user_43', email: '[email protected]', topics: [{ id: '019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b', subscription: 'opt_in' }] },
])
console.log(batch.counts) // { created: 1, updated: 1, unchanged: 0, failed: 0 }
Python
import os
import requests
response = requests.post(
"https://api.postal.so/contacts/batch",
headers={
"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}",
"Idempotency-Key": "sync-2026-10-24-01",
},
json={
"contacts": [
{
"external_id": "user_42",
"email": "[email protected]",
"first_name": "Ana",
"properties": {
"plan": "pro",
},
"tags": ["Cliente VIP"],
},
{
"external_id": "user_43",
"email": "[email protected]",
"topics": [
{
"id": "019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b",
"subscription": "opt_in",
},
],
},
],
},
timeout=10,
)
data = response.json()
PHP
use Postal\Client;
$postal = new Client(); // lê POSTAL_API_KEY
$batch = $postal->contacts->batch([
['external_id' => 'user_42', 'email' => '[email protected]', 'first_name' => 'Ana', 'properties' => ['plan' => 'pro'], 'tags' => ['Cliente VIP']],
['external_id' => 'user_43', 'email' => '[email protected]', 'topics' => [['id' => '019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b', 'subscription' => 'opt_in']]],
]);
print_r($batch['counts']); // created, updated, unchanged, failed
{
"object": "contact_batch",
"status": "completed",
"total": 2,
"counts": {
"created": 1,
"updated": 1,
"unchanged": 0,
"failed": 0
},
"results": [
{
"index": 0,
"status": "updated",
"id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"external_id": "user_42",
"user_id": "user_42",
"email": "[email protected]"
},
{
"index": 1,
"status": "created",
"id": "019a1e00-2b3c-7d4e-8f50-6b7c8d9e0f1a",
"external_id": "user_43",
"user_id": "user_43",
"email": "[email protected]"
}
]
}
#Obtener un lote de contactos
GET/contacts/batches/{id}
Muestra el avance de un lote grande (más de 1.000 elementos). status: queued, processing, completed o failed (con error). processed cuenta los elementos ya tratados, y results trae el resultado de cada uno cuando el lote termina.
Parámetros de ruta
idstringobligatorioID del lote.
Errores
- 404
not_foundEl recurso no existe o es de otro equipo.
cURL
curl https://api.postal.so/contacts/batches/019a1e30-9aa1-7db3-8e2f-4a5b6c7d8e9f \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/contacts/batches/019a1e30-9aa1-7db3-8e2f-4a5b6c7d8e9f', {
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.get(
"https://api.postal.so/contacts/batches/019a1e30-9aa1-7db3-8e2f-4a5b6c7d8e9f",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('contacts/batches/019a1e30-9aa1-7db3-8e2f-4a5b6c7d8e9f', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "contact_batch",
"id": "019a1e30-9aa1-7db3-8e2f-4a5b6c7d8e9f",
"status": "completed",
"source": "api",
"total": 2,
"processed": 2,
"counts": {
"created": 1,
"updated": 1,
"unchanged": 0,
"failed": 0
},
"error": null,
"results": [
{
"index": 0,
"status": "updated",
"id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"external_id": "user_42",
"user_id": "user_42",
"email": "[email protected]"
},
{
"index": 1,
"status": "created",
"id": "019a1e00-2b3c-7d4e-8f50-6b7c8d9e0f1a",
"external_id": "user_43",
"user_id": "user_43",
"email": "[email protected]"
}
],
"created_at": "2026-10-24T14:20:00.000000Z",
"started_at": "2026-10-24T14:20:01.000000Z",
"completed_at": "2026-10-24T14:20:03.000000Z"
}
#Resumen del consentimiento
GET/contacts/consent-summary
Cuántos contactos esperan la confirmación para recibir marketing (unconfirmed, los guardados de los correos transaccionales), cuántos ya están confirmados y la última confirmación masiva. Es lo que alimenta la tarjeta contactos pendientes de tu confirmación del panel.
unconfirmed y confirmed suman el total de contactos. En equipos muy grandes el número puede ser una estimación, y entonces la respuesta trae estimated: true. last_confirmation es null mientras nadie haya confirmado; user es null si la cuenta de quien confirmó ya no existe. El resultado se guarda en caché durante 30 segundos por equipo y se renueva con cada bloque confirmado.
cURL
curl https://api.postal.so/contacts/consent-summary \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/contacts/consent-summary', {
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.get(
"https://api.postal.so/contacts/consent-summary",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('contacts/consent-summary', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "contact_consent_summary",
"unconfirmed": 4213,
"confirmed": 9871,
"last_confirmation": {
"at": "2026-10-27T13:05:00Z",
"user": {
"id": 7,
"name": "Carla Mendes"
},
"count": 1150
}
}
#Acción masiva sobre contactos
POST/contacts/bulk
Ejecuta una acción sobre varios contactos a la vez: los que elegiste (ids) o todos los que coinciden con unos filtros. La selección se fija al aceptar la solicitud: los contactos creados después no entran. Hasta 200 contactos terminan en la propia solicitud (200, con la operación terminada); por encima de eso la respuesta es 202 con la operación en queued, para seguirla en GET /contacts/bulk/{id}. El equipo hace una operación masiva a la vez.
Acciones: delete, unsubscribe, resubscribe, set_topic (topic_id y subscription), add_tags y remove_tags (tags), add_to_segment y remove_from_segment (segment_id, solo estático), verify, export y confirm_consent. Toda acción es idempotente por contacto: quien ya está en el estado final cuenta en skipped y el motivo va en result.skipped_reasons. Para ver antes cuántos contactos cambiaría la acción, usa POST /contacts/bulk/estimate con el mismo cuerpo (no pide la declaración); POST /contacts/bulk/{id}/cancel cancela una operación en curso y conserva lo que ya hizo.
confirm_consent: confirmar que aceptan recibir. Marca los contactos unconfirmed de la selección como confirmed (y guarda consent_confirmed_at), para que pasen a recibir campañas y automatizaciones. Es la declaración de que esas personas aceptaron recibir correos de marketing de tu empresa y de que tú eres el responsable de ese consentimiento: por eso params.accept_responsibility tiene que ser true. Solo el propietario y los administradores del equipo pueden, y solo desde el panel: una clave de API recibe 403 forbidden, igual que un miembro común. Sin la declaración, la respuesta es 422.
Para confirmar todos los contactos sin confirmar, selecciona por filtro: {"mode": "filter", "filters": {"consent": "unconfirmed"}}. Cambia solo a quien todavía es unconfirmed; quien ya estaba confirmado cuenta en skipped (already_confirmed) y no pierde la fecha. No envía el webhook contact.updated ni inicia automatizaciones. El tope de la selección es de 2.000.000 de contactos (en las demás acciones, 200.000). Cada solicitud guarda un registro de auditoría: quién confirmó, cuándo, cuántos contactos, la selección, la versión del texto de la declaración (statement_version) y la IP. Repetir la solicitud es seguro y solo genera otro registro, con 0 contactos.
Cabeceras
Idempotency-KeystringOpcional. Repetir la llamada con la misma clave devuelve la respuesta guardada.
Cuerpo de la solicitud
actionstringobligatorioLa acción:delete,unsubscribe,resubscribe,set_topic,add_tags,remove_tags,add_to_segment,remove_from_segment,verify,exportoconfirm_consent.selectionobjectobligatorioQué contactos.modestringobligatorioidsofilter.idsstring[]Conmode: "ids": de 1 a 1.000 IDs de contacto. Los IDs de otro equipo se ignoran.filtersobjectConmode: "filter": los mismos filtros deGET /contacts(search,segment_id,tag,consent,origin…). Un filtro desconocido da422.excluded_idsstring[]Conmode: "filter": hasta 5.000 contactos que dejar fuera.expected_countintegerEl número de contactos que vio la persona. Si el real es muy distinto, la respuesta es409 selection_changed.
paramsobjectLos parámetros de la acción.accept_responsibilitybooleanobligatorioSolo enconfirm_consent, y obligatorio:truees la declaración de que los contactos aceptaron recibir marketing y de que tú eres el responsable de ese consentimiento.
Errores
- 403
forbiddenconfirm_consentdesde una clave de API o desde un miembro que no es propietario ni administrador. - 422
validation_errorAcción, selección o filtro no válidos, selección vacía o por encima del límite, oaccept_responsibilityausente enconfirm_consent(el mensaje dice que envíesaccept_responsibility: true). - 409
selection_changedEl número de contactos cambió respecto aexpected_count; la respuesta trae elcountactual. - 409
bulk_operation_runningEl equipo ya tiene una operación masiva en cola o en curso (viene enoperation). - 401
restricted_api_keyLa clave es solo de envío.
cURL
curl -X POST https://api.postal.so/contacts/bulk \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"action": "add_to_segment",
"selection": {
"mode": "ids",
"ids": ["019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f"]
},
"params": {
"segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a"
}
}'
Node.js
const response = await fetch('https://api.postal.so/contacts/bulk', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'add_to_segment',
selection: {
mode: 'ids',
ids: ['019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f'],
},
params: {
segment_id: '019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a',
},
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/contacts/bulk",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"action": "add_to_segment",
"selection": {
"mode": "ids",
"ids": ["019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f"],
},
"params": {
"segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
},
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('contacts/bulk', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'action' => 'add_to_segment',
'selection' => [
'mode' => 'ids',
'ids' => ['019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f'],
],
'params' => [
'segment_id' => '019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a',
],
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "contact_bulk_operation",
"id": "019a1e50-bcc3-7fd5-8a40-6c7d8e9fa0b1",
"action": "add_to_segment",
"status": "completed",
"total": 1,
"processed": 1,
"changed": 1,
"skipped": 0,
"failed": 0,
"errors": [],
"params": {
"segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a"
},
"selection_mode": "ids",
"cancel_requested": false,
"error": null,
"created_at": "2026-10-29T15:00:00.000000Z",
"started_at": "2026-10-29T15:00:01.000000Z",
"completed_at": "2026-10-29T15:00:09.000000Z",
"created_by": null,
"result": {
"not_found": 0,
"skipped_reasons": {}
}
}
{
"action": "confirm_consent",
"selection": { "mode": "filter", "filters": { "consent": "unconfirmed" } },
"params": { "accept_responsibility": true }
}
{
"object": "contact_bulk_operation",
"id": "019a1e50-bcc3-7fd5-8a40-6c7d8e9fa0b1",
"action": "confirm_consent",
"status": "queued",
"total": 4213,
"processed": 0,
"changed": 0,
"skipped": 0,
"failed": 0,
"errors": [],
"params": {
"accept_responsibility": true,
"statement_version": "2026-10-v1"
},
"selection_mode": "filter",
"cancel_requested": false,
"error": null,
"created_at": "2026-10-29T15:00:00.000000Z",
"started_at": null,
"completed_at": null,
"created_by": {
"id": 7,
"name": "Carla Mendes"
},
"result": {
"not_found": 0
}
}
#Obtener una acción masiva
GET/contacts/bulk/{id}
Muestra el progreso de una acción masiva. status: queued, running, completed, failed o canceled. total es el tamaño de la selección fijada; processed, changed, skipped y failed cuentan lo que ya se hizo; errors trae los 20 primeros contactos que fallaron. Una operación que termina completed puede tener failed mayor que cero. En confirm_consent, changed es cuántos contactos se confirmaron. GET /contacts/bulk lista las operaciones de los últimos 30 días, de la más nueva a la más antigua (filtro status).
Parámetros de ruta
idstringobligatorioID de la operación.
Errores
- 404
not_foundEl recurso no existe o es de otro equipo.
cURL
curl https://api.postal.so/contacts/bulk/019a1e50-bcc3-7fd5-8a40-6c7d8e9fa0b1 \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/contacts/bulk/019a1e50-bcc3-7fd5-8a40-6c7d8e9fa0b1', {
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.get(
"https://api.postal.so/contacts/bulk/019a1e50-bcc3-7fd5-8a40-6c7d8e9fa0b1",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('contacts/bulk/019a1e50-bcc3-7fd5-8a40-6c7d8e9fa0b1', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "contact_bulk_operation",
"id": "019a1e50-bcc3-7fd5-8a40-6c7d8e9fa0b1",
"action": "confirm_consent",
"status": "completed",
"total": 4213,
"processed": 4213,
"changed": 4200,
"skipped": 13,
"failed": 0,
"errors": [],
"params": {
"accept_responsibility": true,
"statement_version": "2026-10-v1"
},
"selection_mode": "filter",
"cancel_requested": false,
"error": null,
"created_at": "2026-10-29T15:00:00.000000Z",
"started_at": "2026-10-29T15:00:01.000000Z",
"completed_at": "2026-10-29T15:00:09.000000Z",
"created_by": {
"id": 7,
"name": "Carla Mendes"
},
"result": {
"not_found": 0,
"skipped_reasons": {
"already_confirmed": 13
}
}
}
#Etiquetas
Las etiquetas (tags) marcan contactos de forma libre, sin necesidad de un segmento: "Cliente VIP", "Vino del evento". El nombre tiene hasta 64 caracteres y es único en el equipo sin distinguir mayúsculas. Cada etiqueta tiene un color: gray, green, yellow, red, blue o purple. En los endpoints de contactos, una etiqueta puede darse por ID o por nombre, y los nombres que aún no existen se crean. Todo contacto que recibe una etiqueta nueva inicia las automatizaciones con el disparador de etiqueta añadida (tag.added), y las etiquetas también sirven de filtro en segmentos dinámicos y en GET /contacts?tag=.
#Añadir etiquetas a un contacto
POST/contacts/{id}/tags
Añade las etiquetas al contacto (las que ya tenía quedan como están) y devuelve todas sus etiquetas. Los nombres que no existen se crean, con el siguiente color de la paleta.
Parámetros de ruta
idstringobligatorioID o correo electrónico del contacto.
Cuerpo de la solicitud
tagsstring[]obligatorioDe 1 a 50 etiquetas, por ID o por nombre.
Errores
- 422
validation_errorAlgún campo no es válido. El mensaje indica cuál. - 404
not_foundEl contacto o alguna etiqueta (dada por ID) no existe.
cURL
curl -X POST https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/tags \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"tags": ["Cliente VIP"]
}'
Node.js
const response = await fetch('https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/tags', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
tags: ['Cliente VIP'],
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/tags",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"tags": ["Cliente VIP"],
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/tags', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'tags' => ['Cliente VIP'],
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "list",
"has_more": false,
"data": [
{
"id": "019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f",
"name": "Cliente VIP",
"color": "purple"
}
]
}
#Quitar una etiqueta de un contacto
DELETE/contacts/{id}/tags/{tag}
Quita la etiqueta del contacto y devuelve las etiquetas que quedaron. La etiqueta sigue existiendo.
Parámetros de ruta
idstringobligatorioID o correo electrónico del contacto.tagstringobligatorioID o nombre de la etiqueta.
Errores
- 404
not_foundEl contacto o la etiqueta no existe.
cURL
curl -X DELETE https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f', {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.delete(
"https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->delete('contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "list",
"has_more": false,
"data": []
}
#Listar etiquetas
GET/tags
Las etiquetas del equipo, cada una con contacts_count (cuántos contactos la tienen).
Parámetros de consulta
limitintegerDe 1 a 100. Predeterminado: 20.afterstringCursor de la página siguiente.beforestringCursor de la página anterior.searchstringBúsqueda por nombre.
cURL
curl https://api.postal.so/tags \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/tags', {
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.get(
"https://api.postal.so/tags",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('tags', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "list",
"has_more": false,
"data": [
{
"object": "tag",
"id": "019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f",
"name": "Cliente VIP",
"color": "purple",
"contacts_count": 86,
"created_at": "2026-10-20T09:00:00.000000Z",
"updated_at": "2026-10-20T09:00:00.000000Z"
}
]
}
#Crear una etiqueta
POST/tags
Cuerpo de la solicitud
namestringobligatorioNombre, hasta 64 caracteres, único en el equipo sin distinguir mayúsculas.colorstringgray,green,yellow,red,blueopurple. Por defecto: el siguiente color de la paleta.
Errores
- 422
validation_errorNombre vacío, demasiado largo o ya usado, o color fuera de la lista.
cURL
curl -X POST https://api.postal.so/tags \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Cliente VIP",
"color": "purple"
}'
Node.js
const response = await fetch('https://api.postal.so/tags', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'Cliente VIP',
color: 'purple',
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/tags",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"name": "Cliente VIP",
"color": "purple",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('tags', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'name' => 'Cliente VIP',
'color' => 'purple',
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "tag",
"id": "019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f",
"name": "Cliente VIP",
"color": "purple",
"contacts_count": 0,
"created_at": "2026-10-20T09:00:00.000000Z",
"updated_at": "2026-10-20T09:00:00.000000Z"
}
#Obtener una etiqueta
GET/tags/{id}
Parámetros de ruta
idstringobligatorioID de la etiqueta.
Errores
- 404
not_foundEl recurso no existe o es de otro equipo.
cURL
curl https://api.postal.so/tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f', {
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.get(
"https://api.postal.so/tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "tag",
"id": "019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f",
"name": "Cliente VIP",
"color": "purple",
"contacts_count": 86,
"created_at": "2026-10-20T09:00:00.000000Z",
"updated_at": "2026-10-20T09:00:00.000000Z"
}
#Actualizar una etiqueta
PATCH/tags/{id}
Parámetros de ruta
idstringobligatorioID de la etiqueta.
Cuerpo de la solicitud
namestringNombre nuevo.colorstringNuevo color.
Errores
- 422
validation_errorAlgún campo no es válido. El mensaje indica cuál. - 404
not_foundEl recurso no existe o es de otro equipo.
cURL
curl -X PATCH https://api.postal.so/tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"color": "blue"
}'
Node.js
const response = await fetch('https://api.postal.so/tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
color: 'blue',
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.patch(
"https://api.postal.so/tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"color": "blue",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->patch('tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'color' => 'blue',
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "tag",
"id": "019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f",
"name": "Cliente VIP",
"color": "blue",
"contacts_count": 86,
"created_at": "2026-10-20T09:00:00.000000Z",
"updated_at": "2026-10-20T09:00:00.000000Z"
}
#Eliminar una etiqueta
DELETE/tags/{id}
Quita la etiqueta de todos los contactos; los contactos siguen en la audiencia. Una etiqueta usada por una automatización no se puede eliminar: quítala de la automatización antes.
Parámetros de ruta
idstringobligatorioID de la etiqueta.
Errores
- 409
tag_in_useAlguna automatización usa esta etiqueta. El mensaje indica cuáles. - 404
not_foundEl recurso no existe o es de otro equipo.
cURL
curl -X DELETE https://api.postal.so/tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f', {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.delete(
"https://api.postal.so/tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->delete('tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "tag",
"id": "019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f",
"deleted": true
}
#Etiquetar varios contactos
POST/tags/{id}/contacts
Aplica la etiqueta a varios contactos a la vez. Quien ya la tenía queda como está; added cuenta solo a los que recibieron la etiqueta ahora.
Parámetros de ruta
idstringobligatorioID de la etiqueta.
Cuerpo de la solicitud
contactsstring[]obligatorioHasta 1.000 contactos por llamada, cada uno por ID o por correo electrónico. Quien no exista se ignora.
Errores
- 422
validation_errorAlgún campo no es válido. El mensaje indica cuál. - 404
not_foundEl recurso no existe o es de otro equipo.
cURL
curl -X POST https://api.postal.so/tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f/contacts \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"contacts": [
"019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"[email protected]"
]
}'
Node.js
const response = await fetch('https://api.postal.so/tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f/contacts', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
contacts: [
'019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f',
'[email protected]',
],
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f/contacts",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"contacts": [
"019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"[email protected]",
],
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f/contacts', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'contacts' => [
'019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f',
'[email protected]',
],
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "tag_contacts",
"tag_id": "019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f",
"contacts": 2,
"added": 1
}
#Quitar la etiqueta a varios contactos
DELETE/tags/{id}/contacts
Parámetros de ruta
idstringobligatorioID de la etiqueta.
Cuerpo de la solicitud
contactsstring[]obligatorioHasta 1.000 contactos por llamada, cada uno por ID o por correo electrónico. Quien no exista se ignora.
Errores
- 422
validation_errorAlgún campo no es válido. El mensaje indica cuál. - 404
not_foundEl recurso no existe o es de otro equipo.
cURL
curl -X DELETE https://api.postal.so/tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f/contacts \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"contacts": ["019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f"]
}'
Node.js
const response = await fetch('https://api.postal.so/tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f/contacts', {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
contacts: ['019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f'],
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.delete(
"https://api.postal.so/tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f/contacts",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"contacts": ["019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f"],
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->delete('tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f/contacts', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'contacts' => ['019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f'],
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "tag_contacts",
"tag_id": "019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f",
"contacts": 1,
"deleted": true
}