Como configurar um webhook do WhatsApp
Uma chamada cria o endpoint e devolve o segredo — uma única vez. O resto do trabalho é do seu lado: validar a assinatura do jeito certo. Este guia vai da criação até reenviar uma entrega que você perdeu.
O que você precisa
- Uma chave de API (
hub_pk_...) com o escopowebhooks:writepara criar, testar, reenviar e rotacionar;webhooks:readpara listar e consultar entregas. Crie em /painel/chaves. - Uma URL https pública. Loopback, link-local e faixas privadas de IP são recusados com
400 WEBHOOK_URL_NOT_ALLOWED— e um domínio que resolve para IP interno também. Para desenvolver na sua máquina, exponha a porta por um túnel com domínio público. - Um handler que responda
2xxem até 10 segundos e que consiga ler o corpo cru da requisição. Sem o corpo cru você não tem como verificar a assinatura — reserializar o JSON muda os bytes.
Criar o endpoint
curl -X POST https://crprohub.com/api/v1/webhooks \
-H "Authorization: Bearer hub_pk_EXEMPLO_NAO_REAL" \
-H "Content-Type: application/json" \
-d '{"name":"Endpoint principal","url":"https://exemplo.com/webhooks/crprohub","event_types":["message.received","message.status"]}'{
"data": {
"webhook": {
"id": "b1c2d3e4-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
"name": "Endpoint principal",
"url": "https://exemplo.com/webhooks/crprohub",
"events": ["message.received", "message.status"],
"payload_mode": "normalized",
"delivery_format": "native",
"all_channels": true,
"status": "active",
"consecutive_failures": 0,
"channel_ids": [],
"created_at": "2026-08-21T12:00:00.000Z",
"updated_at": "2026-08-21T12:00:00.000Z"
},
"secret": "whsec_EXEMPLO_NAO_REAL",
"warning": "Guarde este segredo agora. Ele nao podera ser visualizado novamente."
}
}O campo event_types parece opcional e é o primeiro lugar onde as pessoas erram: o padrão é lista vazia. Um endpoint criado sem ele nasce ativo, aparece na listagem, passa no teste de URL — e recebe TODOS os eventos da organização. Assine pelo menos message.received e message.status. A lista completa de eventos está em webhooks.
Por padrão o endpoint recebe eventos de todos os canais da organização (all_channels: true). Para restringir, mande all_channels: false junto com channel_ids não vazio — a combinação inconsistente nos dois sentidos responde 400 INVALID_WEBHOOK.
secret (whsec_...) só existe nesta resposta. Ele não é reemitido, não aparece no GET /api/v1/webhooks e não aparece no painel. Perdeu, o único caminho é rotacionar — o que invalida o anterior na hora. Grave em variável de ambiente ou cofre antes de fechar o terminal.Dois campos definem o comportamento da entrega e vale entender a diferença agora, porque delivery_format não muda depois — para trocar, crie outro endpoint. payload_mode escolhe o que vai no corpo (normalized, o padrão, ou meta_raw com o payload bruto da Meta). delivery_format escolhe como esse corpo é assinado: native (padrão) ou evohub, este último só para quem está migrando do EvoHub e não pode mexer no handler.
Os headers de cada entrega
X-Hub-Delivery-Id: c2d3e4f5-6a7b-4c8d-9e0f-1a2b3c4d5e6f X-Hub-Timestamp: 1766000000 X-Hub-Signature-Version: v2 X-Hub-Signature-256: sha256=9f8b1c2d3e4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f809a1b
X-Hub-Delivery-Id— UUID desta tentativa. Deduplique por ele: uma retentativa reenvia o mesmo id, e o seu handler vai ver o mesmo evento mais de uma vez.X-Hub-Timestamp— o instante do envio, em segundos desde a época Unix.X-Hub-Signature-Version—v2no formato nativo,evohub-v1no de compatibilidade. Leia este header antes de calcular o HMAC: os dois formatos assinam conteúdos diferentes.X-Hub-Signature-256— a assinatura HMAC-SHA256, no formatosha256=HEX.
Verificar a assinatura
Este é o ponto onde a integração quebra por inteiro, e o erro é sempre o mesmo. No formato nativo a assinatura não cobre só o corpo: ela cobre `${timestamp}.${delivery_id}.${corpo}`, com os valores exatos dos headers daquela entrega. Quem calcula HMAC(corpo) vê 100% das entregas falharem na verificação — não algumas, todas.
Amarrar a assinatura ao timestamp e ao delivery id é de propósito: impede que um corpo capturado seja transplantado para outra entrega.
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'))Duas linhas desse trecho não são estilo, são requisito. A comparação usa timingSafeEqual e nunca === — uma comparação de string comum vaza o tempo de execução por posição de byte, o que dá a um atacante um caminho para descobrir a assinatura correta por tentativa e erro. Em Python o equivalente é hmac.compare_digest; em PHP, hash_equals.
E o /^[0-9a-f]{64}$/ vem antes do Buffer.from por um motivo prático: uma assinatura de 64 caracteres com conteúdo não-hexadecimal produz um buffer mais curto, e aí timingSafeEqual lança RangeError em vez de devolver false. Sem a validação, um header malformado derruba o seu handler em vez de ser rejeitado.
evohub (X-Hub-Signature-Version: evohub-v1) a assinatura cobre somente o corpo cru. Os headers de delivery id e timestamp continuam chegando e continuam valendo para deduplicar, mas ficam fora do HMAC. O trecho correspondente está em webhooks. Em integração nova, prefira native.Testar a entrega
Não espere uma mensagem real chegar para descobrir que o handler está errado. O endpoint de teste enfileira um evento sintético webhook.test, que percorre o mesmo caminho de qualquer outra entrega e respeita o payload_mode configurado.
curl -X POST https://crprohub.com/api/v1/webhooks/WEBHOOK_UUID/test \ -H "Authorization: Bearer hub_pk_EXEMPLO_NAO_REAL"
{
"data": {
"event_id": "d3e4f5a6-7b8c-4d9e-0f1a-2b3c4d5e6f7a",
"delivery_id": "c2d3e4f5-6a7b-4c8d-9e0f-1a2b3c4d5e6f",
"status": "pending"
}
}A resposta é 202: o evento foi enfileirado, não entregue. Guarde o delivery_id e confira o desfecho em deliveries. São 10 testes por hora por endpoint.
Só endpoint active aceita teste — pausado ou desabilitado responde 404. Se você acabou de bater no limite de falhas e o endpoint pausou sozinho, reative primeiro com PATCH /api/v1/webhooks/{id} mandando {"status":"active"}.
Ver as tentativas
curl https://crprohub.com/api/v1/webhooks/WEBHOOK_UUID/deliveries \ -H "Authorization: Bearer hub_pk_EXEMPLO_NAO_REAL"
{
"data": {
"deliveries": [
{
"id": "c2d3e4f5-6a7b-4c8d-9e0f-1a2b3c4d5e6f",
"event": "message.status",
"status": "succeeded",
"attempts": 1,
"next_attempt_at": "2026-08-21T12:00:05.000Z",
"last_http_status": 200,
"last_error_code": null,
"delivered_at": "2026-08-21T12:00:05.000Z",
"created_at": "2026-08-21T12:00:00.000Z"
}
]
}
}Traz as 50 entregas mais recentes do endpoint, mais recente primeiro. A rota não tem paginação — é uma janela de diagnóstico, não um histórico completo. Para investigar mais fundo, cruze com /painel/logs.
O status de cada entrega é pending, processing, retry, succeeded, dead ou canceled. Quando algo está errado, os campos que respondem “por quê” são last_http_status e last_error_code: um 401 ali costuma ser assinatura calculada errado, e um attempts alto com last_http_status: null costuma ser timeout ou DNS.
Reenviar um evento perdido
Seu servidor caiu, ou você corrigiu a verificação depois de rejeitar entregas válidas. O replay reenvia uma entrega específica pelo id que veio de deliveries.
curl -X POST https://crprohub.com/api/v1/webhooks/WEBHOOK_UUID/replay/DELIVERY_UUID \ -H "Authorization: Bearer hub_pk_EXEMPLO_NAO_REAL"
{
"data": {
"delivery_id": "c2d3e4f5-6a7b-4c8d-9e0f-1a2b3c4d5e6f",
"status": "pending"
}
}Só entregas já finalizadas podem ser reenviadas — succeeded, dead ou canceled. Uma entrega ainda em pending, processing ou retry responde 404, e isso não é bug: ela já está na fila, e reenviar seria duplicar. O replay devolve a entrega para pending; o resultado real aparece depois em deliveries.
X-Hub-Delivery-Id da entrega original. Se o seu handler deduplica por esse id — e ele deve — um replay de entrega succeeded vai ser descartado do seu lado. Reenvie o que ficou dead, ou limpe o registro de deduplicação antes.Retentativa e pausa automática
Uma entrega que falha — timeout, resposta fora de 2xx, erro de conexão — é reenviada até nove vezes. Depois de 20 falhas consecutivas o endpoint pausa sozinho.
A pausa é uma proteção, não um castigo: um endpoint quebrado que continuasse recebendo acumularia fila indefinidamente e atrasaria as entregas dos endpoints saudáveis. O campo consecutive_failures no GET /api/v1/webhooks/{id} é o contador — vale monitorar antes de ele chegar a 20, porque uma vez pausado nada chega até alguém reativar.
Reative com PATCH /api/v1/webhooks/{id} mandando {"status":"active"}, ou em /painel/webhooks. Corrija o que impedia o recebimento primeiro: reativar sem corrigir só gasta as 20 falhas de novo. O mesmo PATCH serve para pausar de propósito durante uma janela de manutenção, com {"status":"paused"}.
Rotacionar o segredo
curl -X POST https://crprohub.com/api/v1/webhooks/WEBHOOK_UUID/rotate-secret \ -H "Authorization: Bearer hub_pk_EXEMPLO_NAO_REAL"
{
"data": {
"secret": "whsec_EXEMPLO_NAO_REAL",
"warning": "O segredo anterior foi invalidado. Guarde este valor agora."
}
}Não há período de convivência entre os dois segredos: o anterior é invalidado imediatamente, e toda assinatura calculada com ele para de bater a partir desta chamada. Na prática isso significa uma janela de falhas entre a rotação e o deploy do novo valor — planeje a ordem, ou aceite reenviar as entregas perdidas depois com replay.
O novo secret aparece só nesta resposta, pela mesma razão do segredo original. Se você perder o valor, o único caminho é rotacionar outra vez.
id de webhook de outra organização responde 404, nunca 403 — 403 confirmaria que o recurso existe. Toda resposta traz x-request-id; guarde no seu log. O contrato completo dos endpoints está em referência de webhooks, e a tabela de códigos em erros.