E-mails
Enviar, listar, agendar e cancelar e-mails, com anexos, tags, cabeçalhos e templates.
Envie e-mails, acompanhe o status de cada mensagem, agende, cancele e consulte anexos. Os envios pela API e pelo SMTP aparecem nos mesmos endpoints.
Quem recebe um e-mail enviado por aqui (em to e cc) é guardado na audiência como contato não confirmado, que não recebe campanhas nem automações até você confirmar. Dá para desligar isso em Configurações › Time.
#Enviar um e-mail
POST/emails
Coloca um e-mail na fila de envio e devolve o ID. O envio acontece em segundos; acompanhe o resultado com GET /emails/{id} ou com webhooks. Antes de enviar, a Postal remove os destinatários que estão na lista de supressão. Para testar sem enviar de verdade, use os endereços de teste.
Cabeçalhos
Idempotency-KeystringChave de 1 a 256 caracteres para repetir a requisição sem duplicar o envio. Veja Idempotência.
Corpo da requisição
fromstringobrigatórioRemetente, no formato[email protected]ouNome <[email protected]>. O domínio precisa estar verificado e com envio ativo. Até 998 caracteres.tostring | string[]obrigatórioDestinatários. Uma string ou uma lista de até 50 endereços. Pode ser omitido quando o destinatário é um contato: vejauser_id,external_idecontact_id.user_idstringAlternativa ato: o ID da pessoa no seu sistema, oexternal_idde um contato (user_idé um apelido). O e-mail vai para o endereço desse contato. Ignorado quandotoé enviado.external_idstringO mesmo queuser_id. Se os dois forem enviados, vale ouser_id.contact_idstringAlternativa ato: o ID do contato na Postal (ou o endereço de e-mail dele). Vale depois deuser_ideexternal_id.subjectstringobrigatórioAssunto, com até 998 caracteres.htmlstringCorpo em HTML. Obrigatório quando não houvertext.textstringVersão em texto puro. Recomendada mesmo comhtml: melhora a entregabilidade.ccstring | string[]Cópia.bccstring | string[]Cópia oculta.reply_tostring | string[]Endereços para resposta (Reply-To).headersobjectCabeçalhos extras, como{"List-Unsubscribe": "<https://…>"}. Valores com até 998 caracteres e sem quebra de linha. Não é possível sobrescreverFrom,To,Cc,Bcc,Subject,Reply-To,Sender,Date,Message-ID,Return-Path,MIME-Version,Content-Type,Content-Transfer-EncodingnemDKIM-Signature. Para enviar um código de uso único que o cliente de e-mail possa preencher, useOTP-Token: veja Cabeçalho OTP-Token. Quando oOTP-Tokenvem na requisição, a API confere o formato e responde422se o valor for inválido ou se o cabeçalho aparecer mais de uma vez, em qualquer caixa de letras (o que a Postal valida).tagsobject[]Até 50 etiquetas para filtrar e identificar o envio nos webhooks.namestringobrigatórioLetras ASCII, números,_ou-, até 256 caracteres. Não pode começar compostal_.valuestringMesmas regras do nome. Opcional.
attachmentsobject[]Anexos, com até 40 MB no total. Não aceito em lote. O plano pode permitir menos que isso (ou nenhum anexo): acima do limite do plano, a resposta é403 plan_limit_reached. Umpathque a Postal não consegue baixar na hora do envio deixa o e-mail com statusfailed.contentstringConteúdo do arquivo em Base64. Obrigatório sempath.pathstringURL http(s) pública de onde a Postal baixa o arquivo no momento do envio. Obrigatório semcontent.filenamestringNome do arquivo (até 255 caracteres). Compath, o padrão é o nome no fim da URL.content_typestringTipo MIME, comoapplication/pdf. Sem ele, o tipo é deduzido pela extensão.content_idstringPara imagens embutidas: use<img src="cid:logo">no HTML e"content_id": "logo"aqui.
scheduled_atstringData e hora do envio em ISO 8601 (ex.:2026-10-25T09:00:00-03:00). Precisa estar no futuro e a no máximo 30 dias. Não aceito em lote. Depende do recurso de agendamento do plano (403 plan_limit_reachedsem ele).templateobjectidstringobrigatórioID ou alias do template.variablesobjectValores das variáveis, como{"NAME": "Ana"}.
Envio com template
Com template, a Postal usa a última versão publicada: from, subject e reply_to do template valem quando não vierem no corpo, e as variáveis {{{NAME}}} são preenchidas com variables (no HTML, os valores são escapados). Uma variável sem valor e sem valor padrão gera 422, e o mesmo vale para um valor que não seja texto nem número ou, numa variável do tipo number, que não seja numérico. Valores de variáveis que o template não declara também são preenchidos. Não envie html ou text junto com template. Um template pausado pelo plano responde 403 paused_by_plan.
{
"from": "Loja Exemplo <[email protected]>",
"to": "[email protected]",
"template": {
"id": "order-shipped",
"variables": {
"NAME": "Ana",
"ORDER": "10482"
}
}
}
Anexos e imagens embutidas
Mande o arquivo em Base64 em content ou uma URL em path. Para uma imagem dentro do HTML, use o mesmo content_id no atributo src="cid:…".
{
"from": "Financeiro <[email protected]>",
"to": ["[email protected]"],
"subject": "Sua nota fiscal",
"html": "<img src=\"cid:logo\" alt=\"Loja Exemplo\"><p>Segue a nota fiscal do pedido #10482.</p>",
"attachments": [
{
"filename": "invoice-10482.pdf",
"content": "JVBERi0xLjcKJcfsj6IKNSAwIG9iago8PC9MZW5ndGggNiAwIFI+PgpzdHJlYW0K…"
},
{
"path": "https://cdn.lojaexemplo.com.br/logo.png",
"content_id": "logo"
}
]
}
Enviar para um contato
Se você guarda as pessoas como contatos, não precisa repetir o endereço: mande o user_id (o external_id do contato) no lugar de to e a Postal usa o e-mail dele. Vale também no lote. Um contato que não existe devolve 404. A lista de supressão continua valendo: se o endereço estiver nela, o e-mail não sai.
{
"from": "Loja Exemplo <[email protected]>",
"user_id": "user_42",
"template": {
"id": "order-shipped",
"variables": {
"NAME": "Ana",
"ORDER": "10482"
}
}
}
Agendamento
Com scheduled_at, o e-mail fica com status scheduled até a hora marcada. Dá para reagendar ou cancelar enquanto ele não sair. A cota é contada quando o e-mail é criado, e um evento email.scheduled entra no histórico.
Erros
- 422
validation_errorAlgum campo é inválido. A mensagem diz qual. - 403
validation_errorO domínio dofromnão está verificado ou não é do seu time. - 403
restricted_api_keyA chave só pode enviar de outro domínio. - 403
sending_pausedOs envios do time estão pausados por taxa alta de bounces ou reclamações. - 403
paused_by_planO domínio dofrom, otemplateou a própria chave está pausado porque passa do limite do plano. Veja Limites do plano. - 403
plan_limit_reachedO plano não inclui o que o envio pede:scheduled_at, endereços de teste, ou anexos (ou o total passa do limite do plano). - 404
not_foundOtemplatenão existe ou não foi publicado. - 404
not_foundNenhum contato tem ouser_id, oexternal_idou ocontact_idinformado. - 400
invalid_idempotency_keyAIdempotency-Keyestá vazia ou tem mais de 256 caracteres. - 409
concurrent_idempotent_requestsOutra requisição com a mesmaIdempotency-Keyainda está em andamento. - 409
invalid_idempotent_requestA mesmaIdempotency-Keyfoi usada com outro corpo. - 429
daily_quota_exceededA cota diária acabou: o limite diário do plano ou o teto da reputação da conta, o que for menor. Ela volta às 00:00 UTC. Quando é a reputação que limita, a mensagem diz o nível e o teto. - 429
monthly_quota_exceededA cota mensal do plano acabou, e a reserva do Adicional de envios também, se o time tem uma. A mensagem diz o que fazer, inclusive onde comprar o adicional quando o plano o oferece.
cURL
curl -X POST https://api.postal.so/emails \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Idempotency-Key: order-10482-shipped" \
-H "Content-Type: application/json" \
-d '{
"from": "Loja Exemplo <[email protected]>",
"to": ["[email protected]"],
"subject": "Seu pedido #10482 saiu para entrega",
"html": "<p>O pedido <b>#10482</b> está a caminho.</p>",
"text": "O pedido #10482 está a caminho.",
"tags": [
{
"name": "category",
"value": "shipping"
}
]
}'
Node.js
const response = await fetch('https://api.postal.so/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': 'order-10482-shipped',
},
body: JSON.stringify({
from: 'Loja Exemplo <[email protected]>',
to: ['[email protected]'],
subject: 'Seu pedido #10482 saiu para entrega',
html: '<p>O pedido <b>#10482</b> está a caminho.</p>',
text: 'O pedido #10482 está a caminho.',
tags: [
{
name: 'category',
value: 'shipping',
},
],
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/emails",
headers={
"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}",
"Idempotency-Key": "order-10482-shipped",
},
json={
"from": "Loja Exemplo <[email protected]>",
"to": ["[email protected]"],
"subject": "Seu pedido #10482 saiu para entrega",
"html": "<p>O pedido <b>#10482</b> está a caminho.</p>",
"text": "O pedido #10482 está a caminho.",
"tags": [
{
"name": "category",
"value": "shipping",
},
],
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('emails', [
'headers' => [
'Authorization' => 'Bearer '.getenv('POSTAL_API_KEY'),
'Idempotency-Key' => 'order-10482-shipped',
],
'json' => [
'from' => 'Loja Exemplo <[email protected]>',
'to' => ['[email protected]'],
'subject' => 'Seu pedido #10482 saiu para entrega',
'html' => '<p>O pedido <b>#10482</b> está a caminho.</p>',
'text' => 'O pedido #10482 está a caminho.',
'tags' => [
[
'name' => 'category',
'value' => 'shipping',
],
],
],
]);
$data = json_decode($response->getBody(), true);
{
"id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b"
}
#Enviar em lote
POST/emails/batch
Envia até 100 e-mails numa requisição. O corpo é uma lista de objetos com os mesmos campos de POST /emails, exceto attachments e scheduled_at. Tudo é validado antes: ou todos os e-mails entram na fila, ou nenhum. Os erros de validação indicam a posição, como [3] Missing `subject` field., e a cota é verificada para o lote inteiro (os itens só para endereços de teste não contam). O envio em lote é um recurso do plano.
Corpo da requisição (lista)
[ ]object[]obrigatórioDe 1 a 100 e-mails, cada um comfrom,to,subject,htmloutexte, se quiser,cc,bcc,reply_to,headers,tagsetemplate. Em vez deto, valeuser_id,external_idoucontact_id, como emPOST /emails.
Erros
- 422
validation_errorO corpo não é uma lista, tem mais de 100 itens, ou algum item é inválido (com a posição na mensagem). - 403
validation_errorAlgumfromusa um domínio não verificado. - 403
plan_limit_reachedO plano não inclui o envio em lote (ou os endereços de teste, se algum item os usa). - 403
sending_pausedOs envios do time estão pausados por taxa alta de bounces ou reclamações. - 403
paused_by_planAlgumfromusa um domínio pausado pelo plano, ou algum item usa um template pausado. - 404
not_foundAlgum item usa umtemplateinexistente ou não publicado, ou um contato que não existe (a mensagem traz a posição). - 429
daily_quota_exceededO lote passaria da cota diária ou mensal.
cURL
curl -X POST https://api.postal.so/emails/batch \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '[
{
"from": "Loja Exemplo <[email protected]>",
"to": ["[email protected]"],
"subject": "Seu cupom chegou",
"html": "<p>Olá, Ana! Use o cupom <b>BEMVINDA</b>.</p>"
},
{
"from": "Loja Exemplo <[email protected]>",
"to": ["[email protected]"],
"subject": "Seu cupom chegou",
"html": "<p>Olá, Bruno! Use o cupom <b>BEMVINDO</b>.</p>"
}
]'
Node.js
const response = await fetch('https://api.postal.so/emails/batch', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify([
{
from: 'Loja Exemplo <[email protected]>',
to: ['[email protected]'],
subject: 'Seu cupom chegou',
html: '<p>Olá, Ana! Use o cupom <b>BEMVINDA</b>.</p>',
},
{
from: 'Loja Exemplo <[email protected]>',
to: ['[email protected]'],
subject: 'Seu cupom chegou',
html: '<p>Olá, Bruno! Use o cupom <b>BEMVINDO</b>.</p>',
},
]),
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/emails/batch",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json=[
{
"from": "Loja Exemplo <[email protected]>",
"to": ["[email protected]"],
"subject": "Seu cupom chegou",
"html": "<p>Olá, Ana! Use o cupom <b>BEMVINDA</b>.</p>",
},
{
"from": "Loja Exemplo <[email protected]>",
"to": ["[email protected]"],
"subject": "Seu cupom chegou",
"html": "<p>Olá, Bruno! Use o cupom <b>BEMVINDO</b>.</p>",
},
],
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('emails/batch', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
[
'from' => 'Loja Exemplo <[email protected]>',
'to' => ['[email protected]'],
'subject' => 'Seu cupom chegou',
'html' => '<p>Olá, Ana! Use o cupom <b>BEMVINDA</b>.</p>',
],
[
'from' => 'Loja Exemplo <[email protected]>',
'to' => ['[email protected]'],
'subject' => 'Seu cupom chegou',
'html' => '<p>Olá, Bruno! Use o cupom <b>BEMVINDO</b>.</p>',
],
],
]);
$data = json_decode($response->getBody(), true);
{
"data": [
{
"id": "019a1b2c-51aa-7c21-9f02-6b7c8d9e0f1a"
},
{
"id": "019a1b2c-51ab-7d30-8e11-2a3b4c5d6e7f"
}
]
}
#Listar e-mails
GET/emails
Lista os e-mails enviados, do mais recente para o mais antigo, com paginação por cursor. A lista não traz os eventos; use GET /emails/{id} para ver o histórico.
Parâmetros de consulta
limitintegerItens por página, de 1 a 100. Padrão: 20.afterstringID do último item recebido, para a próxima página.beforestringID do primeiro item recebido, para a página anterior.searchstringBusca em remetente, assunto e destinatários. Um ID completo encontra o e-mail exato. Os destinatários incluemccebcc; a busca tem até 200 caracteres.statusstringUm ou mais status separados por vírgula, comobounced,complained. Veja os status. Um valor que não é status devolve422.domain_idstringSó e-mails de um domínio.api_key_idstringSó e-mails enviados com uma chave.start_datestringCriados a partir desta data (ISO 8601). Uma data sem hora, como2026-10-01, começa à meia-noite UTC.end_datestringCriados até esta data (ISO 8601). Uma data sem hora, como2026-10-24, vale até o fim desse dia (UTC). Sestart_datefor depois deend_date, a resposta é422.
Erros
- 422
validation_errorFiltro ou cursor inválido, ouafterebeforejuntos. Também quandostart_dateé depois deend_date.
cURL
curl 'https://api.postal.so/emails?limit=2&status=delivered,opened' \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/emails?limit=2&status=delivered,opened', {
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/emails",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
params={
"limit": 2,
"status": "delivered,opened",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('emails', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'query' => [
'limit' => 2,
'status' => 'delivered,opened',
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "list",
"has_more": true,
"data": [
{
"object": "email",
"id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
"message_id": "<0103019a1b2c4d5e-6f7a8b9c-1d2e-4f3a-9b8c-7d6e5f4a3b2c-000000@sa-east-1.amazonses.com>",
"to": ["[email protected]"],
"from": "Loja Exemplo <[email protected]>",
"created_at": "2026-10-24T13:02:11.000000Z",
"subject": "Seu pedido #10482 saiu para entrega",
"html": "<p>O pedido <b>#10482</b> está a caminho.</p>",
"text": "O pedido #10482 está a caminho.",
"bcc": [],
"cc": [],
"reply_to": ["[email protected]"],
"last_event": "delivered",
"scheduled_at": null,
"simulated": false,
"tags": [
{
"name": "category",
"value": "shipping"
}
]
}
]
}
#Obter um e-mail
GET/emails/{id}
Devolve o e-mail com o conteúdo, o status atual (last_event) e a lista de eventos. Nos e-mails para os endereços de teste, simulated é true. Também traz de onde ele veio: source é api ou smtp, e log_id aponta para o log da requisição. Os campos estão em O objeto e-mail e os tipos de evento em Eventos de um e-mail.
Quais chaves podem chamar: uma chave de acesso total (ou o painel) lê qualquer e-mail do time. Uma chave sending_access lê só os e-mails que ela mesma enviou, pela API ou pelo SMTP, e assim acompanha o status da entrega sem webhooks. Qualquer outro e-mail do time (de outra chave, do painel, de campanhas ou de automações) responde 404 not_found, igual a um e-mail que não existe. As demais rotas de /emails continuam exigindo acesso total.
Parâmetros de caminho
idstringobrigatórioID do e-mail.
Erros
- 404
not_foundO e-mail não existe, é de outro time ou, numa chavesending_access, não foi enviado por ela.
cURL
curl https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b', {
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/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
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('emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "email",
"id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
"message_id": "<0103019a1b2c4d5e-6f7a8b9c-1d2e-4f3a-9b8c-7d6e5f4a3b2c-000000@sa-east-1.amazonses.com>",
"to": ["[email protected]"],
"from": "Loja Exemplo <[email protected]>",
"created_at": "2026-10-24T13:02:11.000000Z",
"subject": "Seu pedido #10482 saiu para entrega",
"html": "<p>O pedido <b>#10482</b> está a caminho.</p>",
"text": "O pedido #10482 está a caminho.",
"bcc": [],
"cc": [],
"reply_to": ["[email protected]"],
"last_event": "delivered",
"scheduled_at": null,
"simulated": false,
"tags": [
{
"name": "category",
"value": "shipping"
}
],
"events": [
{
"type": "email.sent",
"occurred_at": "2026-10-24T13:02:12.000000Z",
"data": null
},
{
"type": "email.delivered",
"occurred_at": "2026-10-24T13:02:14.000000Z",
"data": {
"recipients": ["[email protected]"]
}
}
],
"domain_id": "0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f",
"api_key_id": "0199f0e2-7b8c-7d9e-8f0a-1b2c3d4e5f6a",
"template_id": null,
"source": "api",
"log_id": "019a1b2c-4d60-7a11-b2c3-d4e5f6a7b8c9"
}
#Reagendar um e-mail
PATCH/emails/{id}
Muda a data de envio de um e-mail agendado. Só funciona enquanto o status for scheduled.
Parâmetros de caminho
idstringobrigatórioID do e-mail.
Corpo da requisição
scheduled_atstringobrigatórioNova data em ISO 8601, no futuro e a no máximo 30 dias.
Erros
- 422
validation_errorO e-mail não está agendado ou a data é inválida. - 404
not_foundO recurso não existe ou é de outro time.
cURL
curl -X PATCH https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"scheduled_at": "2026-10-26T09:00:00-03:00"
}'
Node.js
const response = await fetch('https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
scheduled_at: '2026-10-26T09:00:00-03:00',
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.patch(
"https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"scheduled_at": "2026-10-26T09:00:00-03:00",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->patch('emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'scheduled_at' => '2026-10-26T09:00:00-03:00',
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "email",
"id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b"
}
#Cancelar um e-mail agendado
POST/emails/{id}/cancel
Cancela o envio de um e-mail agendado. O status passa a canceled e um evento email.canceled entra no histórico.
Parâmetros de caminho
idstringobrigatórioID do e-mail.
Erros
- 422
validation_errorO e-mail não está agendado (já saiu ou já foi cancelado). - 404
not_foundO recurso não existe ou é de outro time.
cURL
curl -X POST https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/cancel \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/cancel', {
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/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/cancel",
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('emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/cancel', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "email",
"id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b"
}
#Mensagem original
GET/emails/{id}/raw
Devolve a mensagem como foi enviada, no formato RFC 5322 (text/plain): a original, para envios por SMTP, ou reconstruída, para envios pela API.
Parâmetros de caminho
idstringobrigatórioID do e-mail.
Erros
- 404
not_foundO recurso não existe ou é de outro time.
cURL
curl https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/raw \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/raw', {
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const message = await response.text();
Python
import os
import requests
response = requests.get(
"https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/raw",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
message = response.text
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/raw', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$message = (string) $response->getBody();
From: Loja Exemplo <[email protected]>
To: [email protected]
Subject: Seu pedido #10482 saiu para entrega
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="b1_Xk2p"
--b1_Xk2p
Content-Type: text/plain; charset=utf-8
O pedido #10482 está a caminho.
--b1_Xk2p--
#Diagnóstico de entregabilidade
GET/emails/{id}/insights
Roda verificações de boas práticas no e-mail e diz se cada uma passou. Os códigos são: has_text_version, no_link_shorteners, https_links, no_ip_links, images_have_alt, html_under_gmail_limit (HTML até 102 KB), enough_text, reply_friendly_sender, subject_not_all_caps, unsubscribe_option e dmarc_configured (só quando o DNS responde). A IA explica como corrigir os que falharam.
Parâmetros de caminho
idstringobrigatórioID do e-mail.
Erros
- 404
not_foundO recurso não existe ou é de outro time.
cURL
curl https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/insights \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/insights', {
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/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/insights",
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('emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/insights', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "list",
"data": [
{
"code": "has_text_version",
"passed": true,
"data": {}
},
{
"code": "https_links",
"passed": false,
"data": {
"count": 1
}
},
{
"code": "dmarc_configured",
"passed": true,
"data": {
"domain": "lojaexemplo.com.br"
}
}
]
}
#Criar link de visualização
POST/emails/{id}/share
Cria um link público e temporário para ver o e-mail no navegador, útil para mostrar a alguém sem acesso ao painel. O HTML abre isolado, sem scripts.
Parâmetros de caminho
idstringobrigatórioID do e-mail.
Corpo da requisição
expires_instringValidade, de 1 minuto a 48 horas:10m,2 hours,1 day… Padrão:48h.
Erros
- 422
validation_errorexpires_infora do formato ou do intervalo. - 403
plan_limit_reachedO plano não inclui os links para compartilhar e-mails. - 404
not_foundO recurso não existe ou é de outro time.
cURL
curl -X POST https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/share \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"expires_in": "2h"
}'
Node.js
const response = await fetch('https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/share', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
expires_in: '2h',
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/share",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"expires_in": "2h",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/share', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'expires_in' => '2h',
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "email",
"id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
"url": "https://api.postal.so/shared/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b?expires=1761321731&signature=4f1c…"
}
#Listar anexos
GET/emails/{id}/attachments
Lista os anexos de um e-mail enviado. Cada um traz um download_url assinado, válido por 1 hora. Para anexos enviados por path, o link é a URL original e expires_at vem nulo. Aceita limit, after e before, como as outras listas.
Parâmetros de caminho
idstringobrigatórioID do e-mail.
Erros
- 422
validation_errorlimitfora do intervalo ouafterebeforejuntos. - 404
not_foundO recurso não existe ou é de outro time.
cURL
curl https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/attachments \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/attachments', {
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/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/attachments",
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('emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/attachments', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "list",
"has_more": false,
"data": [
{
"id": "019a1b2c-4d5f-7e70-9b8a-7c6d5e4f3a2b",
"filename": "invoice-10482.pdf",
"content_type": "application/pdf",
"content_id": null,
"content_disposition": "attachment",
"download_url": "https://api.postal.so/attachments/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/019a1b2c-4d5f-7e70-9b8a-7c6d5e4f3a2b?expires=1761314531&signature=9a7e…",
"expires_at": "2026-10-24T14:02:11.000Z",
"size": 48213
}
]
}
#Obter um anexo
GET/emails/{id}/attachments/{attachment_id}
Devolve um anexo com o link de download.
Parâmetros de caminho
idstringobrigatórioID do e-mail.attachment_idstringobrigatórioID do anexo.
Erros
- 404
not_foundO recurso não existe ou é de outro time.
cURL
curl https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/attachments/019a1b2c-4d5f-7e70-9b8a-7c6d5e4f3a2b \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/attachments/019a1b2c-4d5f-7e70-9b8a-7c6d5e4f3a2b', {
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/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/attachments/019a1b2c-4d5f-7e70-9b8a-7c6d5e4f3a2b",
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('emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/attachments/019a1b2c-4d5f-7e70-9b8a-7c6d5e4f3a2b', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "attachment",
"id": "019a1b2c-4d5f-7e70-9b8a-7c6d5e4f3a2b",
"filename": "invoice-10482.pdf",
"content_type": "application/pdf",
"content_id": null,
"content_disposition": "attachment",
"download_url": "https://api.postal.so/attachments/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/019a1b2c-4d5f-7e70-9b8a-7c6d5e4f3a2b?expires=1761314531&signature=9a7e…",
"expires_at": "2026-10-24T14:02:11.000Z",
"size": 48213
}
#Status de um e-mail
O campo last_event mostra o estado mais avançado do e-mail. Como os eventos podem chegar fora de ordem, o status só avança: um e-mail clicked não volta para delivered.
| Status | Significado |
|---|---|
queued | Na fila, saindo em instantes. |
scheduled | Agendado para scheduled_at. |
sent | Aceito pelo provedor de envio. |
delivery_delayed | O servidor do destinatário adiou a entrega; novas tentativas acontecem sozinhas. |
delivered | Entregue ao servidor do destinatário. |
opened | Aberto (com rastreamento de aberturas ligado no domínio). |
clicked | Um link foi clicado (com rastreamento de cliques ligado). |
bounced | Devolvido. Bounces permanentes vão para a lista de supressão. |
complained | O destinatário marcou como spam. O endereço vai para a lista de supressão. |
failed | Não foi possível enviar (domínio não verificado, envio pausado, anexo inacessível…). |
suppressed | Todos ou parte dos destinatários estavam na lista de supressão. |
canceled | Agendamento cancelado. |
#O objeto e-mail
A lista (GET /emails) e a consulta de um e-mail (GET /emails/{id}) devolvem o mesmo objeto. A consulta acrescenta o histórico e a origem. O envio (POST /emails) devolve só o id, e o envio em lote, uma lista de id. A Postal guarda os e-mails, os anexos e o histórico por 30 dias (e os agendados até saírem); depois disso, a consulta responde 404. Veja Limites.
| Campo | Tipo | Descrição |
|---|---|---|
object, id | string | email e o ID do e-mail. |
message_id | string | null | O Message-ID do e-mail. É null até o provedor de envio aceitá-lo (e-mails na fila, agendados ou cancelados). |
from, to, cc, bcc, reply_to | string | string[] | Os endereços, como foram enviados. cc, bcc e reply_to são listas vazias quando não existem. |
subject, html, text | string | null | O conteúdo enviado. Num envio com template, é o conteúdo já preenchido com as variáveis. |
created_at | string | Quando o e-mail foi criado (ISO 8601, UTC). |
last_event | string | O status atual: queued, scheduled, sent, delivery_delayed, delivered, opened, clicked, bounced, failed, suppressed, canceled ou complained. |
scheduled_at | string | null | A data de envio marcada com scheduled_at, ou null num envio imediato. |
simulated | boolean | true nos e-mails para os endereços de teste, que nunca são enviados de verdade. |
tags | object[] | As etiquetas, como [{"name", "value"}]. |
events | object[] | Só em GET /emails/{id}: o histórico, do mais antigo para o mais novo, como [{"type", "occurred_at", "data"}]. Veja Eventos de um e-mail. |
domain_id, api_key_id, template_id | string | null | Só em GET /emails/{id}: o domínio do remetente, a chave de API usada no envio e o template usado (null quando não houve chave ou template). |
source, log_id | string | Só em GET /emails/{id}: api ou smtp, e o ID do log da requisição (null se não houver). |
#Eventos de um e-mail
O campo events de GET /emails/{id} guarda tudo o que aconteceu com o e-mail, em ordem. Cada evento tem type, occurred_at e data (ou null). Quase todos também podem ser assinados como eventos de webhook (email.canceled e email.unsubscribed existem só no histórico). Os eventos que envolvem vários destinatários listam, em data.recipients, os endereços a que se aplicam.
| Tipo | Quando | data |
|---|---|---|
email.scheduled | O e-mail foi criado com scheduled_at. | null |
email.canceled | O agendamento foi cancelado. | null |
email.sent | O provedor de envio aceitou o e-mail. | null |
email.delivered | O servidor do destinatário recebeu o e-mail. | recipients |
email.delivery_delayed | A entrega foi adiada; novas tentativas acontecem sozinhas. | delay_type, recipients |
email.bounced | O e-mail voltou. Bounces permanentes entram na lista de supressão. | bounce (type, subType, message, diagnosticCode), recipients |
email.complained | O destinatário marcou como spam. O endereço entra na lista de supressão. | recipients, feedback_type |
email.failed | Não foi possível enviar (domínio sem verificação, envio pausado, anexo inacessível…). | reason |
email.suppressed | Todos ou parte dos destinatários estavam na lista de supressão e foram descartados no envio. Os demais seguem normalmente. | recipients |
email.opened | O destinatário abriu o e-mail (rastreamento de aberturas ligado no domínio). | open (ipAddress, userAgent, timestamp) |
email.clicked | Um link foi clicado (rastreamento de cliques ligado). | click (ipAddress, link, userAgent, timestamp) |
email.replied | Chegou uma resposta ao e-mail de uma campanha ou automação que detecta respostas. | reply (from, subject, message_id, preview, attachments), auto |
email.unsubscribed | O contato se descadastrou pelo link de um e-mail de campanha. | null |
O last_event só avança, mas a lista de events guarda todos os eventos, mesmo os que chegam fora de ordem. email.replied e email.unsubscribed não mudam o last_event.
#Endereços de teste
Para testar a integração e os webhooks de ponta a ponta, sem enviar e-mail de verdade e sem arriscar a reputação do seu domínio, envie para os endereços de teste da Postal, no domínio sandbox.postal.so. É a mesma ideia do [email protected] da Resend.
O e-mail é aceito como qualquer outro: a mesma validação, o from de um domínio verificado do time, anexos, templates, tags, scheduled_at, Idempotency-Key, o envio em lote e o SMTP. Ele aparece em GET /emails e no painel, com o histórico de eventos, mas nunca é enviado: no lugar do provedor, a Postal registra os eventos poucos segundos depois, na ordem de um envio real, e os webhooks saem normalmente.
| Endereço | Eventos, em ordem | Status final |
|---|---|---|
[email protected] | email.sent, email.delivered | delivered |
[email protected] | email.sent, email.bounced (bounce permanente: type Permanent, subType General, message smtp; 550 5.1.1 user unknown) | bounced |
[email protected] | email.sent, email.delivered, email.complained | complained |
[email protected] | email.suppressed (sem email.sent nem email.delivered) | suppressed |
[email protected] | email.sent, email.delivery_delayed | delivery_delayed |
Regras
- Maiúsculas e minúsculas não importam, e dá para acrescentar um rótulo depois de
+, com até 64 caracteres entre letras, números,.,_e-:[email protected]. Use o rótulo para reconhecer cada teste nos eventos. - Não misture. Os destinatários de um e-mail (
to,ccebcc) são todos endereços de teste ou todos reais; misturar devolve422 validation_error. Um endereço do domínio de teste com outro nome, como[email protected], também devolve422, listando os nomes válidos. - Vários endereços de teste no mesmo e-mail são permitidos: cada resultado vira o seu evento, com a lista
recipientsdos endereços daquele resultado. O status final segue o evento mais avançado (complainedvale mais quebouncedesuppressed, que valem mais quedelivered). - Não contam em nada que afete o seu envio real: não usam a cota de envio (diária, mensal ou de envio além da cota), não entram na lista de supressão (um
bounced@oucomplained@não bloqueia nada), não entram nas taxas de bounce e de reclamação que podem pausar os envios, não aparecem nas métricas nem no resumo semanal, e não disparam o aviso de primeiro e-mail. - O resto continua valendo: o limite de requisições por segundo, as chaves de API restritas a um domínio e a pausa de envios do time (
403 sending_paused). - Recurso do plano. Os endereços de teste dependem do recurso
test_addressesdo plano (veja Limites do plano); sem ele, o envio responde403 plan_limit_reached. - O e-mail traz
"simulated": trueemGET /emails/{id}. Não é preciso configurar DNS: nada é entregue a esse domínio.
{
"from": "Loja Exemplo <[email protected]>",
"to": ["[email protected]"],
"subject": "Seu pedido #10482 saiu para entrega",
"html": "<p>O pedido <b>#10482</b> está a caminho.</p>",
"tags": [
{
"name": "category",
"value": "shipping"
}
]
}
Alguns segundos depois, o seu webhook recebe o mesmo evento de um bounce real:
{
"type": "email.bounced",
"created_at": "2026-10-24T13:02:15.004Z",
"data": {
"email_id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
"created_at": "2026-10-24T13:02:11.000Z",
"from": "Loja Exemplo <[email protected]>",
"to": ["[email protected]"],
"subject": "Seu pedido #10482 saiu para entrega",
"tags": {
"category": "shipping"
},
"bounce": {
"diagnosticCode": ["smtp; 550 5.1.1 user unknown"],
"message": "smtp; 550 5.1.1 user unknown",
"subType": "General",
"type": "Permanent"
}
}
}
#Limites do plano
Alguns recursos de envio dependem do plano. Quando o plano não inclui um deles, a API responde 403 plan_limit_reached e a mensagem diz qual recurso falta. O que cada plano inclui está em Limites e em GET /plans.
| Recurso do plano | Chave | Onde vale |
|---|---|---|
| Envio em lote | batch_sending | POST /emails/batch |
| Agendamento de envios | scheduled_sending | O campo scheduled_at de POST /emails. |
| Endereços de teste simulados | test_addresses | sandbox.postal.so |
| Tamanho dos anexos | attachments_mb | O total dos attachments: 0 não permite anexos; um número limita o total em MB; sem número vale o limite de 40 MB da plataforma. |
| Links para compartilhar e-mails | shared_email_links | POST /emails/{id}/share |
| Envio por SMTP | smtp_access | SMTP |
As cotas de envio são outro limite: quando acabam, a resposta é 429 daily_quota_exceeded ou 429 monthly_quota_exceeded (veja Cotas). O limite diário é o menor entre o do plano e o teto da reputação da conta, que começa em 500 e-mails por dia nas contas novas. Antes de recusar por monthly_quota_exceeded, a Postal usa a reserva do Adicional de envios, se o time tem uma; o limite diário não é levantado por ela. Os e-mails para endereços de teste não usam a cota.
Envios pausados
Quando as taxas de bounce, de reclamação ou de conteúdo rejeitado do time passam do limite (veja Pausa automática), a Postal pausa os envios para proteger a reputação dos seus domínios. Enquanto isso, POST /emails, POST /emails/batch e os envios para endereços de teste respondem 403 sending_paused. Os webhooks sending.paused e sending.resumed avisam quando isso começa e termina (veja Webhooks).
Itens pausados pelo plano
Se o plano muda e o time passa a ter mais domínios, templates, chaves de API, inboxes, webhooks ou outros itens do que o novo plano permite, a Postal não apaga nada: os mais antigos, até o limite, continuam ativos, e os mais novos ficam pausados. Um item pausado continua na lista e pode ser lido, e o campo paused_by_plan dele é true. Alterá-lo ou usá-lo responde 403 paused_by_plan. Para os envios, isso vale para um from de domínio pausado, para um template pausado e para a própria chave de API, quando ela é a pausada: toda requisição feita com ela é recusada. Os itens voltam a ficar ativos assim que o plano permite de novo, ou quando você apaga outros itens.
{
"statusCode": 403,
"name": "plan_limit_reached",
"message": "Your plan does not include “Scheduled sending”. Upgrade your plan to use it."
}
{
"statusCode": 403,
"name": "paused_by_plan",
"message": "This item is paused because it is above the limit of your current plan. Upgrade your plan or delete other items to use it again."
}
As mensagens seguem o Accept-Language (sem ele, vêm em inglês); o name não muda.
#Com os SDKs
Os SDKs oficiais têm métodos para enviar (emails.send e emails.batch), consultar e listar (emails.get, emails.list e emails.iterate), reagendar e cancelar (emails.update e emails.cancel) e ler a mensagem original (emails.raw). Os campos têm os nomes da API.
Node.js
import { Postal } from 'postal-so'
const postal = new Postal() // lê POSTAL_API_KEY
const { id } = await postal.emails.send({
from: 'Loja Exemplo <[email protected]>',
to: '[email protected]',
subject: 'Seu pedido #10482 saiu para entrega',
html: '<p>O pedido <b>#10482</b> está a caminho.</p>',
scheduled_at: new Date(Date.now() + 3600_000),
})
const email = await postal.emails.get(id) // email.last_event e email.events
await postal.emails.update(id, { scheduled_at: '2026-10-26T09:00:00-03:00' })
await postal.emails.cancel(id)
PHP
use Postal\Client;
$postal = new Client(); // lê POSTAL_API_KEY
$result = $postal->emails->send([
'from' => 'Loja Exemplo <[email protected]>',
'to' => '[email protected]',
'subject' => 'Seu pedido #10482 saiu para entrega',
'html' => '<p>O pedido <b>#10482</b> está a caminho.</p>',
'scheduled_at' => new DateTimeImmutable('+1 hour'),
]);
$email = $postal->emails->get($result['id']); // last_event e events
$postal->emails->update($result['id'], '2026-10-26T09:00:00-03:00');
$postal->emails->cancel($result['id']);