Entrar Criar conta
Guias

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

EventoQuando acontece
email.sentA Postal entregou o e-mail ao provedor de envio.
email.deliveredO servidor do destinatário aceitou a mensagem.
email.delivery_delayedA entrega foi adiada (caixa cheia, servidor indisponível…). Novas tentativas acontecem sozinhas.
email.bouncedO e-mail voltou. Em bounces permanentes, o endereço vai para a lista de supressão.
email.complainedO destinatário marcou como spam. O endereço vai para a lista de supressão.
email.openedO e-mail foi aberto (rastreamento de aberturas ligado).
email.clickedUm link foi clicado (rastreamento de cliques ligado).
email.repliedAlgué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.failedNão foi possível enviar. failed.reason diz o motivo.
email.scheduledO e-mail foi agendado.
email.suppressedDestinatários estavam na lista de supressão e foram removidos do envio.
email.receivedUma inbox recebeu uma mensagem. O código de um cabeçalho OTP-Token não vai no payload.
domain.createdUm domínio foi cadastrado.
domain.updatedUm domínio mudou (status da verificação, rastreamento, capacidades).
domain.deletedUm domínio foi removido (inclusive quando outra conta o reivindicou).
suppression.addedUm endereço entrou na lista de supressão.
suppression.removedUm endereço saiu da lista de supressão.
contact.createdUm contato foi criado (pela API, por importação ou por formulário).
contact.updatedUm contato mudou (dados, propriedades ou descadastro).
contact.deletedUm contato foi excluído.
contact.segment_enteredUm 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_exitedUm contato deixou de fazer parte de um segmento dinâmico, porque não cumpre mais os filtros.
form.submittedAlguém se inscreveu por um formulário e entrou na audiência (depois da confirmação, com double opt-in).
broadcast.sentUma campanha terminou de ser enviada: todos os destinatários foram processados.
sending.pausedOs 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.resumedOs envios do time foram liberados de novo.
inbox.thread.createdUma conversa nova foi aberta numa inbox, por um e-mail recebido ou por uma mensagem enviada pela inbox.
inbox.thread.updatedA 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.deletedUma conversa foi apagada de vez (a partir da lixeira).
inbox.draft.createdO 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.

email.delivered
{
  "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

email.bounced
{
  "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.

email.clicked
{
  "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)"
    }
  }
}
email.replied
{
  "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.

email.failed
{
  "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."
    }
  }
}
email.suppressed
{
  "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"
    }
  }
}
email.received
{
  "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).

domain.updated
{
  "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.

suppression.added
{
  "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"
  }
}
contact.created
{
  "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.

contact.segment_entered
{
  "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.

form.submitted
{
  "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).

broadcast.sent
{
  "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.

sending.paused
{
  "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"
  }
}
CampoO que traz
pausedtrue em sending.paused e false em sending.resumed.
reasonO 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_codeO 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_detailsOs 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_atQuando 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}.

inbox.thread.updated
{
  "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.

inbox.draft.created
{
  "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çalhoConteúdo
webhook-idID da mensagem. É o mesmo em todas as tentativas e nos reenvios: use para descartar duplicatas.
webhook-timestampMomento do envio, em segundos (Unix).
webhook-signatureAssinatura no formato v1,<base64>.
svix-id, svix-timestamp, svix-signatureOs 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.

Verificação

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:

TentativaEspera 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_since mostra desde quando as entregas falham (volta a null na primeira entrega com sucesso) e last_success_at, a última que deu certo. Uma única resposta 2xx zera 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:

webhook.test
{
  "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 200 logo e processe em segundo plano (numa fila), para não estourar os 15 segundos.
  • Guarde os webhook-id já processados: a mesma mensagem pode chegar mais de uma vez.
  • Não dependa da ordem de chegada. Use created_at do payload para ordenar.
  • Troque o segredo com POST /webhooks/{id}/signing-secret/rotate se 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.