Webhooks — eventos
Catálogo de eventos, envelope de entrega, verificação de assinatura HMAC e política de retry.
Envelope de entrega
Toda entrega é um POST application/json com este envelope:
{
"id": "evt_...",
"type": "message.delivered",
"api_version": "2026-06-01",
"created_at": "2026-06-22T14:05:00.000Z",
"account_id": "1029384756",
"data": { }
}
| Campo | Tipo | Descrição |
|---|---|---|
id | string | ID único do evento (evt_…) |
type | string | Tipo do evento (catálogo abaixo) |
api_version | string | Fixo: "2026-06-01" |
created_at | ISO 8601 | Timestamp do evento |
account_id | string | null | WABA ID que originou o evento |
data | object | Payload específico do tipo |
Catálogo de eventos + payloads
Mensagens
| Evento | Gatilho |
|---|---|
message.sent | Mensagem outbound postada na Meta |
message.delivered | Entregue no WhatsApp do destinatário |
message.read | Destinatário leu a mensagem |
message.failed | Falha no envio outbound |
message.received | Mensagem inbound de um contato — inclui clique em botão / escolha em lista |
message.echoed | Mensagem enviada pelo app WhatsApp Business (pass-through) |
// message.sent | message.delivered | message.read
{ "message_id": "...", "to": "...", "status": "sent|delivered|read", "pricing": { } }
// message.failed
{ "message_id": "...", "to": "...", "status": "failed", "errors": [ ] }
// message.received
{ "message_id": "...", "from": "...", "contact_name": "...", "type": "text",
"text": "...", "media": { "id": "...", "mime_type": "...", "caption": "..." } }
// message.echoed
{ "message_id": "...", "from": "...", "to": "...", "type": "text",
"text": "...", "media": { "id": "...", "mime_type": "...", "caption": "..." } }
Resposta a botão e lista
Não existe evento separado para clique em botão. O WhatsApp entrega a escolha do contato como uma mensagem inbound normal — ou seja, chega em message.received, com type diferente de text. Basta assinar message.received.
data.type | Origem | Onde vem a escolha |
|---|---|---|
interactive | Botões/listas enviados pela API (POST /v1/messages interativo) | data.interactive.reply |
button | Quick-reply de template (campanha) | data.button |
// clique em botão de resposta rápida enviado pela API
{ "message_id": "wamid...", "from": "5575...", "contact_name": "Pedro", "type": "interactive",
"interactive": { "type": "button_reply", "reply": { "id": "fruta_caju", "title": "Caju" } },
"context": { "message_id": "wamid... (mensagem que levou os botões)", "from": "5511..." } }
// escolha em lista de opções
{ "message_id": "wamid...", "from": "5575...", "type": "interactive",
"interactive": { "type": "list_reply",
"reply": { "id": "h_0800", "title": "08:00", "description": "Dr. Italo Pinto" } },
"context": { "message_id": "wamid...", "from": "5511..." } }
// quick-reply de template (campanha)
{ "message_id": "wamid...", "from": "5575...", "type": "button",
"button": { "text": "Quero saber mais", "payload": "PROMO_2026" },
"context": { "message_id": "wamid... (a mensagem da campanha)", "from": "5511..." } }
interactive.reply.idé o id que você definiu no envio — use ele para rotear a automação (titleé o texto exibido e pode mudar).context.message_idé omessage_idda mensagem que levou os botões. É a única forma de correlacionar o clique com o disparo que o originou. Aparece em qualquermessage.receivedque seja resposta a uma mensagem sua (inclusive texto citado); quando não há resposta a nada, a chave é omitida.- Tipos interativos sem forma estável (ex.:
nfm_replyde Flows) trazem apenasinteractive.type.
Templates
| Evento | Gatilho |
|---|---|
template.status_updated | Aprovação/rejeição mudou |
template.quality_updated | Quality score mudou |
// template.status_updated
{ "template_name": "...", "language": "...", "event": "APPROVED", "reason": null }
// template.quality_updated
{ "template_name": "...", "language": "...", "previous_quality_score": "...", "new_quality_score": "..." }
Número de telefone
| Evento | Gatilho |
|---|---|
phone_number.quality_updated | Quality/limite do número mudou |
phone_number.name_updated | Decisão sobre nome verificado |
// phone_number.quality_updated
{ "display_phone_number": "...", "event": "...", "current_limit": "..." }
// phone_number.name_updated
{ "display_phone_number": "...", "decision": "...", "requested_verified_name": "...", "rejection_reason": "..." }
Conta / capacidade
| Evento | Gatilho |
|---|---|
account.updated | Review status / verificação / restrições mudaram |
account.alert | Alerta emitido na conta |
business_capability.updated | Limites de capacidade mudaram |
// account.updated
{ "event": "...", "account_review_status": "...", "business_verification_status": "...", "restriction_info": [ ] }
// account.alert
{ "entity_type": "...", "entity_id": "...", "alert_severity": "...", "alert_status": "...", "alert_type": "...", "alert_description": "..." }
// business_capability.updated
{ "max_daily_conversation_per_phone": "...", "max_phone_numbers_per_business": "...", "max_phone_numbers_per_waba": "..." }
Contato / usuário
| Evento | Gatilho |
|---|---|
contact.synced | Contato adicionado/editado/removido |
user.preferences_updated | Preferências do usuário: opt-out/opt-in de marketing (stop/resume) ou aceite de um link de In-App Signup (signup) |
// contact.synced
{ "action": "add|remove", "name": "...", "phone": "..." }
// user.preferences_updated — opt-out/opt-in nativo
{ "wa_id": "...", "category": "...", "value": "stop|resume", "detail": "..." }
// user.preferences_updated — aceite de In-App Signup
{ "wa_id": "...", "category": "marketing_messages", "value": "signup", "detail": "...", "signup_id": "...", "user_id": "..." }
signup_idé oidretornado porPOST /v1/accounts/{id}/signups(ver In-App Signup) — use pra saber qual link/campanha o contato usou (se você criou vários).
Onboarding de parceiros
| Evento | Gatilho |
|---|---|
account.connected | Seu cliente final concluiu o Embedded Signup e a conexão já existe |
account.connect_failed | ⚠️ Consta do catálogo mas não é emitido hoje — ver aviso abaixo |
// account.connected
{ "external_ref": "cliente-42", "waba_id": "1234567890", "phone_number": "+5511999999999",
"display_name": "Padaria do Zé", "onboarded_via": "PARTNER" }
external_refé o identificador que você enviou emPOST /v1/onboarding/sessions— use-o para casar o evento com o seu cliente.- O
account_iddo envelope é o id da conexão criada: o mesmo deGET /v1/accountse o que você passa ao enviar mensagens. - O payload é curado: nunca contém token de acesso.
Não construa tratamento de falha em cima de account.connect_failed — nenhum caminho do código o emite hoje. Detecte falha por GET /v1/onboarding/sessions/\{id\} (status: FAILED + failure_reason). Além disso, account.connected não é garantido quando um slot de conexão é reciclado (desconectar um cliente e onboardar outro no lugar) — mantenha o polling da sessão como fallback; ela é a fonte autoritativa. Ver Onboarding de parceiros.
O evento especial endpoint.test é enviado apenas por POST /v1/webhooks/\{id\}/test. Use-o para validar sua implementação de verificação de assinatura.
Assinatura (verificação)
Segue o padrão Standard Webhooks. Cada entrega traz 3 headers:
| Header | Conteúdo |
|---|---|
webhook-id | ID do evento (ex.: evt_abc123) |
webhook-timestamp | Unix em segundos (anti-replay) |
webhook-signature | v1,<assinatura_base64> |
Segredo: whsec_ + 24 bytes aleatórios em base64.
Algoritmo de assinatura
secretBytes = base64decode(secret.slice(7))(remove o prefixowhsec_).signedContent = "{webhook-id}.{webhook-timestamp}.{raw_body}".signature = base64(HMAC_SHA256(secretBytes, signedContent)).- Header =
v1,{signature}.
Para verificar (no seu servidor)
- Extraia os 3 headers.
- Confirme que
webhook-timestampestá dentro de ~5 min do agora (anti-replay). - Recomponha
signedContentcom o corpo bruto recebido (bytes exatos, antes de qualquerJSON.parse). - Recalcule o HMAC e compare (constante) com a parte após
v1,. - Aceite se bater; rejeite se não.
Use sempre o body bruto (string/bytes recebidos pelo HTTP server) na recomposição. Aplicar JSON.stringify no body parseado muda espaços/chaves e quebra a assinatura.
Exemplo (Node.js)
# Não há exemplo cURL para verificação — rode dentro do seu app.import crypto from 'crypto'
function verify(secret, headers, rawBody) {
const id = headers['webhook-id']
const ts = headers['webhook-timestamp']
const sig = headers['webhook-signature'] // "v1,xxxx"
const secretBytes = Buffer.from(secret.slice(7), 'base64') // tira "whsec_"
const signed = id + '.' + ts + '.' + rawBody
const expected = 'v1,' + crypto.createHmac('sha256', secretBytes).update(signed).digest('base64')
// comparação em tempo constante
if (sig.length !== expected.length) return false
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
}import hmac, hashlib, base64, time
def verify(secret: str, headers: dict, raw_body: bytes) -> bool:
wh_id = headers['webhook-id']
ts = headers['webhook-timestamp']
sig = headers['webhook-signature'] # "v1,xxxx"
secret_bytes = base64.b64decode(secret[7:]) # tira "whsec_"
signed = f"{wh_id}.{ts}.".encode() + raw_body
expected = 'v1,' + base64.b64encode(
hmac.new(secret_bytes, signed, hashlib.sha256).digest()
).decode()
# anti-replay: rejeita timestamps fora de 5 min
if abs(int(ts) - int(time.time())) > 300:
return False
return hmac.compare_digest(sig, expected)<?php
function verify(string $secret, array $headers, string $rawBody): bool {
$id = $headers['webhook-id'];
$ts = $headers['webhook-timestamp'];
$sig = $headers['webhook-signature']; // "v1,xxxx"
$secretBytes = base64_decode(substr($secret, 7)); // tira "whsec_"
$signed = $id . '.' . $ts . '.' . $rawBody;
$expected = 'v1,' . base64_encode(hash_hmac('sha256', $signed, $secretBytes, true));
// anti-replay
if (abs(time() - (int)$ts) > 300) return false;
return hash_equals($expected, $sig);
} Política de retry / entrega
| Item | Valor |
|---|---|
| Método | POST application/json |
| Timeout | 10s por tentativa |
| Redirects | manual — 3xx conta como falha |
| Sucesso | HTTP 2xx (>=200 && <300) |
| Máx. tentativas | 8 (1 inicial + 7 retries) |
Cronograma de retry após falha: 5s → 5min → 30min → 2h → 5h → 10h → 14h → DEAD (≈ 36h no total).
Estados de delivery: PENDING → DELIVERING → SUCCESS | FAILED (re-tentará) | DEAD (terminal).
Auto-disable: após 15 falhas consecutivas o endpoint vai para DISABLED (notificação no painel, link /developers/keys). PAUSED adia entregas com back-off de 60s; reativar zera o contador.
Campos de uma delivery (via API): id, event_type, status, attempts (0–8), last_response_code, last_error, delivered_at, created_at.