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.
#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.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. |
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. |
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. |
#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).
{
"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.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",
"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
}
]
}
}
Para ler o conteúdo de um e-mail recebido, use GET /emails/receiving/{email_id}.
{
"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"
}
]
}
}
{
"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]",
"first_name": "Ana",
"last_name": "Souza",
"unsubscribed": false,
"properties": {
"plan": "pro"
},
"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).
{
"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": {}
},
"status": "subscribed",
"created_at": "2026-10-24T15:00:00.000Z",
"confirmed_at": "2026-10-24T15:03:12.000Z"
}
}
{
"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.
#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. As entregas usam o User-Agent Postal-Webhooks/1.0.
#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.