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étodo | path | o que faz |
|---|---|---|
GET | /v1/webhooks/config | lê eventsUrl + secretLast4 |
PUT | /v1/webhooks/config | define a URL de entrega |
POST | /v1/webhooks/secret/rotate | rotaciona 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.
# 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
{
"eventsUrl": "https://crm.example.com/webhooks/wabroker"
}Resposta 200
{
"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.
curl -X POST https://api.grwthy.com/v1/webhooks/secret/rotate \
-H "Authorization: Bearer $WABROKER_API_KEY"Resposta 200
{
"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):
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.
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
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.
| type | quando |
|---|---|
message.received | uma mensagem chegou (from, message, profileName?) |
message.sent | o broker despachou e o provider aceitou (to, messageId, content) |
message.status | status de entrega: sent/delivered/read/failed |
template.updated | um template mudou de status na plataforma oficial |
channel.status | sinal grosso up/down do número |
channel.lifecycle | cada transição de estado do número (auditoria) |
Metadado, não conteúdo do cliente
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
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
{
"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
{
"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
{
"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
{
"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
{
"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
{
"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
{
"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
{
"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
{
"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
{
"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
{
"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.
{
"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.