CRPRO HubEntrar no painel

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: v2 para o formato nativo e evohub-v1 para 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 formato sha256=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.

verificar-assinatura.js
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.

verificar-assinatura-evohub.js
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'))
O formato nativo é mais seguro: prefira 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 id do evento, no corpo (modo normalized): ele se repete na retentativa e no replay. O X-Hub-Delivery-Id só se repete na retentativa. No modo meta_raw o corpo é o payload da Meta, sem id no topo: use o id da mensagem (wamid) que vem dentro dele.
  • Responda 2xx em 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 para normalized.

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.

ciclo-de-vida-evohub.json
{
  "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.