Webhooks
Receba eventos de entrega, bounces, cliques, e-mails recebidos e mudanças de domínio no seu servidor, em tempo real e assinados.
#Configurar
Cadastre a URL que vai receber os eventos no painel, em Webhooks, ou com POST /webhooks, escolhendo os eventos. A URL precisa ser HTTPS e pública. Guarde o signing_secret (whsec_…): ele serve para conferir que a requisição veio da Postal. O número de endpoints depende do plano (veja Limites).
#Eventos
| Evento | Quando acontece |
|---|---|
email.sent | A Postal entregou o e-mail ao provedor de envio. |
email.delivered | O servidor do destinatário aceitou a mensagem. |
email.delivery_delayed | A entrega foi adiada (caixa cheia, servidor indisponível…). Novas tentativas acontecem sozinhas. |
email.bounced | O e-mail voltou. Em bounces permanentes, o endereço vai para a lista de supressão. |
email.complained | O destinatário marcou como spam. O endereço vai para a lista de supressão. |
email.opened | O e-mail foi aberto (rastreamento de aberturas ligado). |
email.clicked | Um link foi clicado (rastreamento de cliques ligado). |
email.replied | Alguém respondeu a um e-mail enviado com detecção de respostas (detect_replies numa campanha ou num passo de envio de uma automação). Respostas automáticas, como as de fora do escritório, chegam com reply.auto: true. |
email.failed | Não foi possível enviar. failed.reason diz o motivo. |
email.scheduled | O e-mail foi agendado. |
email.suppressed | Destinatários estavam na lista de supressão e foram removidos do envio. |
email.received | Uma inbox recebeu uma mensagem. O código de um cabeçalho OTP-Token não vai no payload. |
domain.created | Um domínio foi cadastrado. |
domain.updated | Um domínio mudou (status da verificação, rastreamento, capacidades). |
domain.deleted | Um domínio foi removido (inclusive quando outra conta o reivindicou). |
suppression.added | Um endereço entrou na lista de supressão. |
suppression.removed | Um endereço saiu da lista de supressão. |
contact.created | Um contato foi criado (pela API, por importação ou por formulário). |
contact.updated | Um contato mudou (dados, propriedades ou descadastro). |
contact.deleted | Um contato foi excluído. |
contact.segment_entered | Um contato passou a fazer parte de um segmento dinâmico, porque agora cumpre os filtros. Segmentos estáticos não geram o evento. |
contact.segment_exited | Um contato deixou de fazer parte de um segmento dinâmico, porque não cumpre mais os filtros. |
form.submitted | Alguém se inscreveu por um formulário e entrou na audiência (depois da confirmação, com double opt-in). |
broadcast.sent | Uma campanha terminou de ser enviada: todos os destinatários foram processados. |
sending.paused | Os envios do time foram pausados por taxa alta de bounces ou reclamações (veja Limites). A partir daí os envios recebem 403 sending_paused. O data traz o motivo em reason (no idioma do time), reason_code e reason_details: veja Pausa de envios. |
sending.resumed | Os envios do time foram liberados de novo. |
inbox.thread.created | Uma conversa nova foi aberta numa inbox, por um e-mail recebido ou por uma mensagem enviada pela inbox. |
inbox.thread.updated | A pasta, o estado de lida, o responsável ou as etiquetas de uma conversa mudaram (PATCH, ação em lote, lixeira ou agente de IA), ou um e-mail novo a tirou do arquivo. Um evento por mudança. |
inbox.thread.deleted | Uma conversa foi apagada de vez (a partir da lixeira). |
inbox.draft.created | O agente de IA de uma inbox criou um rascunho de resposta. Rascunhos criados por pessoas não geram evento. |
#Formato do payload
Cada entrega é um POST com um JSON de três campos: type, created_at e data. Nos eventos de e-mail, data traz o e-mail e, conforme o tipo, um objeto extra. As tags vêm como objeto {"category": "shipping"} (nome da tag → valor). Se o e-mail saiu de uma campanha, data traz também broadcast_id; se está ligado a um contato (campanhas e automações), contact_id e contact_external_id.
{
"type": "email.delivered",
"created_at": "2026-10-24T13:02:14.120Z",
"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"
}
}
}
Extras por tipo
{
"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"
}
}
}
bounce.type é Permanent (endereço inválido: não tente de novo), Transient (problema temporário) ou Undetermined.
{
"type": "email.clicked",
"created_at": "2026-10-24T13:20:41.530Z",
"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"
},
"click": {
"ipAddress": "203.0.113.24",
"link": "https://lojaexemplo.com.br/rastreio/10482",
"timestamp": "2026-10-24T13:20:41.000Z",
"userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)"
}
}
}
{
"type": "email.replied",
"created_at": "2026-10-24T16:12:08.440Z",
"data": {
"email_id": "019a1f10-6c11-7d22-8e33-4f5a6b7c8d9e",
"created_at": "2026-10-24T15:30:02.000Z",
"from": "Loja Exemplo <[email protected]>",
"to": ["[email protected]"],
"subject": "Ana, a Black Friday começa mais cedo para você",
"tags": {
"broadcast_id": "019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b"
},
"broadcast_id": "019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b",
"contact_id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"contact_external_id": "user_8421",
"reply": {
"from": "Ana Souza <[email protected]>",
"subject": "Re: Ana, a Black Friday começa mais cedo para você",
"message_id": "[email protected]",
"preview": "Oi! Posso trocar o tamanho do meu pedido antes da promoção?",
"attachments": 0,
"auto": false
}
}
}
reply.auto é true nas respostas automáticas (fora do escritório, autoresponders), que não contam como resposta de verdade nas automações. reply.preview traz os primeiros 200 caracteres do texto e reply.attachments, só a quantidade de anexos. A resposta chega ao endereço de resposta que a Postal cria para o e-mail e é encaminhada para o reply_to que você definiu.
{
"type": "email.failed",
"created_at": "2026-10-24T13:02:12.300Z",
"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"
},
"failed": {
"reason": "The sending domain is not verified."
}
}
}
{
"type": "email.suppressed",
"created_at": "2026-10-24T13:02:12.300Z",
"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"
},
"suppressed": {
"diagnosticCode": [],
"message": "The recipient is on the suppression list: [email protected]",
"reason": "previous_bounce",
"type": "Suppressed"
}
}
}
{
"type": "email.received",
"created_at": "2026-10-24T12: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-10-24T12:41:09.000Z",
"from": "Carla Souza <[email protected]>",
"to": ["[email protected]"],
"cc": [],
"subject": "Troca de tamanho do pedido #10482",
"message_id": "<[email protected]>",
"attachments": [
{
"id": "019a1c00-3e50-7b6c-8d7e-9f0a1b2c3d4e",
"filename": "label-photo.jpg",
"content_type": "image/jpeg",
"size": 15620
}
]
}
}
O evento traz só o resumo (remetente, assunto e anexos, sem o corpo). Para ler o conteúdo, use GET /emails/receiving/{email_id}; para ver a conversa inteira, GET /inboxes/{inbox_id}/threads/{thread_id}. O webhook vale para todas as inboxes do time: filtre pelo inbox_id se quiser tratar só uma. Marque Recebido ao criar o webhook no painel (ou inclua email.received em events).
{
"type": "domain.updated",
"created_at": "2026-10-02T18:31:44.210Z",
"data": {
"id": "0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f",
"name": "lojaexemplo.com.br",
"status": "verified",
"created_at": "2026-10-02T18:20:00.000Z",
"region": "sa-east-1",
"records": [
{
"record": "DKIM",
"name": "postal._domainkey",
"value": "p=MIIBIjANBgkqh…IDAQAB",
"type": "TXT",
"ttl": "Auto",
"status": "verified"
}
]
}
}
Quando um registro obrigatório de um domínio já verificado deixa de ser encontrado, o domain.updated traz também records_missing, com since, deadline_at e os records que faltam. Veja quando um registro some.
{
"type": "suppression.added",
"created_at": "2026-10-24T13:02:15.400Z",
"data": {
"id": "019a1530-6d7e-7f80-9a1b-2c3d4e5f6a7b",
"email": "[email protected]",
"origin": "bounce",
"source_id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
"created_at": "2026-10-24T13:02:15.000Z"
}
}
{
"type": "contact.created",
"created_at": "2026-10-24T14:00:00.310Z",
"data": {
"id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"email": "[email protected]",
"external_id": "user_8421",
"user_id": "user_8421",
"first_name": "Ana",
"last_name": "Souza",
"unsubscribed": false,
"properties": {
"plan": "pro"
},
"source": "api",
"created_at": "2026-10-24T14:00:00.000Z",
"updated_at": "2026-10-24T14:00:00.000Z"
}
}
contact.updated e contact.deleted trazem o mesmo formato (no deleted, o contato como estava antes de ser excluído). external_id é o ID do contato no seu sistema (user_id é o mesmo valor, com outro nome) e source diz de onde veio a mudança: veja a origem das mudanças.
{
"type": "contact.segment_entered",
"created_at": "2026-10-24T14:05:31.870Z",
"data": {
"segment": {
"id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"name": "Clientes ativos",
"type": "dynamic"
},
"contact": {
"id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"email": "[email protected]",
"external_id": "user_8421",
"user_id": "user_8421",
"first_name": "Ana",
"last_name": "Souza",
"unsubscribed": false,
"properties": {
"plan": "pro"
},
"source": "api",
"created_at": "2026-10-24T14:00:00.000Z",
"updated_at": "2026-10-24T14:05:30.000Z"
}
}
}
contact.segment_exited tem o mesmo formato. data traz o segment e o contact (no formato acima). Os dois eventos valem só para segmentos dinâmicos, são disparados uma única vez por entrada ou saída, e um segmento recém-criado ou com filtros alterados começa de uma linha de base silenciosa: quem já cumpre os filtros não gera segment_entered.
{
"type": "form.submitted",
"created_at": "2026-10-24T15:03:12.800Z",
"data": {
"id": "019a1e20-8091-7ca2-9d1e-3f4a5b6c7d8e",
"form_id": "019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d",
"form_name": "Newsletter",
"contact_id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"email": "[email protected]",
"data": {
"email": "[email protected]",
"first_name": "Ana",
"properties": {}
},
"answers": {
"email": "[email protected]",
"first_name": "Ana"
},
"consent_given_at": "2026-10-24T15:00:00.000Z",
"status": "subscribed",
"created_at": "2026-10-24T15:00:00.000Z",
"confirmed_at": "2026-10-24T15:03:12.000Z"
}
}
Em answers, as respostas pelo ID de cada bloco do formulário; consent_given_at é o momento do consentimento (ou null).
{
"type": "broadcast.sent",
"created_at": "2026-10-22T12:04:31.220Z",
"data": {
"id": "019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b",
"name": "Black Friday: acesso antecipado",
"segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"topic_id": "019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b",
"from": "Loja Exemplo <[email protected]>",
"subject": "{{{FIRST_NAME|Cliente}}}, a Black Friday começa mais cedo para você",
"status": "sent",
"recipients_count": 1247,
"created_at": "2026-10-21T18:40:00.000Z",
"sent_at": "2026-10-22T12:04:31.000Z"
}
}
Os e-mails de cada destinatário geram os eventos email.* normais, com a tag broadcast_id; os das automações, com automation_id e automation_run_id.
Pausa de envios
sending.paused e sending.resumed não têm e-mail: data traz o estado dos envios do time. Use para alertar a sua equipe na hora em que a pausa acontece, em vez de descobrir pelos 403.
{
"type": "sending.paused",
"created_at": "2026-10-24T15:20:02.310Z",
"data": {
"paused": true,
"reason": "Taxa de bounce de 6,40% nos últimos 7 dias (limite: 5,00%).",
"reason_code": "bounce_rate",
"reason_details": {
"bounce_rate": 0.064,
"limit": 0.05,
"window_days": 7
},
"paused_at": "2026-10-24T15:20:02.000Z"
}
}
| Campo | O que traz |
|---|---|
paused | true em sending.paused e false em sending.resumed. |
reason | O motivo em texto para pessoas, no idioma do time: o que o dono do time escolheu no painel (pt-BR ou en); enquanto ele não escolhe, em inglês. Quando a equipe da Postal pausa os envios à mão, é o texto que ela escreveu, como foi escrito (sem texto, a frase padrão no idioma do time). Pausas registradas antes de existir o reason_code mantêm o texto em português com que foram salvas. |
reason_code | O motivo como código estável, igual em qualquer idioma: bounce_rate (taxa de bounces permanentes), complaint_rate (taxa de reclamações), content_rejected (taxa de e-mails recusados pelo conteúdo), all_bounce_rate (taxa de bounces de todos os tipos, nos níveis 1 a 3 da reputação da conta) ou manual (a equipe da Postal pausou). Trate um código que você não conhece como uma pausa qualquer. null em sending.resumed e nas pausas antigas. |
reason_details | Os números por trás do motivo. Em bounce_rate: {"bounce_rate", "limit", "window_days"}; em complaint_rate: {"complaint_rate", "limit", "window_days"}; em content_rejected: {"content_rejected_rate", "limit", "window_days"}; em all_bounce_rate: {"all_bounce_rate", "limit", "window_days"}. As taxas são frações (0.0526 é 5,26%) e window_days é a janela, em dias, em que foram medidas. Quando a pausa veio da janela das últimas 24 horas (níveis 1 a 3), o lugar de window_days é ocupado por window_hours (24). null em manual, em sending.resumed e nas pausas antigas. |
paused_at | Quando a pausa começou (ISO 8601, UTC); null em sending.resumed. |
Para decidir o que fazer, use paused e reason_code, nunca o texto de reason: ele existe para ser mostrado a pessoas e pode mudar de palavras. O estado atual, no idioma da requisição, está em GET /usage. Em sending.resumed, paused é false e reason, reason_code, reason_details e paused_at são null.
Conversas e rascunhos das inboxes
Nos eventos inbox.thread.*, data é a conversa no formato de um item de GET /inboxes/{id}/threads (sem object e sem as mensagens): id, inbox_id, subject, folder, read, unread_count, message_count, snippet, participants, has_attachments, has_draft, has_sent, last_message_at, created_at, assigned_to e labels. Em inbox.thread.updated ela já vem com a mudança aplicada e em inbox.thread.deleted como estava antes de ser apagada. Uma ação em lote gera um inbox.thread.updated por conversa que realmente mudou. Para ler as mensagens, use GET /inboxes/{inbox_id}/threads/{id}.
{
"type": "inbox.thread.updated",
"created_at": "2026-10-24T13:10:42.620Z",
"data": {
"id": "019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e",
"inbox_id": "0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
"subject": "Troca de tamanho do pedido #10482",
"folder": "archive",
"read": true,
"unread_count": 0,
"message_count": 2,
"snippet": "Oi, Carla! Já separamos o tamanho M para você.",
"participants": [
{
"name": "Carla Souza",
"email": "[email protected]",
"is_me": false
},
{
"name": "Ada do Suporte",
"email": "[email protected]",
"is_me": true
}
],
"has_attachments": true,
"has_draft": false,
"has_sent": true,
"last_message_at": "2026-10-24T13:02:11.000Z",
"created_at": "2026-10-24T12:41:09.000Z",
"assigned_to": {
"id": 12,
"name": "Carlos Lima",
"email": "[email protected]",
"avatar_url": null
},
"labels": [
{
"id": "019a1c01-0b21-7e32-9f43-1a2b3c4d5e6f",
"name": "Importante",
"color": "orange"
}
]
}
}
inbox.thread.created e inbox.thread.deleted têm o mesmo formato. Em inbox.draft.created, data é o rascunho no formato de GET /inboxes/{id}/drafts/{id}, sem object; como só o agente dispara o evento, source é sempre "agent" e created_by é null.
{
"type": "inbox.draft.created",
"created_at": "2026-10-24T12:41:14.205Z",
"data": {
"id": "019a1c02-1c32-7f43-8a54-2b3c4d5e6f70",
"inbox_id": "0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
"thread_id": "019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e",
"source": "agent",
"to": ["[email protected]"],
"cc": [],
"bcc": [],
"subject": "Re: Troca de tamanho do pedido #10482",
"text": "Oi, Carla! Vamos separar o tamanho M e já te avisamos quando o envio sair.",
"html": null,
"attachments": [],
"created_by": null,
"created_at": "2026-10-24T12:41:14.000Z",
"updated_at": "2026-10-24T12:41:14.000Z"
}
}
#Origem das mudanças nos contatos
Os eventos contact.created, contact.updated e contact.deleted trazem source, a origem da mudança: api (uma chave de API), dashboard, form (formulário), import, automation, system, crm:<id da conexão> (uma conexão de CRM) ou o rótulo que quem chamou a API escolheu. A mesma origem fica no contato, em last_change_source, e filtra GET /contacts com source (veja a origem na referência de contatos).
Para dar um rótulo às mudanças que um sistema seu faz, mande o cabeçalho X-Postal-Source com a chave de API (ou o campo source no corpo, onde ele existe, como em POST /contacts): X-Postal-Source: erp. O rótulo tem até 64 caracteres (letras minúsculas, números, ., _, : ou -). Os rótulos que descrevem os canais da própria Postal são reservados (dashboard, form, import, automation, partner, system, postal_lifecycle e tudo que começa com crm:) e dão 422 validation_error.
É o que evita o eco entre dois sistemas que se sincronizam: crie o webhook com skip_sources (POST /webhooks), como ["erp"], e ele não recebe as mudanças que o próprio ERP fez. Um * no fim vale como prefixo (crm:* ignora todas as conexões de CRM). Em contact.deleted, a origem é a de quem excluiu o contato. Os eventos contact.segment_* e os demais não têm source e não são filtrados.
#Verificar a assinatura
As entregas seguem o padrão Standard Webhooks e chegam com estes cabeçalhos:
| Cabeçalho | Conteúdo |
|---|---|
webhook-id | ID da mensagem. É o mesmo em todas as tentativas e nos reenvios: use para descartar duplicatas. |
webhook-timestamp | Momento do envio, em segundos (Unix). |
webhook-signature | Assinatura no formato v1,<base64>. |
svix-id, svix-timestamp, svix-signature | Os mesmos valores, para bibliotecas que leem estes nomes. |
A assinatura é um HMAC-SHA256, em Base64, do texto {webhook-id}.{webhook-timestamp}.{body}, usando como chave o signing_secret sem o prefixo whsec_ e decodificado de Base64. Calcule sobre o corpo exatamente como chegou (sem fazer parse e serializar de novo), compare em tempo constante e recuse timestamps com mais de 5 minutos de diferença, para evitar repetições.
Node.js
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const secret = process.env.POSTAL_WEBHOOK_SECRET; // whsec_...
// O corpo precisa chegar cru (Buffer) para a assinatura bater.
app.post('/webhooks/postal', express.raw({ type: 'application/json' }), (req, res) => {
const id = req.get('webhook-id');
const timestamp = req.get('webhook-timestamp');
const signatures = (req.get('webhook-signature') || '').split(' ');
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.status(400).send('timestamp antigo');
}
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = crypto
.createHmac('sha256', key)
.update(`${id}.${timestamp}.${req.body}`)
.digest('base64');
const valid = signatures.some((item) => {
const [, value = ''] = item.split(',');
return value.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(value), Buffer.from(expected));
});
if (!valid) {
return res.status(401).send('assinatura inválida');
}
const event = JSON.parse(req.body);
// processe event.type e event.data (de preferência numa fila)
res.sendStatus(200);
});
Python
import base64
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["POSTAL_WEBHOOK_SECRET"] # whsec_...
@app.post("/webhooks/postal")
def postal_webhook():
body = request.get_data() # bytes, exatamente como chegaram
msg_id = request.headers["webhook-id"]
timestamp = request.headers["webhook-timestamp"]
signatures = request.headers.get("webhook-signature", "").split(" ")
if abs(time.time() - int(timestamp)) > 300:
abort(400)
key = base64.b64decode(SECRET.removeprefix("whsec_"))
content = f"{msg_id}.{timestamp}.".encode() + body
expected = base64.b64encode(hmac.new(key, content, hashlib.sha256).digest()).decode()
if not any(hmac.compare_digest(item.partition(",")[2], expected) for item in signatures):
abort(401)
event = json.loads(body)
# processe event["type"] e event["data"]
return "", 200
PHP
<?php
$secret = getenv('POSTAL_WEBHOOK_SECRET'); // whsec_...
$body = file_get_contents('php://input'); // corpo cru
$id = $_SERVER['HTTP_WEBHOOK_ID'] ?? '';
$timestamp = $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '';
$signatures = explode(' ', $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '');
if (abs(time() - (int) $timestamp) > 300) {
http_response_code(400);
exit;
}
$key = base64_decode(preg_replace('/^whsec_/', '', $secret));
$expected = base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$body}", $key, true));
$valid = false;
foreach ($signatures as $signature) {
[, $value] = array_pad(explode(',', $signature, 2), 2, '');
$valid = $valid || hash_equals($expected, $value);
}
if (! $valid) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
// processe $event['type'] e $event['data']
http_response_code(200);
Qualquer biblioteca compatível com Standard Webhooks também funciona, como standardwebhooks (npm e PyPI).
#Respostas e novas tentativas
Responda com qualquer status 2xx em até 15 segundos para confirmar o recebimento. Redirecionamentos não são seguidos. Qualquer outra resposta, timeout ou erro de conexão conta como falha, e a Postal tenta de novo depois de:
| Tentativa | Espera depois da anterior |
|---|---|
| 2ª | 5 segundos |
| 3ª | 5 minutos |
| 4ª | 30 minutos |
| 5ª | 2 horas |
| 6ª | 5 horas |
| 7ª | 10 horas |
| 8ª | 10 horas |
Depois de 8 tentativas (cerca de 27 horas), o evento fica como failed. Veja as tentativas e reenvie qualquer evento pelo painel ou com POST /webhooks/{id}/events/{event_id}/replay. Todas as tentativas e os reenvios de um evento usam o mesmo webhook-id. As entregas usam o User-Agent Postal-Webhooks/1.0.
#Desativação automática
Um endpoint que só falha, sem nenhuma entrega com sucesso durante 5 dias, é desativado: o webhook fica com status: "disabled" e disabled_reason: "failing", os eventos que ainda esperavam nova tentativa ficam failed e os donos e admins do time recebem um e-mail. A partir daí, nenhum evento é enviado para ele.
failing_sincemostra desde quando as entregas falham (volta anullna primeira entrega com sucesso) elast_success_at, a última que deu certo. Uma única resposta2xxzera a contagem.- Depois de corrigir o servidor, envie um evento de teste e reative pelo painel (Reativar) ou com
PATCH /webhooks/{id}e{"status": "enabled"}. Os eventos do período desativado não são reenviados sozinhos: reenvie os que precisar. - Um webhook pausado por alguém fica com
disabled_reason: "manual".
#Evento de teste
No painel, Enviar evento de teste (ou POST /webhooks/{id}/test) manda na hora um evento assinado, numa tentativa só, mesmo com o webhook desativado:
{
"type": "webhook.test",
"created_at": "2026-10-24T13:10:00.000Z",
"data": {
"webhook_id": "0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b",
"message": "This is a test event sent from the Postal dashboard."
}
}
Ele aparece nas entregas, com as tentativas e a resposta do seu servidor, e não conta para a desativação automática. Não é preciso assinar o tipo webhook.test.
#Testar com e-mails de teste
Para ver os eventos chegando ao seu servidor sem enviar e-mail de verdade, mande um e-mail para os endereços de teste: [email protected], [email protected], [email protected], [email protected] ou [email protected]. Eles geram entregas de webhook reais, assinadas e com retentativas como as de qualquer e-mail (email.sent e email.delivered, email.bounced com um bounce permanente, email.complained, email.suppressed ou email.delivery_delayed), mas nada é enviado, nenhum endereço entra na lista de supressão e a reputação do seu domínio não muda. Cada endereço gera estes eventos: delivered, email.sent e email.delivered; bounced, email.sent e email.bounced; complained, email.sent, email.delivered e email.complained; suppressed, só email.suppressed; delayed, email.sent e email.delivery_delayed. Os primeiros saem na hora e o resto segundos depois. Dá para acrescentar um rótulo, como [email protected], para separar os seus testes. Os endereços de teste fazem parte do plano (test_addresses) e não se misturam com destinatários de verdade no mesmo e-mail.
#Histórico
Os eventos enviados e as tentativas de cada um ficam 30 dias no painel e em GET /webhooks/{id}/events; depois disso, são apagados.
#Boas práticas
- Responda
200logo e processe em segundo plano (numa fila), para não estourar os 15 segundos. - Guarde os
webhook-idjá processados: a mesma mensagem pode chegar mais de uma vez. - Não dependa da ordem de chegada. Use
created_atdo payload para ordenar. - Troque o segredo com
POST /webhooks/{id}/signing-secret/rotatese ele vazar.
#Webhooks de CRM e de apps
Estes são os webhooks que a Postal envia ao seu servidor. O caminho inverso, um CRM ou um app mandando mudanças de pessoas para a Postal, tem a sua própria URL e usa a mesma assinatura: veja Conexão de CRM por webhook, Conexões de CRM e Integre seu app. Uma conexão de CRM por webhook também recebe da Postal os eventos de contato e de e-mail, assinados como acima.