Webhook de status

Cadastre a URL do seu endpoint no painel, em Integrações. A cada mudança de status de uma mensagem enviada, fazemos um POST nela, assinado.

O que chega (link para esta seção)

corpo do POST
{
  "event": "message.status",
  "occurred_at": "2026-07-30T18:00:00Z",
  "delivery_id": "1841",
  "data": {
    "message_id": "wamid.HBgNNTUyMT...",
    "status": "delivered",
    "status_rank": 3,
    "to": "5521999999999",
    "error": null,
    "phone_number_id": "840747309123670",
    "client_ref": "pedido-8421",
    "campaign_id": null
  }
}

Headers (link para esta seção)

HeaderO que é
X-PT-TimestampEpoch em SEGUNDOS, inteiro. Entra na assinatura. Muda a cada tentativa.
X-PT-SignaturePrefixo sha256= mais o HMAC em hexadecimal minúsculo.
X-PT-Delivery-IdId desta entrega. Repete nas retentativas do mesmo evento.
X-PT-AttemptNúmero da tentativa, começando em 1.
X-PT-EventNome do evento. Hoje sempre message.status.

Conferir a assinatura (link para esta seção)

A assinatura é o HMAC-SHA256 de "timestamp" + "." + corpo cru, em hexadecimal minúsculo, com o prefixo sha256=. O segredo aparece no painel, em Integrações.

Assine o corpo cru, byte a byte, como chegou. Se você parsear o JSON e serializar de novo para conferir, o espaçamento muda e o HMAC não bate. É o erro número um de quem integra webhook assinado.
Node.js (Express)
const crypto = require("crypto");

// Guarde o corpo CRU. Sem isto o express.json() já consumiu o stream.
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));

function assinaturaValida(req, segredo) {
  const ts = req.get("X-PT-Timestamp");
  const assinada = req.get("X-PT-Signature") || "";
  const esperada =
    "sha256=" +
    crypto.createHmac("sha256", segredo).update(`${ts}.`).update(req.rawBody).digest("hex");

  // Comparação em tempo constante: comparar com === vaza, pelo tempo, o quanto você acertou.
  const a = Buffer.from(assinada);
  const b = Buffer.from(esperada);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return false;

  // Recusa evento velho (replay). 5 minutos é folgado e cobre relógio fora de sincronia.
  return Math.abs(Date.now() / 1000 - Number(ts)) < 300;
}

app.post("/webhooks/patrociniotech", (req, res) => {
  if (!assinaturaValida(req, process.env.PT_WEBHOOK_SECRET)) return res.sendStatus(401);
  enfileirarParaProcessar(req.body);  // grave e processe depois
  res.sendStatus(200);                // responda rápido
});
PHP
$corpoCru = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_PT_TIMESTAMP'] ?? '';
$assinada = $_SERVER['HTTP_X_PT_SIGNATURE'] ?? '';

$esperada = 'sha256=' . hash_hmac('sha256', $ts . '.' . $corpoCru, $segredo);

if (!hash_equals($esperada, $assinada) || abs(time() - (int) $ts) > 300) {
    http_response_code(401);
    exit;
}

http_response_code(200);
A assinatura é recalculada a cada tentativa, com o timestamp do momento do disparo. Uma retentativa nossa de seis horas depois chega com assinatura nova e válida, então dá para manter a janela de replay apertada sem medo de recusar reenvio legítimo.

Campos do evento (link para esta seção)

CampoO que é
message_idO mesmo id devolvido no envio.
statussent, delivered, read ou failed.
status_rankOrdem do ciclo de vida, de 1 a 4. A entrega não garante ordem: compare com o que você já guardou e ignore o menor, senão um sent atrasado apaga um read que já chegou.
toNúmero do destinatário. Mesmo nome do campo no envio.
errorObjeto {code, message, reason} só quando o status é failed; nulo nos outros. reason é o nosso código estável, code é o número da Meta.
client_refO que você mandou no envio. É por aqui que se correlaciona.
campaign_idPreenchido quando a mensagem saiu de uma campanha do painel.
delivery_idId desta entrega. Repete nas retentativas, então serve de chave de idempotência do seu lado.
occurred_atQuando o evento aconteceu. NÃO muda entre tentativas (quem muda é o header).

Como responder (link para esta seção)

Qualquer coisa fora de 2xx entra na fila de retentativa. Se o seu processamento é demorado, grave o evento e responda na hora; processe depois. Endpoint lento vira fila acumulada e atraso em todo mundo.

Trate o mesmo delivery_id chegando duas vezes como normal: a rede falha, e preferimos entregar de novo a perder. Use o delivery_id como chave de idempotência.

Se o seu endpoint cair (link para esta seção)

Nada se perde. Cada evento vira uma linha numa fila e tentamos de novo em 30 segundos, 2 minutos, 10 minutos, 1 hora, 6 horas e 24 horas: cerca de 31 horas de janela. O que esgota as tentativas fica guardado esperando reenvio, que você dispara pelo painel ou pela API de entregas.