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.

#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.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.
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.
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.

#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).

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.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",
    "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}.

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"
      }
    ]
  }
}
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]",
    "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).

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": {}
    },
    "status": "subscribed",
    "created_at": "2026-10-24T15:00:00.000Z",
    "confirmed_at": "2026-10-24T15:03:12.000Z"
  }
}
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.

#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. As entregas usam o User-Agent Postal-Webhooks/1.0.

#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.