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)

EventoDispara quandoServe para
message.statusUma mensagem SUA muda de estado.Saber se chegou, se foi lida ou se falhou.
message.receivedO contato escreve para você.Reagir à resposta e saber que a janela de 24h reabriu.
Assine os dois. Só com 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)

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
  }
}

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.

corpo do POST
{
  "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"
  }
}
CampoO que é
fromQuem escreveu. Em message.status o campo é to, porque lá o destinatário é o contato. Papéis diferentes, nomes diferentes.
typetext, image, audio, video, document, sticker, location, contacts, unsupported.
textO texto, ou a legenda quando veio mídia. Nulo quando não há texto.
media_idPresente 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_idQuando ele respondeu marcando uma mensagem sua, é o id dela. Serve para amarrar a resposta ao pedido.
window_expires_atAté quando você pode responder com texto livre, de graça. Guarde e economize a consulta da janela.
window_expires_at é o campo que economiza dinheiro. Cada mensagem do contato reabre a janela de 24 horas, e este é o instante exato em que ela fecha. Guardando isso, você decide entre texto e template sem chamar GET /window.
Citação feita por VOCÊ pelo celular não chega aqui, e isso é limitação da coexistência: o eco que a Meta manda para a API vem sem a marcação. Citação feita pelo contato chega normalmente.

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 de message.status (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 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.