Webhooks
Cada endpoint de webhook recebe um segredo whsec_... exibido uma única vez, no momento da criação. O corpo de cada entrega é assinado sem nenhuma normalização posterior — a assinatura cobre os bytes exatos enviados.
Headers de assinatura
Toda entrega chega com quatro headers:
X-Hub-Delivery-Id— UUID da entrega: igual em todas as tentativas dela, novo num reenvio manual (replay).X-Hub-Timestamp— o instante do envio, em segundos desde a época Unix.X-Hub-Signature-Version— qual formato de assinatura foi usado:v2para o formato nativo eevohub-v1para o de compatibilidade EvoHub. Os dois assinam conteúdos diferentes — leia este header antes de calcular o HMAC.X-Hub-Signature-256— a assinatura HMAC-SHA256, no formatosha256=HEX.
Verificando em Node
No formato nativo (v2, o padrão de todo endpoint criado com delivery_format: "native") a assinatura não cobre só o corpo: ela cobre `${timestamp}.${delivery_id}.${corpo}`, com os valores exatos de X-Hub-Timestamp e X-Hub-Delivery-Id daquela entrega. Amarrar a assinatura aos dois é o que impede um corpo capturado de ser transplantado para outra entrega.
Compare com timingSafeEqual, nunca com === — uma comparação de string comum vaza o tempo de execução por posição de byte, o que dá a um atacante um jeito de descobrir a assinatura correta por tentativa e erro.
import { createHmac, timingSafeEqual } from 'node:crypto'
const timestamp = request.headers['x-hub-timestamp']
const deliveryId = request.headers['x-hub-delivery-id']
const signed = `${timestamp}.${deliveryId}.${rawBody}`
const expected = createHmac('sha256', process.env.WEBHOOK_SECRET).update(signed).digest('hex')
const received = request.headers['x-hub-signature-256']?.replace('sha256=', '')
const valid = !!received &&
/^[0-9a-f]{64}$/.test(received) &&
timingSafeEqual(Buffer.from(received, 'hex'), Buffer.from(expected, 'hex'))Formato de compatibilidade EvoHub
Um endpoint criado com delivery_format: "evohub" — direto no POST /api/v1/webhooks, ou implicitamente pelo contrato de criação de canal compatível — assina somente o corpo cru, sem timestamp e sem delivery id. É uma exceção deliberada, para consumidores que já vinham do EvoHub e não podem mudar o handler.
Identifique o formato pelo header: X-Hub-Signature-Version: evohub-v1 em vez de v2. Os headers X-Hub-Delivery-Id e X-Hub-Timestamp continuam sendo enviados e continuam valendo para deduplicar, mas não entram no cálculo do HMAC.
import { createHmac, timingSafeEqual } from 'node:crypto'
const expected = createHmac('sha256', process.env.WEBHOOK_SECRET).update(rawBody).digest('hex')
const received = request.headers['x-hub-signature-256']?.replace('sha256=', '')
const valid = !!received &&
/^[0-9a-f]{64}$/.test(received) &&
timingSafeEqual(Buffer.from(received, 'hex'), Buffer.from(expected, 'hex'))native em integrações novas e use evohub só enquanto durar a migração.Regras de recebimento
- Leia o corpo cru antes de fazer parse de JSON — reserializar o JSON pode mudar a assinatura esperada.
- Rejeite qualquer entrega sem o header de assinatura, ou com assinatura inválida.
- Deduplique pelo
iddo evento, no corpo (modonormalized): ele se repete na retentativa e no replay. OX-Hub-Delivery-Idsó se repete na retentativa. No modometa_rawo corpo é o payload da Meta, semidno topo: use oidda mensagem (wamid) que vem dentro dele. - Responda
2xxem até 10 segundos — processe o evento de forma assíncrona se demorar.
Retentativas e pausa
Uma entrega que falha (timeout, resposta fora de 2xx, erro de conexão) é reenviada até nove vezes, somando dez tentativas em cerca de quatro dias. Depois de 20 falhas consecutivas, o endpoint pausa automaticamente. Depois de corrigir o que impedia o recebimento, reative-o com PATCH /api/v1/webhooks/{id} e {"status":"active"}. Eventos que acontecem durante a pausa não geram entrega e não podem ser reenviados: recupere as mensagens desse intervalo por GET /api/v1/channels/{id}/messages com direction=inbound.
O que é bloqueado no destino
A URL de um endpoint de webhook passa por uma verificação de destino. São bloqueados:
- HTTP puro — só HTTPS é aceito, e só na porta 443.
- URL com usuário e senha embutidos.
- Redes internas (loopback, link-local e faixas privadas de IP).
- Redirects — a entrega não segue um redirecionamento HTTP.
- DNS privado — um domínio que resolve para um IP interno é recusado. A checagem se repete a cada entrega: um domínio que passa a apontar para IP interno, ou deixa de resolver, faz a entrega falhar.
Modos de payload
Cada endpoint escolhe um entre dois modos de payload:
normalized(padrão) — envelope do CRPRO Hub:{"id", "event", "channel_id", "occurred_at", "data"}, igual para qualquer evento.meta_raw— o payload bruto da Meta para o evento; sem correspondente bruto, cai de volta paranormalized.
O modo de payload e o formato de entrega são independentes: payload_mode escolhe o que vai no corpo, delivery_format escolhe como esse corpo é assinado.
Payload de ciclo de vida no modo EvoHub
Num endpoint evohub, os eventos de ciclo de vida do canal (channel.connected, channel.degraded e channel.disconnected) chegam num envelope próprio, com event_type em channel_connected ou channel_disconnected. Os dados da conexão com a Meta aparecem em quatro lugares ao mesmo tempo, de propósito — no topo, em channel, em data e em meta_connection — para que um handler escrito para qualquer uma dessas formas continue funcionando.
{
"event_type": "channel_connected",
"channel_id": "b1c2d3e4-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
"external_id": "11111111-1111-4111-8111-111111111111",
"phone_number_id": "109876543210987",
"waba_id": "102233445566778",
"display_phone_number": "+55 11 90000-0000",
"verified_name": "Loja Exemplo",
"meta_connection": {
"phone_number_id": "109876543210987",
"waba_id": "102233445566778",
"display_phone_number": "+55 11 90000-0000",
"verified_name": "Loja Exemplo",
"phone_number": "+55 11 90000-0000"
},
"channel": {
"id": "b1c2d3e4-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
"channel_id": "b1c2d3e4-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
"external_id": "11111111-1111-4111-8111-111111111111",
"phone_number_id": "109876543210987",
"waba_id": "102233445566778",
"display_phone_number": "+55 11 90000-0000",
"verified_name": "Loja Exemplo"
},
"data": {
"external_id": "11111111-1111-4111-8111-111111111111",
"phone_number_id": "109876543210987",
"waba_id": "102233445566778",
"display_phone_number": "+55 11 90000-0000",
"verified_name": "Loja Exemplo",
"channel_id": "b1c2d3e4-5f6a-4b7c-8d9e-0f1a2b3c4d5e"
}
}O token do canal (hub_ch_...) nunca aparece no corpo de um webhook. Para rotacioná-lo, use POST /api/v1/channels/{id}/regenerate-token.
Tipos de evento
Um endpoint assina um ou mais destes eventos:
- channel.created
- channel.connected
- channel.degraded
- channel.disconnected
- message.received
- message.sent
- message.status
- message.echo
- template.status
- phone.quality
- account.update
- meta.raw
- webhook.test
meta.raw só chega a endpoint com payload_mode: "meta_raw" — assiná-lo num endpoint normalized não entrega nada. Na outra direção, um endpoint meta_raw recebe os eventos channel.* e webhook.test normalmente, mas nenhum dos eventos de mensagem, template, qualidade ou conta.