Seções

Webhooks (HTTP)

O webhook da Meta, a config de entrega por tenant e a verificação HMAC da assinatura.

O broker entrega eventos ao seu endpoint HTTP por POST, com corpo JSON e uma assinatura HMAC no header. Você configura a URL e o segredo pelos endpoints /v1/webhooks/* e verifica a assinatura no recebimento — o @wabroker/sdk faz isso via verifyAndParse, e o Wabroker.Sdk via WabrokerWebhooks.VerifyAndParse. Sem o SDK, a verificação é sua — o algoritmo está abaixo.

métodopatho que faz
GET/v1/webhooks/configlê eventsUrl + secretLast4
PUT/v1/webhooks/configdefine a URL de entrega
POST/v1/webhooks/secret/rotaterotaciona o segredo (mostrado uma vez)

Configurar o endpoint

Defina a URL de entrega com PUT. O broker a valida pela guarda SSRF ao gravar — use um endpoint público https e estável. Um host que resolve para IP interno, ou não-https, é recusado com 422 unsupported_capability.

bash
# Definir a URL de entrega
curl -X PUT https://api.grwthy.com/v1/webhooks/config \
  -H "Authorization: Bearer $WABROKER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "eventsUrl": "https://api.suaempresa.com/webhooks/wabroker" }'

# Ler a config — só o last4 do segredo, nunca o segredo inteiro
curl https://api.grwthy.com/v1/webhooks/config \
  -H "Authorization: Bearer $WABROKER_API_KEY"

O PUT devolve a eventsUrl gravada; o GET devolve a URL e só o secretLast4 — nunca o segredo inteiro.

Resposta 200

json
{
  "eventsUrl": "https://crm.example.com/webhooks/wabroker"
}

Resposta 200

json
{
  "eventsUrl": "https://crm.example.com/webhooks/wabroker",
  "secretLast4": "<redacted>"
}

Rotacionar o segredo

O segredo em claro aparece uma única vez, na resposta da rotação. Capture-o e guarde-o no seu backend — depois só o last4 é legível.

bash
curl -X POST https://api.grwthy.com/v1/webhooks/secret/rotate \
  -H "Authorization: Bearer $WABROKER_API_KEY"

Resposta 200

json
{
  "last4": "<redacted>",
  "secret": "<redacted>"
}

Segredo é write-only

GET /v1/webhooks/config nunca devolve o segredo, só secretLast4. Perdeu-o? Rotacione de novo e atualize o seu backend.

Verificar a assinatura (HMAC)

Cada entrega traz o header X-WaBroker-Signature, no formato (padrão Stripe):

text
X-WaBroker-Signature: t=1753531200,v1=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b...
  • t — timestamp Unix (segundos) de quando o broker assinou.
  • v1 — o HMAC-SHA256, em hex, de {t}.{body} (o timestamp, um ponto, e o corpo cru), com o seu segredo como chave.

O timestamp entra no material assinado (não só no header) para viabilizar a janela anti-replay: a tolerância padrão é de 5 minutos para os dois lados. Compare a assinatura em tempo constante e use o corpo exato recebido — não parseie e reserialize antes, ou os bytes não batem.

ts
import { createHmac, timingSafeEqual } from 'node:crypto'

// rawBody: a string EXATA do corpo recebido (não o objeto reserializado)
function verify(rawBody: string, header: string, secret: string): boolean {
  const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=')))
  const t = Number(parts.t)
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false // janela de 5 min

  const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex')
  const a = Buffer.from(expected)
  const b = Buffer.from(parts.v1 ?? '')
  return a.length === b.length && timingSafeEqual(a, b)
}

Falha fechado

Sem segredo, corpo adulterado, fora da janela anti-replay, ou header ausente/malformado, a verificação deve falhar — recuse a requisição. Nunca processe um evento que não passou na assinatura.

Eventos

O corpo é um NormalizedEvent. O discriminante é o type no topo do envelope (data não tem type próprio) — switch (event.type) é seguro e exaustivo. Todo evento carrega channelNumber (E.164 sem +, ou null antes de parear) — a chave estável recomendada para amarrar conversas dos dois lados.

typequando
message.receiveduma mensagem chegou (from, message, profileName?)
message.sento broker despachou e o provider aceitou (to, messageId, content)
message.statusstatus de entrega: sent/delivered/read/failed
template.updatedum template mudou de status na plataforma oficial
channel.statussinal grosso up/down do número
channel.lifecyclecada transição de estado do número (auditoria)

Metadado, não conteúdo do cliente

Os eventos carregam estado e metadado. Mídia recebida vem como mediaId opaco — troque-o pelos bytes em GET /v1/media/:channelId/:mediaId (precisa do channelId do envelope além do mediaId; veja Mensagens (HTTP) → Baixar mídia recebida), nunca a URL interna da instância. Responda 2xx rápido; empurre trabalho pesado para uma fila sua, para não estourar o timeout de entrega e provocar retries.

Bloqueios de número não têm webhook

Restrição, limitação e quarentena de um número não produzem webhook — o channel.status cobre só connected/down. Descubra-os por polling de GET /v1/channels ou por GET /v1/channels/:id/overview.

message.received tem campos aditivos opcionais: replyTo (o msg_… da citada, quando conhecida) e replyToUnknown: true (citada fora do broker — o wamid nunca é entregue). Além de text/media, o kind pode ser interactive_reply, reaction (traz o targetMessageId = o msg_… reagido quando conhecido, ou targetMessageUnknown: true quando o alvo passou fora do broker — o wamid nunca é entregue) ou unsupported.

message.sent confirma que o próprio broker despachou e o provider aceitou — distinto de message.status, que é o ciclo de vida de entrega da mesma mensagem. Carrega data.to, data.messageId (msg_…) e data.content no mesmo kind enviado; em template, rendered traz o texto já com os params substituídos, quando o broker localizou o template. Se o envio carregou um metadata (mapa string→string opcional, chaves livres), ele volta idêntico em data.metadata — opaco, nunca interpretado/logado pelo broker; omitido (não null/{}) quando o envio não trouxe um. Como pode carregar dado pessoal, o erasure (POST /v1/erasure) apaga também o metadata já entregue/pendente do contato.

Exemplo de cada tipo

O corpo exato entregue no seu endpoint, um por tipo — fiel ao que o broker envia (o mesmo NormalizedEvent que verifyAndParse/WabrokerWebhooks.VerifyAndParse devolvem tipado).

Evento message.received

json
{
  "brokerTimestamp": "2026-08-04T12:00:00.000Z",
  "channelId": "ch_9f2c8a1b0d3e4f50",
  "channelKind": "official",
  "channelNumber": "5511999999999",
  "data": {
    "from": "5511999999999",
    "message": {
      "kind": "text",
      "text": "Olá, preciso de ajuda com meu pedido."
    },
    "messageId": "msg_7b3e1d9a2c4f6081"
  },
  "id": "evt_0a7d3f1c9b5e2648",
  "provider": "meta",
  "providerTimestamp": "2026-08-04T12:00:00.000Z",
  "tenantId": "tn_1a5c9e3f7b2d4068",
  "traceId": "tr_2f8b6d0a4c1e3597",
  "type": "message.received"
}

Entregue por POST com o header X-WaBroker-Signature (HMAC) — valide-o antes de confiar no corpo. Veja Verificar a assinatura.

Evento message.received.media

json
{
  "brokerTimestamp": "2026-08-04T12:00:00.000Z",
  "channelId": "ch_9f2c8a1b0d3e4f50",
  "channelKind": "unofficial",
  "channelNumber": "5511999999999",
  "data": {
    "from": "5511999999999",
    "message": {
      "fileSize": 50176,
      "filename": "Currículo.pdf",
      "kind": "media",
      "media": "document",
      "mediaId": "media-9f2c8a1b.pdf",
      "mimeType": "application/pdf"
    },
    "messageId": "msg_7b3e1d9a2c4f6081"
  },
  "id": "evt_0a7d3f1c9b5e2648",
  "provider": "waha",
  "providerTimestamp": "2026-08-04T12:00:00.000Z",
  "tenantId": "tn_1a5c9e3f7b2d4068",
  "traceId": "tr_2f8b6d0a4c1e3597",
  "type": "message.received"
}

Entregue por POST com o header X-WaBroker-Signature (HMAC) — valide-o antes de confiar no corpo. Veja Verificar a assinatura.

Evento message.sent

json
{
  "brokerTimestamp": "2026-08-04T12:00:00.000Z",
  "channelId": "ch_9f2c8a1b0d3e4f50",
  "channelKind": "official",
  "channelNumber": "5511999999999",
  "data": {
    "content": {
      "kind": "text",
      "text": "Olá! Seu pedido foi confirmado."
    },
    "messageId": "msg_7b3e1d9a2c4f6081",
    "to": "5511999999999"
  },
  "id": "evt_0a7d3f1c9b5e2648",
  "provider": "meta",
  "providerTimestamp": "2026-08-04T12:00:00.000Z",
  "tenantId": "tn_1a5c9e3f7b2d4068",
  "traceId": "tr_2f8b6d0a4c1e3597",
  "type": "message.sent"
}

Entregue por POST com o header X-WaBroker-Signature (HMAC) — valide-o antes de confiar no corpo. Veja Verificar a assinatura.

Evento message.sent.template

json
{
  "brokerTimestamp": "2026-08-04T12:00:00.000Z",
  "channelId": "ch_9f2c8a1b0d3e4f50",
  "channelKind": "official",
  "channelNumber": "5511999999999",
  "data": {
    "content": {
      "kind": "template",
      "language": "pt_BR",
      "name": "order_shipped",
      "params": [
        "Maria",
        "12345"
      ],
      "rendered": "Olá Maria, seu pedido 12345 foi enviado!"
    },
    "messageId": "msg_7b3e1d9a2c4f6081",
    "to": "5511999999999"
  },
  "id": "evt_0a7d3f1c9b5e2648",
  "provider": "meta",
  "providerTimestamp": "2026-08-04T12:00:00.000Z",
  "tenantId": "tn_1a5c9e3f7b2d4068",
  "traceId": "tr_2f8b6d0a4c1e3597",
  "type": "message.sent"
}

Entregue por POST com o header X-WaBroker-Signature (HMAC) — valide-o antes de confiar no corpo. Veja Verificar a assinatura.

Evento message.sent.metadata

json
{
  "brokerTimestamp": "2026-08-04T12:00:00.000Z",
  "channelId": "ch_9f2c8a1b0d3e4f50",
  "channelKind": "official",
  "channelNumber": "5511999999999",
  "data": {
    "content": {
      "kind": "text",
      "text": "Seu pedido foi confirmado."
    },
    "messageId": "msg_7b3e1d9a2c4f6081",
    "metadata": {
      "contrato": "CT-88213",
      "nome": "Maria Silva"
    },
    "to": "5511999999999"
  },
  "id": "evt_0a7d3f1c9b5e2648",
  "provider": "meta",
  "providerTimestamp": "2026-08-04T12:00:00.000Z",
  "tenantId": "tn_1a5c9e3f7b2d4068",
  "traceId": "tr_2f8b6d0a4c1e3597",
  "type": "message.sent"
}

Entregue por POST com o header X-WaBroker-Signature (HMAC) — valide-o antes de confiar no corpo. Veja Verificar a assinatura.

Evento message.status

json
{
  "brokerTimestamp": "2026-08-04T12:00:00.000Z",
  "channelId": "ch_9f2c8a1b0d3e4f50",
  "channelKind": "official",
  "channelNumber": "5511999999999",
  "data": {
    "messageId": "msg_7b3e1d9a2c4f6081",
    "status": "delivered"
  },
  "id": "evt_0a7d3f1c9b5e2648",
  "provider": "meta",
  "providerTimestamp": "2026-08-04T12:00:00.000Z",
  "tenantId": "tn_1a5c9e3f7b2d4068",
  "traceId": "tr_2f8b6d0a4c1e3597",
  "type": "message.status"
}

Entregue por POST com o header X-WaBroker-Signature (HMAC) — valide-o antes de confiar no corpo. Veja Verificar a assinatura.

Evento message.status.failed

json
{
  "brokerTimestamp": "2026-08-04T12:00:00.000Z",
  "channelId": "ch_9f2c8a1b0d3e4f50",
  "channelKind": "official",
  "channelNumber": "5511999999999",
  "data": {
    "errorCode": "provider_rejected",
    "messageId": "msg_7b3e1d9a2c4f6081",
    "status": "failed"
  },
  "id": "evt_0a7d3f1c9b5e2648",
  "provider": "meta",
  "providerTimestamp": "2026-08-04T12:00:00.000Z",
  "tenantId": "tn_1a5c9e3f7b2d4068",
  "traceId": "tr_2f8b6d0a4c1e3597",
  "type": "message.status"
}

Entregue por POST com o header X-WaBroker-Signature (HMAC) — valide-o antes de confiar no corpo. Veja Verificar a assinatura.

Evento channel.status

json
{
  "brokerTimestamp": "2026-08-04T12:00:00.000Z",
  "channelId": "ch_9f2c8a1b0d3e4f50",
  "channelKind": "unofficial",
  "channelNumber": "5511999999999",
  "data": {
    "phoneNumber": "5511999999999",
    "state": "connected"
  },
  "engine": "webjs",
  "id": "evt_0a7d3f1c9b5e2648",
  "provider": "waha",
  "providerTimestamp": "2026-08-04T12:00:00.000Z",
  "tenantId": "tn_1a5c9e3f7b2d4068",
  "traceId": "tr_2f8b6d0a4c1e3597",
  "type": "channel.status"
}

Entregue por POST com o header X-WaBroker-Signature (HMAC) — valide-o antes de confiar no corpo. Veja Verificar a assinatura.

Evento channel.status.down

json
{
  "brokerTimestamp": "2026-08-04T12:00:00.000Z",
  "channelId": "ch_9f2c8a1b0d3e4f50",
  "channelKind": "unofficial",
  "channelNumber": "5511999999999",
  "data": {
    "state": "down"
  },
  "engine": "webjs",
  "id": "evt_0a7d3f1c9b5e2648",
  "provider": "waha",
  "providerTimestamp": "2026-08-04T12:00:00.000Z",
  "tenantId": "tn_1a5c9e3f7b2d4068",
  "traceId": "tr_2f8b6d0a4c1e3597",
  "type": "channel.status"
}

Entregue por POST com o header X-WaBroker-Signature (HMAC) — valide-o antes de confiar no corpo. Veja Verificar a assinatura.

Evento channel.lifecycle

json
{
  "brokerTimestamp": "2026-08-04T12:00:00.000Z",
  "channelId": "ch_9f2c8a1b0d3e4f50",
  "channelKind": "unofficial",
  "channelNumber": "5511999999999",
  "data": {
    "fromState": "connecting",
    "reason": null,
    "source": "connect",
    "toState": "connected"
  },
  "id": "evt_0a7d3f1c9b5e2648",
  "provider": "waha",
  "providerTimestamp": "2026-08-04T12:00:00.000Z",
  "tenantId": "tn_1a5c9e3f7b2d4068",
  "traceId": "tr_2f8b6d0a4c1e3597",
  "type": "channel.lifecycle"
}

Entregue por POST com o header X-WaBroker-Signature (HMAC) — valide-o antes de confiar no corpo. Veja Verificar a assinatura.

Evento template.updated

json
{
  "brokerTimestamp": "2026-08-04T12:00:00.000Z",
  "channelId": "ch_9f2c8a1b0d3e4f50",
  "channelKind": "official",
  "channelNumber": null,
  "data": {
    "language": "en_US",
    "name": "welcome",
    "status": "approved"
  },
  "id": "evt_0a7d3f1c9b5e2648",
  "provider": "meta",
  "providerTimestamp": "2026-08-04T12:00:00.000Z",
  "tenantId": "tn_1a5c9e3f7b2d4068",
  "traceId": "tr_2f8b6d0a4c1e3597",
  "type": "template.updated"
}

Entregue por POST com o header X-WaBroker-Signature (HMAC) — valide-o antes de confiar no corpo. Veja Verificar a assinatura.

channel.lifecycle é a auditoria por-transição (fromState → toState, reason, source) — distinta do channel.status, que é só o sinal grosso up/down.

redelivery: message.status re-notificado manualmente

Um message.status pode chegar com data.redelivery: true — um campo aditivo opcional (ausente no fluxo normal, como no exemplo acima). Ele aparece quando você mesmo pediu uma re-notificação (POST /v1/messages/:id/renotify ou POST /v1/messages/renotifyem lote, veja Mensagens (HTTP) → Re-notificar) porque seu endpoint ficou fora do ar e perdeu o evento original.

json
{
  "id": "evt_3c8f1a9d2b4e6075",
  "type": "message.status",
  "tenantId": "tn_1a5c9e3f7b2d4068",
  "channelId": "ch_9f2c8a1b0d3e4f50",
  "channelKind": "official",
  "channelNumber": "5511999999999",
  "provider": "meta",
  "traceId": "tr_6a2d9f4b1c8e3057",
  "brokerTimestamp": "2026-08-24T15:03:00.000Z",
  "providerTimestamp": "2026-08-04T12:00:00.000Z",
  "data": {
    "messageId": "msg_7b3e1d9a2c4f6081",
    "status": "delivered",
    "redelivery": true
  }
}

Reafirmação, não mudança de status — deduplique por event.id

redelivery: true sinaliza que este é o mesmo status já conhecido, reemitido sob pedido — nunca uma transição real (o pipeline de disparo não avançou nem regrediu). O broker gera um id (evt_…) e traceId novos a cada re-notificação (o providerTimestamp reflete a última transição de estado conhecida da mensagem, e o brokerTimestamp é o momento da re-notificação) — trate seu handler como idempotente e deduplique por event.id: um message.status com redelivery deve reafirmar o estado atual do seu lado (ex.: reconfirmar "lido" na UI), não disparar de novo os efeitos colaterais de uma transição (contagem, notificação ao agente, etc.) que já rodaram na primeira entrega.

Webhook da plataforma oficial (número oficial)

Distinto dos endpoints acima: /webhooks/meta/:appId é onde a plataforma oficial entrega ao broker (não algo que você chama). Ele se autentica pelo HMAC X-Hub-Signature-256 sobre o corpo e pelo handshake hub.verify_token (o GET de verificação da subscription) — nunca por API key de tenant. Um :appId inexistente ou token/assinatura inválidos respondem 403 invalid_signature. Você o configura no painel da plataforma oficial ao habilitar um número oficial; os eventos normalizados chegam a você pela sua eventsUrl, acima.