Webhook
Cadastre a URL do seu endpoint no painel, em Integrações, e escolha os eventos. Fazemos um POST assinado nela a cada acontecimento.
Os dois eventos (link para esta seção)
| Evento | Dispara quando | Serve para |
|---|---|---|
message.status | Uma mensagem SUA muda de estado. | Saber se chegou, se foi lida ou se falhou. |
message.received | O contato escreve para você. | Reagir à resposta e saber que a janela de 24h reabriu. |
message.status o seu sistema fica sabendo se a mensagem dele chegou, mas nunca o que a pessoa respondeu.message.status (link para esta seção)
{
"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
}
}message.received (link para esta seção)
O contato respondeu. É por aqui que o seu sistema fica sabendo o que ele escreveu, sem ninguém precisar abrir o painel.
{
"event": "message.received",
"occurred_at": "2026-08-02T11:14:22Z",
"delivery_id": "1842",
"data": {
"message_id": "wamid.HBgNNTUyMT...",
"from": "5521999999999",
"phone_number_id": "1234567890",
"type": "text",
"text": "Recebi, obrigado!",
"media_id": null,
"media_mime": null,
"media_filename": null,
"reply_to_message_id": "wamid.HBgNNTUyMT...",
"contact_name": "Maria",
"occurred_at": "2026-08-02T11:14:22Z",
"window_expires_at": "2026-08-03T11:14:22Z"
}
}| Campo | O que é |
|---|---|
| from | Quem escreveu. Em message.status o campo é to, porque lá o destinatário é o contato. Papéis diferentes, nomes diferentes. |
| type | text, image, audio, video, document, sticker, location, contacts, unsupported. |
| text | O texto, ou a legenda quando veio mídia. Nulo quando não há texto. |
| media_id | Presente quando veio arquivo. Baixe pela conversa no painel; o WhatsApp apaga a mídia do lado da Meta em cerca de 7 dias. |
| reply_to_message_id | Quando ele respondeu marcando uma mensagem sua, é o id dela. Serve para amarrar a resposta ao pedido. |
| window_expires_at | Até quando você pode responder com texto livre, de graça. Guarde e economize a consulta da janela. |
Headers (link para esta seção)
| Header | O que é |
|---|---|
| X-PT-Timestamp | Epoch em SEGUNDOS, inteiro. Entra na assinatura. Muda a cada tentativa. |
| X-PT-Signature | Prefixo sha256= mais o HMAC em hexadecimal minúsculo. |
| X-PT-Delivery-Id | Id desta entrega. Repete nas retentativas do mesmo evento. |
| X-PT-Attempt | Número da tentativa, começando em 1. |
| X-PT-Event | Nome 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.
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
});$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);Campos de message.status (link para esta seção)
| Campo | O que é |
|---|---|
| message_id | O mesmo id devolvido no envio. |
| status | sent, delivered, read ou failed. |
| status_rank | Ordem 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. |
| to | Número do destinatário. Mesmo nome do campo no envio. |
| error | Objeto {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_ref | O que você mandou no envio. É por aqui que se correlaciona. |
| campaign_id | Preenchido quando a mensagem saiu de uma campanha do painel. |
| delivery_id | Id desta entrega. Repete nas retentativas, então serve de chave de idempotência do seu lado. |
| occurred_at | Quando 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 30s, 2min, 10min, 1h, 6h, 24h: 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.