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 |
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": "..." } }
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 (ex.: consentimento de marketing) |
// contact.synced
{ "action": "add|remove", "name": "...", "phone": "..." }
// user.preferences_updated
{ "wa_id": "...", "category": "...", "value": "stop|resume", "detail": "..." }
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.