Receber e-mails (Inbound)
Receba e-mails no seu app: ative o recebimento no domínio, crie endereços ou um catch-all, trate o webhook email.received e busque o conteúdo, os anexos e o original pela API.
#Como funciona
Inbound é receber e processar e-mails no seu próprio sistema: um endereço seu recebe a mensagem, a Postal guarda o texto, o HTML, os anexos e o original, avisa o seu código pelo webhook email.received e entrega o conteúdo pela API. Não é preciso manter um servidor de e-mail. Conheça a página do Inbound.
- Quem escreve manda a mensagem para um endereço seu (no seu domínio, com o MX apontado para
mx.postal.so, ou num endereço gerado emin.postal.so). - O servidor de entrada da Postal aceita a mensagem só se existe uma inbox para aquele endereço.
- A Postal guarda a mensagem e os anexos e cria um
received_email, um por inbox de destino. - Cada webhook do time que assina
email.receivedrecebe um POST assinado com o resumo. - O seu código busca o conteúdo com
GET /emails/receiving/{id}e faz o que precisa: abre um chamado, guarda um anexo, responde.
#Planos e disponibilidade
O plano define quantas inboxes o time pode ter e se elas podem usar um domínio seu. No plano Grátis, até 3 inboxes, todas com endereço em in.postal.so. Endereços no seu domínio, inclusive o catch-all, e a ativação do recebimento no domínio são dos planos pagos (custom_inbound_domains).
| Grátis | Profissional | Escala | Empresarial | |
|---|---|---|---|---|
| Inboxes | 3 | 10 | 100 | Sem limite |
| Inboxes no seu domínio | Não | Sim | Sim | Sim |
Valores atualizados da GET /plans ao abrir a página.
Receber não usa a cota de envios do plano. Respostas e encaminhamentos saem como envios normais e contam na cota. Passar do número de inboxes responde 403 plan_limit_reached; veja Limites do plano e Recursos por plano.
#Receber no seu domínio
Ative o recebimento no domínio no painel (Domínios, aba de configuração, Recebimento (inboxes)) ou pela API, com capabilities.receiving em PATCH /domains/{id} (ou ao cadastrar, em POST /domains):
cURL
curl -X PATCH https://api.postal.so/domains/0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"capabilities": {
"receiving": "enabled"
}
}'
Node.js
const response = await fetch('https://api.postal.so/domains/0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
capabilities: {
receiving: 'enabled',
},
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.patch(
"https://api.postal.so/domains/0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"capabilities": {
"receiving": "enabled",
},
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->patch('domains/0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'capabilities' => [
'receiving' => 'enabled',
],
],
]);
$data = json_decode($response->getBody(), true);
Depois, crie no DNS do domínio o registro que a Postal pede:
| Tipo | Nome | Valor | Prioridade |
|---|---|---|---|
MX | @ | mx.postal.so | 10 |
O nome @ é o próprio domínio cadastrado: para respostas.lojaexemplo.com.br, o MX fica em respostas.lojaexemplo.com.br. O recebimento não depende da verificação de envio (DKIM e SPF): as mensagens chegam assim que o MX aponta para a Postal. Um domínio pode até ser só de recebimento, com capabilities.sending em disabled. Responder e encaminhar a partir do domínio, por outro lado, pedem o domínio verificado para envio.
Use um subdomínio se o domínio já recebe e-mails
O MX decide para onde vão todos os e-mails do domínio. Se lojaexemplo.com.br já recebe no Google Workspace ou no Microsoft 365, trocar o MX tira as mensagens de lá. Cadastre um subdomínio, como respostas.lojaexemplo.com.br, e ative o recebimento nele. Para saber se o domínio já tem MX de outro provedor, consulte GET /domains/{id}/receiving-check: ele só informa, não bloqueia, e o painel usa a resposta para avisar antes de ativar.
Mais detalhes sobre o DNS em Domínios e DNS.
#Endereços e catch-all
Cada endereço que recebe é uma inbox. Crie no painel, em Inboxes, ou com POST /inboxes. Há três tipos:
| Tipo | Como criar | Exemplo |
|---|---|---|
| Endereço exato | domain_id e local_part | [email protected] |
| Catch-all | domain_id e local_part igual a * | *@respostas.lojaexemplo.com.br |
| Endereço gerado | Sem domain_id: a Postal gera o endereço (inclusive no plano Grátis) | [email protected] |
cURL
curl -X POST https://api.postal.so/inboxes \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Respostas",
"domain_id": "0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f",
"local_part": "*"
}'
Node.js
const response = await fetch('https://api.postal.so/inboxes', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'Respostas',
domain_id: '0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f',
local_part: '*',
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/inboxes",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"name": "Respostas",
"domain_id": "0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f",
"local_part": "*",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('inboxes', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'name' => 'Respostas',
'domain_id' => '0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f',
'local_part' => '*',
],
]);
$data = json_decode($response->getBody(), true);
Quando uma mensagem chega, a Postal procura a inbox nesta ordem: o endereço de recebimento em in.postal.so de uma inbox (receiving_address), o endereço exato e, por último, o catch-all do domínio. O endereço exato vence o catch-all. Endereços são comparados sem diferença entre maiúsculas e minúsculas e são únicos em toda a Postal.
- Sem tratamento especial para
+:suporte+pedido@só chega se existir uma inbox com esse endereço exato ou um catch-all no domínio. Para um endereço por pedido ou por conversa, use o catch-all. - Encaminhar de outro provedor, sem trocar o MX: cada inbox de um endereço exato no seu domínio ganha também um
receiving_addressemin.postal.so. Configure o seu provedor atual para encaminhar para ele e as mensagens chegam à inbox. O catch-all não tem esse endereço. - Uma mensagem para duas inboxes vira dois e-mails recebidos e dois eventos, um por inbox. Duas vezes a mesma inbox (por exemplo, em
toe emcc) dá um só.
#O webhook email.received
Cadastre a URL do seu sistema em Webhooks, no painel, e marque Recebido, ou crie com POST /webhooks. A URL precisa ser HTTPS e pública. Guarde o signing_secret (whsec_…) da resposta: ele confere a assinatura.
cURL
curl -X POST https://api.postal.so/webhooks \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"endpoint": "https://lojaexemplo.com.br/webhooks/postal",
"events": ["email.received"]
}'
Node.js
const response = await fetch('https://api.postal.so/webhooks', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
endpoint: 'https://lojaexemplo.com.br/webhooks/postal',
events: ['email.received'],
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/webhooks",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"endpoint": "https://lojaexemplo.com.br/webhooks/postal",
"events": ["email.received"],
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('webhooks', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'endpoint' => 'https://lojaexemplo.com.br/webhooks/postal',
'events' => ['email.received'],
],
]);
$data = json_decode($response->getBody(), true);
Assim que guarda a mensagem, a Postal envia um POST com este corpo:
{
"type": "email.received",
"created_at": "2026-11-04T12:41:09.880Z",
"data": {
"email_id": "019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d",
"inbox_id": "0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
"thread_id": "019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e",
"created_at": "2026-11-04T12:41:09.000Z",
"from": "Bruno Lima <[email protected]>",
"to": ["[email protected]"],
"cc": [],
"subject": "Nota fiscal do pedido #10475",
"message_id": "[email protected]",
"attachments": [
{
"id": "019a1c00-3e50-7b6c-8d7e-9f0a1b2c3d4e",
"filename": "comprovante-10475.pdf",
"content_type": "application/pdf",
"size": 48213
}
]
}
}
Campo de data | O que é |
|---|---|
email_id | ID do e-mail recebido. Use em GET /emails/receiving/{id}. |
inbox_id | A inbox que recebeu. Um webhook vale para todas as inboxes do time: filtre por este campo para tratar só uma. |
thread_id | A conversa: mensagens que respondem umas às outras (pelos cabeçalhos In-Reply-To e References) têm o mesmo. |
created_at | Quando a mensagem foi recebida (ISO 8601, UTC). |
from | O remetente, como veio no cabeçalho From (com o nome, se houver). |
to, cc | Os destinatários dos cabeçalhos To e Cc. Uma inbox em cópia oculta não aparece aqui: use o inbox_id. |
subject | O assunto. |
message_id | O Message-ID da mensagem, sem os sinais < >, ou null. |
attachments | id, filename (pode ser null), content_type e size (bytes) de cada anexo. Sem o conteúdo. |
O evento não traz o corpo nem o otp_token: um webhook passa por filas, logs e novas tentativas, e o conteúdo fica atrás da sua chave de API. Responda com 2xx em até 15 segundos; senão, a Postal tenta de novo até 8 vezes em cerca de 27 horas, sempre com o mesmo webhook-id (use-o para descartar repetições). Veja Respostas e novas tentativas e o histórico de entregas.
#Verificar a assinatura e ler o e-mail
Cada entrega traz os cabeçalhos webhook-id, webhook-timestamp e webhook-signature (padrão Standard Webhooks, HMAC-SHA256 com o signing_secret; os mesmos valores vêm em svix-*). Os SDKs conferem a assinatura, recusam mensagens com mais de 5 minutos de diferença e devolvem o evento. O exemplo abaixo confere, responde logo e depois busca o conteúdo e os anexos:
Node.js
import express from 'express';
import { verifyWebhook, PostalWebhookVerificationError } from 'postal-so';
const app = express();
const api = { Authorization: `Bearer ${process.env.POSTAL_API_KEY}` }; // chave de acesso total
// O corpo precisa chegar cru (Buffer) para a assinatura bater.
app.post('/webhooks/postal', express.raw({ type: 'application/json' }), async (req, res) => {
let event;
try {
event = verifyWebhook(req.body, req.headers, process.env.POSTAL_WEBHOOK_SECRET);
} catch (error) {
if (error instanceof PostalWebhookVerificationError) return res.sendStatus(400);
throw error;
}
// Responda logo (até 15 s) e processe numa fila; o webhook-id não muda nas novas tentativas.
res.sendStatus(200);
if (event.type !== 'email.received') return;
const { email_id, inbox_id, from, subject } = event.data;
const email = await fetch(`https://api.postal.so/emails/receiving/${email_id}`, { headers: api })
.then((response) => response.json());
for (const attachment of email.attachments) {
const file = await fetch(attachment.download_url); // link assinado, vale 1 hora
await salvarAnexo(attachment.filename ?? attachment.id, Buffer.from(await file.arrayBuffer()));
}
await abrirChamado({ inbox_id, from, subject, text: email.text, html: email.html });
});
PHP
use Postal\Exceptions\WebhookVerificationException;
use Postal\Webhook;
try {
$event = Webhook::verify(
file_get_contents('php://input'), // corpo cru
getallheaders(),
getenv('POSTAL_WEBHOOK_SECRET'),
);
} catch (WebhookVerificationException $e) {
http_response_code(400);
exit;
}
if ($event['type'] === 'email.received') {
$id = $event['data']['email_id'];
$api = stream_context_create(['http' => [
'header' => 'Authorization: Bearer ' . getenv('POSTAL_API_KEY'), // chave de acesso total
]]);
$email = json_decode(file_get_contents("https://api.postal.so/emails/receiving/{$id}", false, $api), true);
foreach ($email['attachments'] as $attachment) {
$name = basename($attachment['filename'] ?? $attachment['id']);
file_put_contents("/var/anexos/{$id}-{$name}", file_get_contents($attachment['download_url']));
}
abrirChamado($event['data']['from'], $event['data']['subject'], $email['text'], $email['html']);
}
http_response_code(200);
Instale com npm install postal-so ou composer require postal-so/postal-php (veja SDKs). Sem SDK, ou em outra linguagem, confira a assinatura como em Verificar a assinatura, sempre sobre o corpo exatamente como chegou.
#Conteúdo, anexos e original
GET /emails/receiving/{id} devolve a mensagem completa: text, html, reply_to, os cabeçalhos guardados e os anexos. É preciso uma chave de API de acesso total; uma chave só de envio recebe 401 restricted_api_key.
cURL
curl https://api.postal.so/emails/receiving/019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/emails/receiving/019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d', {
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/receiving/019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d",
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/receiving/019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "received_email",
"id": "019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d",
"inbox_id": "0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
"created_at": "2026-11-04T12:41:09.000000Z",
"from": "Bruno Lima <[email protected]>",
"to": ["[email protected]"],
"cc": [],
"subject": "Nota fiscal do pedido #10475",
"message_id": "[email protected]",
"read": false,
"size": 66103,
"has_attachments": true,
"otp_token": null,
"reply_to": [],
"html": "<p>Oi! Pode reenviar a nota fiscal do pedido #10475? Segue o comprovante.</p>",
"text": "Oi! Pode reenviar a nota fiscal do pedido #10475? Segue o comprovante.",
"headers": {
"message-id": ["<[email protected]>"],
"date": ["Wed, 4 Nov 2026 09:41:07 -0300"]
},
"attachments": [
{
"id": "019a1c00-3e50-7b6c-8d7e-9f0a1b2c3d4e",
"filename": "comprovante-10475.pdf",
"content_type": "application/pdf",
"content_id": null,
"size": 48213,
"download_url": "https://api.postal.so/received-attachments/019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d/019a1c00-3e50-7b6c-8d7e-9f0a1b2c3d4e?expires=1762263669&signature=5f1c…"
}
]
}
- Anexos: cada um tem
download_url, um link assinado que vale 1 hora e não pede a chave. Passou do prazo, o link responde403: busque a mensagem de novo para um link novo. Imagens embutidas no HTML têmcontent_id. - Cabeçalhos:
headersguarda, em listas,message-id,in-reply-to,references,date,list-unsubscribe,auto-submitted,x-postal-forwardedeotp-token, quando a mensagem os tem. Os demais estão no original. - Original:
GET /emails/receiving/{id}/rawdevolve a mensagem exatamente como chegou ao servidor (text/plain, RFC 5322), com todos os cabeçalhos e partes MIME. - Códigos de acesso:
otp_tokené{"code", "origin"}quando a mensagem tem um cabeçalhoOTP-Tokenválido, enullnos outros casos. Veja Cabeçalho OTP-Token, com um teste de login de ponta a ponta. - Lidas e não lidas: marque com
PATCH /emails/receiving/{id}({"read": true}) para controlar o que o seu código já processou.
Sem webhook: consultar a lista
Se o seu sistema não recebe webhooks, consulte GET /emails/receiving de tempos em tempos, filtrando pela inbox e pelas não lidas, e marque como lida cada mensagem processada. A lista traz o resumo, da mais recente para a mais antiga.
cURL
curl 'https://api.postal.so/emails/receiving?inbox_id=0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f&unread=true&limit=20' \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/emails/receiving?inbox_id=0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f&unread=true&limit=20', {
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/receiving",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
params={
"inbox_id": "0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
"unread": "true",
"limit": "20",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('emails/receiving', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'query' => [
'inbox_id' => '0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f',
'unread' => 'true',
'limit' => '20',
],
]);
$data = json_decode($response->getBody(), true);
#Responder e encaminhar
Para responder pelo seu sistema, envie com POST /emails, de um domínio verificado, e passe os cabeçalhos In-Reply-To e References com o message_id recebido entre < >. Assim o cliente de e-mail de quem escreveu mostra a resposta na mesma conversa. Use o reply_to da mensagem, quando houver, como destinatário.
// email: a resposta de GET /emails/receiving/{id}
const original = `<${email.message_id}>`; // message_id vem sem os sinais < >
await fetch('https://api.postal.so/emails', {
method: 'POST',
headers: { ...api, 'Content-Type': 'application/json' },
body: JSON.stringify({
from: 'Loja Exemplo <[email protected]>',
to: email.reply_to.length ? email.reply_to : [email.from],
subject: email.subject.startsWith('Re:') ? email.subject : `Re: ${email.subject}`,
text: 'Oi, Bruno! Segue a nota fiscal do pedido #10475.',
headers: { 'In-Reply-To': original, References: original },
}),
});
- Pela conversa da inbox: numa inbox de um domínio seu verificado,
POST /inboxes/{id}/threads/{thread_id}/replyresponde e guarda a resposta na conversa, visível no painel. Inboxes emin.postal.sosó recebem. - Encaminhar automaticamente:
forward_tona inbox (até 5 endereços, emPATCH /inboxes/{id}) encaminha cada mensagem nova. Só em inboxes de endereço exato num domínio seu verificado. - Encaminhar uma mensagem:
POST /emails/receiving/{id}/forward, para até 5 endereços, com as mesmas condições.
#Limites e retenção
| Item | Limite | O que acontece |
|---|---|---|
| Tamanho da mensagem | 25 MB, com os anexos | O servidor recusa na entrega: 552 5.3.4. |
| Destinatários por entrega | 50 | Os seguintes recebem 452 4.5.3, e o servidor remetente os entrega numa próxima conexão. |
| Mensagens por servidor remetente | 60 por minuto, por IP | Aviso temporário 421 4.7.0; o servidor remetente tenta de novo. |
| Webhook | 2xx em até 15 segundos | Até 8 tentativas em cerca de 27 horas, com o mesmo webhook-id. |
| Link de anexo | 1 hora | Busque a mensagem de novo para um link novo. |
| Encaminhamento | 5 endereços | Só em inboxes de endereço exato num domínio verificado. |
| Retenção | O prazo do plano (retention_days), 30 dias por padrão | A mensagem, os anexos e o original são apagados na limpeza da noite. |
O que a Postal não faz com o que chega
A Postal não filtra spam nem vírus e não verifica nem registra SPF, DKIM ou DMARC das mensagens recebidas. O seu código recebe tudo o que foi enviado a um endereço que tem inbox. Se o seu fluxo depende de quem enviou, confira no original (/raw), trate anexos como arquivos não confiáveis e guarde no seu sistema o que precisar manter além da retenção.
#Erros e solução de problemas
| O que acontece | Causa provável e o que fazer |
|---|---|
Quem envia recebe 550 5.1.1 Recipient address rejected: no such mailbox | Não há inbox para o endereço, ou o domínio está com o recebimento desligado. Crie a inbox (ou um catch-all) e confira capabilities.receiving. |
| As mensagens continuam chegando no Google ou no Microsoft | O domínio ainda tem outro MX, ou o novo registro não propagou. Consulte GET /domains/{id}/receiving-check e prefira um subdomínio. |
552 5.3.4 ou 421 4.7.0 na entrega | Mensagem acima de 25 MB, ou mais de 60 mensagens por minuto do mesmo servidor. O segundo caso é temporário. |
451 4.3.0 Temporary failure na entrega | Falha temporária ao guardar a mensagem. O servidor remetente tenta de novo sozinho. |
403 plan_limit_reached ao criar a inbox ou ativar o recebimento | O plano não inclui inboxes no seu domínio, ou o time chegou ao número de inboxes do plano. |
422 validation_error ao criar a inbox | O domínio não tem o recebimento ativo, o local_part é inválido ou o endereço já está em uso. |
| O webhook não chega | Confira se o endpoint assina email.received e está ativo: um endpoint que falha por 5 dias é desativado. As tentativas ficam em Webhooks (GET /webhooks/{id}/events), com o status e a resposta de cada uma. |
| A assinatura não confere | O corpo foi lido como JSON antes da verificação, o segredo é de outro endpoint ou o relógio do servidor está fora de hora (mais de 5 minutos). |
401 restricted_api_key ao ler a mensagem | A chave é só de envio. Os endpoints de recebidos pedem uma chave de acesso total. |
403 no download_url | O link passou de 1 hora. Busque a mensagem de novo. |
404 ao buscar a mensagem | A mensagem foi excluída ou passou da retenção, ou a chave é de outro time. |
Veja também Erros, a referência de Recebidos e inboxes e o guia de Webhooks.