Webhooks — eventos

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": { }
}
CampoTipoDescrição
idstringID único do evento (evt_…)
typestringTipo do evento (catálogo abaixo)
api_versionstringFixo: "2026-06-01"
created_atISO 8601Timestamp do evento
account_idstring | nullWABA ID que originou o evento
dataobjectPayload específico do tipo

Catálogo de eventos + payloads

Mensagens

EventoGatilho
message.sentMensagem outbound postada na Meta
message.deliveredEntregue no WhatsApp do destinatário
message.readDestinatário leu a mensagem
message.failedFalha no envio outbound
message.receivedMensagem inbound de um contato — inclui clique em botão / escolha em lista
message.echoedMensagem 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.typeOrigemOnde vem a escolha
interactiveBotões/listas enviados pela API (POST /v1/messages interativo)data.interactive.reply
buttonQuick-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 é o message_id da mensagem que levou os botões. É a única forma de correlacionar o clique com o disparo que o originou. Aparece em qualquer message.received que 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_reply de Flows) trazem apenas interactive.type.

Templates

EventoGatilho
template.status_updatedAprovação/rejeição mudou
template.quality_updatedQuality 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

EventoGatilho
phone_number.quality_updatedQuality/limite do número mudou
phone_number.name_updatedDecisã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

EventoGatilho
account.updatedReview status / verificação / restrições mudaram
account.alertAlerta emitido na conta
business_capability.updatedLimites 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

EventoGatilho
contact.syncedContato adicionado/editado/removido
user.preferences_updatedPreferê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 é o id retornado por POST /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

EventoGatilho
account.connectedSeu 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 em POST /v1/onboarding/sessions — use-o para casar o evento com o seu cliente.
  • O account_id do envelope é o id da conexão criada: o mesmo de GET /v1/accounts e 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:

HeaderConteúdo
webhook-idID do evento (ex.: evt_abc123)
webhook-timestampUnix em segundos (anti-replay)
webhook-signaturev1,<assinatura_base64>

Segredo: whsec_ + 24 bytes aleatórios em base64.

Algoritmo de assinatura

  1. secretBytes = base64decode(secret.slice(7)) (remove o prefixo whsec_).
  2. signedContent = "{webhook-id}.{webhook-timestamp}.{raw_body}".
  3. signature = base64(HMAC_SHA256(secretBytes, signedContent)).
  4. Header = v1,{signature}.

Para verificar (no seu servidor)

  1. Extraia os 3 headers.
  2. Confirme que webhook-timestamp está dentro de ~5 min do agora (anti-replay).
  3. Recomponha signedContent com o corpo bruto recebido (bytes exatos, antes de qualquer JSON.parse).
  4. Recalcule o HMAC e compare (constante) com a parte após v1,.
  5. 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.

Política de retry / entrega

ItemValor
MétodoPOST application/json
Timeout10s por tentativa
Redirectsmanual — 3xx conta como falha
SucessoHTTP 2xx (>=200 && <300)
Máx. tentativas8 (1 inicial + 7 retries)

Cronograma de retry após falha: 5s → 5min → 30min → 2h → 5h → 10h → 14h → DEAD (≈ 36h no total).

Estados de delivery: PENDINGDELIVERINGSUCCESS | 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.

Pular para o conteúdo