Contatos
As pessoas da sua audiência: dados, propriedades, segmentos e inscrição em tópicos.
Contatos são as pessoas da sua audiência de marketing: e-mail, nome, propriedades personalizadas, segmentos e a inscrição em tópicos. Cada e-mail aparece uma vez por time. O número de contatos depende do plano de marketing (Grátis: 1.000, Profissional: 5.000, Escala: 50.000, Empresarial: sem limite). Nos endpoints com {id}, você pode usar o ID ou o próprio e-mail do contato. Para trazer muitos contatos de uma vez, use a importação de CSV. Endereços que estão na lista de supressão nunca viram contatos (veja Criar um contato).
Cada contato também guarda o external_id (apelido user_id): o ID da pessoa no seu sistema, único no time. Em qualquer endpoint com {id}, você pode usar external_id:user_42 (ou user_id:user_42) no lugar do ID e do e-mail. As etiquetas marcam contatos sem precisar de um segmento, e o lote cria ou atualiza até 10.000 contatos numa chamada. Todos os endpoints desta página exigem uma chave de API de acesso total (uma chave só de envio recebe 401 restricted_api_key).
Quem recebe um e-mail transacional seu também vira contato, sozinho, mas não confirmado para marketing: veja Contatos guardados dos e-mails transacionais e os campos marketing_consent, origin e consent_confirmed_at.
#Origem das alterações
Cada alteração de um contato guarda de onde veio em last_change_source: api, dashboard, form, import, automation, partner, system, transactional, postal_lifecycle, crm:{id da conexão} ou um rótulo seu. Para dar um rótulo à alteração, envie o cabeçalho X-Postal-Source ou o campo source no corpo (se os dois vierem, vale o campo): até 64 caracteres entre letras minúsculas, números, ., _, : e -. Os nomes que a Postal usa e o prefixo crm: são reservados e geram 422. Os webhooks contact.* trazem o campo source, e um endpoint de webhook pode ignorar origens (skip_sources), o que evita eco entre dois sistemas que se sincronizam nos dois sentidos. Na lista, ?source= filtra por origem.
#Contatos guardados dos e-mails transacionais
Todo endereço que recebe um e-mail transacional seu fica guardado, sozinho, na audiência do time, para você poder mandar marketing depois a quem já interagiu com a sua empresa (quem preencheu um formulário de contato, quem se cadastrou…). Entram os destinatários de POST /emails, de POST /emails/batch, do SMTP e dos e-mails de autenticação das conexões do Supabase, só em to e cc (nunca bcc). Não entram os destinatários de campanhas, automações, testes de template e de campanha, encaminhamentos, respostas da inbox e envios para os endereços de teste. O contato é criado em segundo plano, depois que o provedor aceitou o e-mail, sem atrasar o envio. O nome de exibição (Ana Souza <[email protected]>) vira first_name e last_name, quando existe.
| Campo | Valores | O que diz |
|---|---|---|
marketing_consent | confirmed, unconfirmed | Se o contato pode receber marketing (campanhas e automações). Todo contato que já existia e todo criado de outro jeito (POST /contacts, lote, eventos, importação, formulário, CRM) é confirmed. O contato guardado de um e-mail transacional nasce unconfirmed. |
origin | transactional, null | De onde o contato veio: transactional para os guardados dos e-mails transacionais; null para os demais. Não muda quando o contato é confirmado. |
consent_confirmed_at | data ou null | Quando o contato foi confirmado pela declaração do dono ou de um admin (ação em massa confirm_consent) ou por uma ação explícita. null nos que nunca foram confirmados dessa forma. |
- Fora de campanhas e automações. Um contato
unconfirmednão entra na lista de envio de uma campanha (nem emrecipients_counteGET /broadcasts/audience), não inicia automações e o passo de enviar e-mail pula quem deixou de estar confirmado. Os seus envios transacionais para ele não mudam. Ele está na audiência de verdade: conta no limite de contatos, aparece emGET /contacts, nos contadores e nos segmentos. - Silencioso. A captura não dispara o webhook
contact.created, nenhuma automação (contact.created,segment.added…) e não empurra o contato para um CRM conectado. A origem da alteração étransactional. - Nunca mexe no que existe. Um contato que já existe (com o consentimento, os tópicos, o descadastro e os dados que tem) fica como está, e um endereço da lista de supressão nunca vira contato.
- Nunca passa do limite do plano. A captura usa o limite de contatos do plano de marketing e para ao chegar nele, mesmo com o excedente ligado (o excedente cobra contatos que você adicionou de propósito; a captura automática não cobra ninguém). Ela pausa em silêncio e volta sozinha quando houver espaço. O bloco
audiencedeGET /usagediz se está pausada. - Segmento padrão. Na primeira captura, se o plano inclui segmentos dinâmicos e tem espaço para mais um, nasce o segmento dinâmico Envios transacionais (
originétransactional), uma vez só: se você apagar, não volta. - O que confirma um contato. A confirmação em massa e as ações explícitas: reimportar o endereço por CSV,
POST /contacts/batchePOST /eventscom o contato,PATCH /contacts/{id}e a inscrição da pessoa num formulário. Confirmar não disparacontact.created, e uma captura nunca rebaixa um contato já confirmado. - Desligar. A captura vem ligada. A chave do time,
capture_transactional_contacts, muda comPATCH /teame{"capture_transactional_contacts": false}, mas só o dono e os admins, e só pelo painel (Configurações › Time): a rota usa a sessão do painel e não aceita chave de API. Os e-mails que já estavam na fila antes do recurso entrar no ar não são capturados.
Para listar só esses contatos, use os filtros consent=unconfirmed e origin=transactional em GET /contacts; eles valem também nas seleções das ações em massa, nas exportações e (como as condições consent e origin) nos segmentos dinâmicos. Para ver quantos esperam confirmação, use GET /contacts/consent-summary.
#Listar contatos
GET/contacts
Do mais recente para o mais antigo. A lista não traz os segmentos de cada contato.
Parâmetros de consulta
limitintegerDe 1 a 100. Padrão: 20.afterstringCursor da próxima página.beforestringCursor da página anterior.searchstringBusca no e-mail, noexternal_ide no nome.segment_idstringSó contatos de um segmento: os da lista, se o segmento for estático, ou os que combinam com os filtros agora, se for dinâmico.topic_idstringSó contatos inscritos no tópico: os que escolheram receber e, se o tópico foropt_inpor padrão, os que não escolheram nada.tagstringSó contatos com a etiqueta (ID ou nome, sem diferenciar maiúsculas).unsubscribedbooleantruepara os descadastrados;falsepara os demais.email_statusstringResultado da validação de e-mails, separado por vírgula:valid,invalid,riskyeunverified(nunca validado).external_idstringSó o contato com esteexternal_id(também aceito comouser_id).sourcestringSó contatos cuja última alteração veio desta origem.consentstringconfirmedouunconfirmed: só contatos que podem (ou que ainda não podem) receber marketing. Veja Contatos guardados dos e-mails transacionais.originstringtransactional: só os contatos guardados dos e-mails transacionais.
Erros
- 422
validation_errorAlgum campo é inválido. A mensagem diz qual.
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"
}
]
}
]
}
#Criar um contato
POST/contacts
Cria o contato e, se quiser, já o coloca em segmentos estáticos, aplica etiquetas e registra a escolha dele nos tópicos. Dispara o webhook contact.created.
Um endereço que está na lista de supressão do time nunca vira contato: a resposta é 422 com email_suppressed. Para cadastrá-lo, remova-o antes da lista de supressão. Contatos que já existem não são afetados.
Quer criar ou atualizar sem saber se o contato já existe? Use o lote, que procura por ID, external_id e e-mail.
Corpo da requisição
emailstringobrigatórioE-mail, até 320 caracteres. Guardado em minúsculas.external_idstring | nullID da pessoa no seu sistema, até 255 caracteres, único no time. Também aceito comouser_id.first_namestring | nullNome, até 255 caracteres.last_namestring | nullSobrenome, até 255 caracteres.unsubscribedbooleanDescadastrado de todos os envios de marketing. Padrão:false.propertiesobjectValores das propriedades, como{"plan": "pro"}. Cada chave precisa existir antes; os valores são convertidos para o tipo da propriedade.segmentsstring[]IDs de até 100 segmentos estáticos (ou objetos{"id": "…"}). Segmentos dinâmicos não aceitam contatos à mão.topicsobject[]Até 100 escolhas de tópicos.idstringobrigatórioID do tópico.subscriptionstringobrigatórioopt_in(quer receber) ouopt_out(não quer).
tagsstring[]Até 50 etiquetas, por ID ou por nome. Os nomes que ainda não existem são criados.sourcestringRótulo da origem da alteração. Também aceito no cabeçalhoX-Postal-Source.
Erros
- 422
validation_errorE-mail inválido ou já cadastrado,external_idjá usado por outro contato, propriedade desconhecida, valor com o tipo errado, ou segmento dinâmico emsegments. - 422
email_suppressedO e-mail está na lista de supressão do time, então não pode virar contato. - 403
plan_limit_reachedO plano não permite mais contatos, contando os pacotes do Adicional de contatos (a mensagem diz como continuar acima do limite ou adicionar um pacote, se o plano permitir). - 404
not_foundAlgum segmento, tópico ou etiqueta (dada por ID) não 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"
}
#Obter um contato
GET/contacts/{id}
Devolve o contato com os segmentos de que ele faz parte e as etiquetas. email_status e email_verified_at vêm da última validação de e-mails do endereço e são null se ele nunca foi validado.
Parâmetros de caminho
idstringobrigatórioID ou e-mail do contato.
Erros
- 404
not_foundO recurso não existe ou é de outro time.
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"
}
]
}
#Atualizar um contato
PATCH/contacts/{id}
Só os campos enviados mudam. Em properties, as chaves enviadas são atualizadas e as outras ficam como estão; mande null ou "" para apagar um valor. Segmentos e tópicos têm endpoints próprios (abaixo). Se algo mudou, dispara contact.updated.
Em tags, a lista enviada substitui as etiquetas do contato; para só acrescentar ou tirar uma, use os endpoints de etiquetas do contato. No lote, quem se descadastrou só é reinscrito com a prova do novo consentimento (resubscribe).
Atualizar um contato unconfirmed (guardado de um e-mail transacional) é uma ação explícita: ele passa a confirmed.
Parâmetros de caminho
idstringobrigatórioID ou e-mail do contato.
Corpo da requisição (todos opcionais)
emailstringNovo e-mail.external_idstring | nullNovoexternal_id(ouuser_id).first_namestring | nullNome.last_namestring | nullSobrenome.unsubscribedbooleanDescadastra (true) ou reinscreve (false).propertiesobjectValores a mudar.tagsstring[]Até 50 etiquetas (ID ou nome) que passam a ser as únicas do contato.sourcestringRótulo da origem da alteração.
Erros
- 422
validation_errorE-mail ouexternal_idjá usado por outro contato, propriedade desconhecida, valor inválido, ousegments/topicsno corpo. - 404
not_foundO recurso não existe ou é de outro time.
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"
}
#Excluir um contato
DELETE/contacts/{id}
Remove o contato da audiência, com os segmentos e as escolhas de tópicos. Dispara contact.deleted. Para só parar de enviar, prefira unsubscribed: true.
Parâmetros de caminho
idstringobrigatórioID ou e-mail do contato.
Erros
- 404
not_foundO recurso não existe ou é de outro time.
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 um contato
GET/contacts/{id}/segments
Os segmentos de que o contato faz parte: os estáticos a que foi adicionado e os dinâmicos cujos filtros ele combina (conforme a última checagem). Aceita limit, after e before.
Parâmetros de caminho
idstringobrigatórioID ou e-mail do contato.
Erros
- 404
not_foundO recurso não existe ou é de outro time.
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"
}
]
}
#Adicionar a um segmento
POST/contacts/{id}/segments/{segment_id}
Coloca o contato no segmento. Se ele já estiver lá, nada muda. Só segmentos estáticos aceitam contatos à mão.
Parâmetros de caminho
idstringobrigatórioID ou e-mail do contato.segment_idstringobrigatórioID do segmento.
Erros
- 404
not_foundO contato ou o segmento não existe. - 422
validation_errorO segmento é dinâmico: ele guarda quem combina com os filtros, e não aceita contatos à mão.
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"
}
#Remover de um segmento
DELETE/contacts/{id}/segments/{segment_id}
Parâmetros de caminho
idstringobrigatórioID ou e-mail do contato.segment_idstringobrigatórioID do segmento.
Erros
- 404
not_foundO contato ou o segmento não existe. - 422
validation_errorO segmento é dinâmico: ele guarda quem combina com os filtros, e não aceita contatos à mão.
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
}
#Tópicos de um contato
GET/contacts/{id}/topics
Todos os tópicos do time, em ordem alfabética, com a inscrição do contato: a escolha dele ou, se ele não escolheu, o padrão do tópico.
Parâmetros de caminho
idstringobrigatórioID ou e-mail do contato.
Erros
- 404
not_foundO recurso não existe ou é de outro time.
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"
}
]
}
#Mudar a inscrição em tópicos
PATCH/contacts/{id}/topics
Registra a escolha do contato em um ou mais tópicos. Os tópicos não enviados ficam como estão.
Parâmetros de caminho
idstringobrigatórioID ou e-mail do contato.
Corpo da requisição
topicsobject[]obrigatórioDe 1 a 100 escolhas, sem tópicos repetidos.idstringobrigatórioID do tópico.subscriptionstringobrigatórioopt_inouopt_out.
Erros
- 422
validation_errorAlgum campo é inválido. A mensagem diz qual. - 404
not_foundO contato ou algum tópico não 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"
}
#Criar ou atualizar contatos em lote
POST/contacts/batch
Cria ou atualiza vários contatos de uma vez (upsert). Cada item procura o contato pelo id, depois pelo external_id e depois pelo e-mail; se não achar e houver e-mail, cria. Achado pelo external_id, um e-mail novo substitui o antigo; achado pelo e-mail, o external_id é gravado se o contato ainda não tinha um.
Até 1.000 itens são processados na própria requisição e a resposta traz um resultado por item (200): um item com erro não derruba os outros. Acima disso (até 10.000), o lote vai para a fila e a resposta é 202 com o id do lote, para acompanhar em GET /contacts/batches/{id}. Dá para repetir a chamada com segurança: use o cabeçalho Idempotency-Key.
Regras por item: etiquetas e segmentos são só acrescentados (nunca removidos); tópicos são gravados; propriedades são conferidas com as definições do time e mescladas; endereços da lista de supressão não viram contatos (o item falha com code: "email_suppressed"), mas contatos que já existem podem ser atualizados. Quem se descadastrou não volta a receber só porque o item traz unsubscribed: false: o item ganha o aviso kept_unsubscribed. Para reinscrever, envie resubscribe com a data e a prova do novo consentimento.
Cabeçalhos
Idempotency-KeystringOpcional. Repetir a chamada com a mesma chave devolve a resposta guardada.
Corpo da requisição
contactsobject[]obrigatórioDe 1 a 10.000 itens.idstringID do contato (procurado primeiro).external_idstringID da pessoa no seu sistema (também aceito comouser_id).emailstringE-mail. Obrigatório para criar um contato; semidnemexternal_id, é a chave de busca.first_namestring | nullNome.last_namestring | nullSobrenome.unsubscribedbooleantruedescadastra;falsesó vale para contatos novos ou que já estavam inscritos.resubscribeobjectReinscreve quem se descadastrou, junto comunsubscribed: false:consented_at(data, não pode ser futura) eproof(como a pessoa consentiu, até 500 caracteres).propertiesobjectValores das propriedades.tagsstring[]Até 50 etiquetas (ID ou nome), só acrescentadas.segmentsstring[]Até 100 segmentos estáticos, só acrescentados.topicsobject[]Até 100 escolhas{"id", "subscription"}.
sourcestringRótulo da origem das alterações. Também aceito no cabeçalhoX-Postal-Source.
Em cada item de results, status é created, updated, unchanged ou failed. Itens que falharam trazem error (e code, quando há) e id: null; itens com avisos trazem warnings. index é a posição do item na lista enviada.
Erros
- 422
validation_errorcontactsausente, vazio ou com mais de 10.000 itens. Erros de um item não geram este erro: eles ficam emresults. - 403
plan_limit_reachedO plano não permite mais contatos: os itens que criariam contatos falham com esta mensagem emresults.
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]"
}
]
}
#Obter um lote de contatos
GET/contacts/batches/{id}
Mostra o andamento de um lote grande (acima de 1.000 itens). status: queued, processing, completed ou failed (com error). processed conta os itens já tratados, e results traz o resultado de cada um quando o lote termina.
Parâmetros de caminho
idstringobrigatórioID do lote.
Erros
- 404
not_foundO recurso não existe ou é de outro time.
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"
}
#Resumo do consentimento
GET/contacts/consent-summary
Quantos contatos esperam a confirmação para receber marketing (unconfirmed, os guardados dos e-mails transacionais), quantos já estão confirmados e a última confirmação em massa. É o que alimenta o cartão contatos aguardando a sua confirmação do painel.
unconfirmed e confirmed somam o total de contatos. Em times muito grandes o número pode ser uma estimativa, e então a resposta traz estimated: true. last_confirmation é null enquanto ninguém confirmou; user é null se a conta de quem confirmou não existe mais. O resultado fica em cache por 30 segundos por time e é renovado a cada bloco 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
}
}
#Ação em massa nos contatos
POST/contacts/bulk
Roda uma ação em vários contatos de uma vez: os que você escolheu (ids) ou todos os que combinam com filtros. A seleção é fixada ao aceitar o pedido: contatos criados depois não entram. Até 200 contatos terminam na própria requisição (200, com a operação pronta); acima disso a resposta é 202 com a operação em queued, para acompanhar em GET /contacts/bulk/{id}. O time faz uma operação em massa por vez.
Ações: delete, unsubscribe, resubscribe, set_topic (topic_id e subscription), add_tags e remove_tags (tags), add_to_segment e remove_from_segment (segment_id, só estático), verify, export e confirm_consent. Toda ação é idempotente por contato: quem já está no estado final conta em skipped e o motivo vai em result.skipped_reasons. Para ver antes quantos contatos a ação mudaria, use POST /contacts/bulk/estimate com o mesmo corpo (ele não pede a declaração); POST /contacts/bulk/{id}/cancel cancela uma operação em andamento e mantém o que ela já fez.
confirm_consent: confirmar que aceitam receber. Marca os contatos unconfirmed da seleção como confirmed (e grava consent_confirmed_at), para que passem a receber campanhas e automações. É a declaração de que essas pessoas aceitaram receber e-mails de marketing da sua empresa e de que você é o responsável por esse consentimento: por isso params.accept_responsibility precisa ser true. Só o dono e os admins do time podem, e só pelo painel: uma chave de API recebe 403 forbidden, assim como um membro comum. Sem a declaração, a resposta é 422.
Para confirmar todos os contatos não confirmados, selecione por filtro: {"mode": "filter", "filters": {"consent": "unconfirmed"}}. Muda só quem ainda é unconfirmed; quem já estava confirmado conta em skipped (already_confirmed) e não perde a data. Não manda o webhook contact.updated nem inicia automações. O teto da seleção é de 2.000.000 de contatos (nas outras ações, 200.000). Cada pedido grava um registro de auditoria: quem confirmou, quando, quantos contatos, a seleção, a versão do texto da declaração (statement_version) e o IP. Repetir o pedido é seguro e só gera outro registro, com 0 contatos.
Cabeçalhos
Idempotency-KeystringOpcional. Repetir a chamada com a mesma chave devolve a resposta guardada.
Corpo da requisição
actionstringobrigatórioA ação:delete,unsubscribe,resubscribe,set_topic,add_tags,remove_tags,add_to_segment,remove_from_segment,verify,exportouconfirm_consent.selectionobjectobrigatórioQuais contatos.modestringobrigatórioidsoufilter.idsstring[]Commode: "ids": de 1 a 1.000 IDs de contato. IDs de outro time são ignorados.filtersobjectCommode: "filter": os mesmos filtros deGET /contacts(search,segment_id,tag,consent,origin…). Um filtro desconhecido dá422.excluded_idsstring[]Commode: "filter": até 5.000 contatos a deixar de fora.expected_countintegerO número de contatos que a pessoa viu. Se o real for muito diferente, a resposta é409 selection_changed.
paramsobjectOs parâmetros da ação.accept_responsibilitybooleanobrigatórioSó emconfirm_consent, e obrigatório:trueé a declaração de que os contatos aceitaram receber marketing e de que você é o responsável por esse consentimento.
Erros
- 403
forbiddenconfirm_consentpor uma chave de API ou por um membro que não é dono nem admin. - 422
validation_errorAção, seleção ou filtro inválido, seleção vazia ou acima do limite, ouaccept_responsibilityausente emconfirm_consent(a mensagem diz para enviaraccept_responsibility: true). - 409
selection_changedO número de contatos mudou em relação aexpected_count; a resposta traz ocountatual. - 409
bulk_operation_runningO time já tem uma operação em massa na fila ou em andamento (vem emoperation). - 401
restricted_api_keyA chave é só de envio.
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
}
}
#Obter uma ação em massa
GET/contacts/bulk/{id}
Mostra o andamento de uma ação em massa. status: queued, running, completed, failed ou canceled. total é o tamanho da seleção fixada; processed, changed, skipped e failed contam o que já foi feito; errors traz os 20 primeiros contatos que falharam. Uma operação que termina completed pode ter failed maior que zero. Em confirm_consent, changed é quantos contatos foram confirmados. GET /contacts/bulk lista as operações dos últimos 30 dias, da mais nova para a mais antiga (filtro status).
Parâmetros de caminho
idstringobrigatórioID da operação.
Erros
- 404
not_foundO recurso não existe ou é de outro time.
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
Etiquetas (tags) marcam contatos de forma livre, sem a necessidade de um segmento: "Cliente VIP", "Veio do evento". O nome tem até 64 caracteres e é único no time sem diferenciar maiúsculas. Cada etiqueta tem uma cor: gray, green, yellow, red, blue ou purple. Nos endpoints de contatos, uma etiqueta pode ser dada pelo ID ou pelo nome, e os nomes que ainda não existem são criados. Todo contato que ganha uma etiqueta nova inicia as automações com o gatilho de etiqueta adicionada (tag.added), e as etiquetas também servem de filtro em segmentos dinâmicos e em GET /contacts?tag=.
#Adicionar etiquetas a um contato
POST/contacts/{id}/tags
Acrescenta as etiquetas ao contato (as que ele já tinha ficam como estão) e devolve todas as etiquetas dele. Os nomes que não existem são criados, com a próxima cor da paleta.
Parâmetros de caminho
idstringobrigatórioID ou e-mail do contato.
Corpo da requisição
tagsstring[]obrigatórioDe 1 a 50 etiquetas, por ID ou por nome.
Erros
- 422
validation_errorAlgum campo é inválido. A mensagem diz qual. - 404
not_foundO contato ou alguma etiqueta (dada por ID) não 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"
}
]
}
#Remover uma etiqueta de um contato
DELETE/contacts/{id}/tags/{tag}
Tira a etiqueta do contato e devolve as etiquetas que sobraram. A etiqueta continua existindo.
Parâmetros de caminho
idstringobrigatórioID ou e-mail do contato.tagstringobrigatórioID ou nome da etiqueta.
Erros
- 404
not_foundO contato ou a etiqueta não 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
As etiquetas do time, cada uma com contacts_count (quantos contatos a têm).
Parâmetros de consulta
limitintegerDe 1 a 100. Padrão: 20.afterstringCursor da próxima página.beforestringCursor da página anterior.searchstringBusca no nome.
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"
}
]
}
#Criar uma etiqueta
POST/tags
Corpo da requisição
namestringobrigatórioNome, até 64 caracteres, único no time sem diferenciar maiúsculas.colorstringgray,green,yellow,red,blueoupurple. Padrão: a próxima cor da paleta.
Erros
- 422
validation_errorNome vazio, grande demais ou já usado, ou cor fora da 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"
}
#Obter uma etiqueta
GET/tags/{id}
Parâmetros de caminho
idstringobrigatórioID da etiqueta.
Erros
- 404
not_foundO recurso não existe ou é de outro time.
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"
}
#Atualizar uma etiqueta
PATCH/tags/{id}
Parâmetros de caminho
idstringobrigatórioID da etiqueta.
Corpo da requisição
namestringNovo nome.colorstringNova cor.
Erros
- 422
validation_errorAlgum campo é inválido. A mensagem diz qual. - 404
not_foundO recurso não existe ou é de outro time.
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"
}
#Excluir uma etiqueta
DELETE/tags/{id}
Remove a etiqueta de todos os contatos; os contatos continuam na audiência. Uma etiqueta usada por uma automação não pode ser excluída: tire-a da automação antes.
Parâmetros de caminho
idstringobrigatórioID da etiqueta.
Erros
- 409
tag_in_useAlguma automação usa esta etiqueta. A mensagem lista quais. - 404
not_foundO recurso não existe ou é de outro time.
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 vários contatos
POST/tags/{id}/contacts
Aplica a etiqueta a vários contatos de uma vez. Quem já a tinha fica como está; added conta só os que ganharam a etiqueta agora.
Parâmetros de caminho
idstringobrigatórioID da etiqueta.
Corpo da requisição
contactsstring[]obrigatórioAté 1.000 contatos por chamada, cada um por ID ou por e-mail. Quem não existir é ignorado.
Erros
- 422
validation_errorAlgum campo é inválido. A mensagem diz qual. - 404
not_foundO recurso não existe ou é de outro time.
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
}
#Tirar a etiqueta de vários contatos
DELETE/tags/{id}/contacts
Parâmetros de caminho
idstringobrigatórioID da etiqueta.
Corpo da requisição
contactsstring[]obrigatórioAté 1.000 contatos por chamada, cada um por ID ou por e-mail. Quem não existir é ignorado.
Erros
- 422
validation_errorAlgum campo é inválido. A mensagem diz qual. - 404
not_foundO recurso não existe ou é de outro time.
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
}