Webhooks
Os eventos entregues ao seu endpoint 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, verifica a assinatura e processa o evento tipado.
Origem das entregas
5.161.113.197. Se seu endpoint estiver atrás de uma allowlist de rede, libere esse IP em vez de uma faixa inteira.Configurar o endpoint
O namespace webhooks gere a config do tenant:
// Definir a URL de entrega (o broker a valida pela guarda SSRF)
await wa.webhooks.setConfig({ eventsUrl: 'https://api.suaempresa.com/webhooks/wabroker' })
// Ler a config — só o last4 do segredo, nunca o segredo inteiro
const cfg = await wa.webhooks.getConfig() // { eventsUrl, secretLast4 }
// Rotacionar o segredo — o valor em claro aparece UMA vez, aqui
const rotated = await wa.webhooks.rotateSecret() // { secret, last4 }
console.log(rotated.secret) // guarde-o agora; nunca é reexibidoResposta 200
{
"eventsUrl": "https://crm.example.com/webhooks/wabroker"
}Resposta 200
{
"eventsUrl": "https://crm.example.com/webhooks/wabroker",
"secretLast4": "<redacted>"
}Resposta 200
{
"last4": "<redacted>",
"secret": "<redacted>"
}Segredo é write-only
getConfig() devolve só secretLast4. rotateSecret() é a única vez que o segredo em claro aparece — capture-o e guarde-o no seu backend. Perdeu? Rotacione de novo.Múltiplos destinos
Além da config singular, um workspace pode ter até 5 destinos de webhook ativos ao mesmo tempo — cada um com a própria url e o próprio segredo HMAC. Todos os destinos recebem todos os eventos: não há filtro por tipo nem roteamento seletivo por endpoint. É útil para espelhar entregas a um segundo sistema (ex.: um CRM e um data warehouse) sem duplicar lógica de assinatura no seu lado — cada host valida com o segredo que ele recebeu na criação.
A coleção mora em webhooks.endpoints:
// Criar um destino — o secret em claro aparece só aqui, uma vez
const endpoint = await wa.webhooks.endpoints.create({
url: 'https://api.suaempresa.com/webhooks/wabroker',
})
console.log(endpoint.secret) // guarde-o agora; nunca é reexibido
// Listar os destinos do workspace — só o last4 do segredo de cada um
const endpoints = await wa.webhooks.endpoints.list()
// [{ id, url, secretLast4, enabled, createdAt }, ...]
// Rotacionar o segredo de UM destino específico (não afeta os outros)
const rotated = await wa.webhooks.endpoints.rotateSecret(endpoint.id) // { secret, last4 }get(id) / GetAsync(id) busca um destino, update(id, patch) / UpdateAsync(id, patch) troca a url e/ou liga/desliga (enabled) sem apagar, e remove(id) / RemoveAsync(id) apaga o destino.
- A config singular
webhooks.setConfig()/getConfig()continua funcionando como atalho para o primeiro destino do workspace — não é um canal separado. Quem já usa só a config singular não precisa migrar para continuar recebendo eventos. - Cada destino tem seu próprio segredo:
verifyAndParse(body, header, secret)não muda — passe sempre o segredo daquele host, não um segredo compartilhado entre destinos. - A
urlde cada destino passa pela mesma guarda SSRF da config singular ao ser criada ou atualizada.
Limites da coleção
webhook_endpoint_limit (409) — apague ou reaproveite um existente. Criar um destino com uma url já cadastrada neste workspace devolve webhook_endpoint_url_taken (409) — cada URL é única por tenant, não global.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), usando o seu segredo como chave.
O timestamp entra no material assinado — não só no header — para viabilizar a janela anti-replay: uma entrega válida capturada não pode ser reenviada para sempre. A tolerância padrão é de 5 minutos para os dois lados.
Com o SDK (recomendado)
verifyAndParse(rawBody, headers, secret) faz tudo: valida a assinatura, checa a janela anti-replay, parseia o JSON e devolve o evento tipado — ou lança EventVerificationError. Passe o corpo cru (o texto exato recebido); não parseie e reserialize antes, ou os bytes não batem com a assinatura.
import { verifyAndParse, EventVerificationError } from '@wabroker/sdk'
app.post('/webhooks/wabroker', (req, res) => {
try {
const event = verifyAndParse(req.rawBody, req.headers, process.env.WABROKER_WEBHOOK_SECRET!)
handleEvent(event) // event já é NormalizedEvent, tipado
res.sendStatus(200)
} catch (err) {
if (err instanceof EventVerificationError) {
// err.reason: 'missing_signature' | 'invalid_signature' | 'malformed_event'
return res.sendStatus(400)
}
throw err
}
})Se você só quer o booleano, isBrokerSignatureValid(rawBody, header, secret) devolve true/false (header é o valor de X-WaBroker-Signature).
Falha fechado
Eventos
O corpo é um NormalizedEvent. O discriminante é o type no topo do envelope (data não tem type próprio), então switch (event.type) é seguro e exaustivo.
Todo evento carrega channelNumber — o telefone do número deste tenant (E.164 sem +), ou null se o número ainda não pareou. É a chave estável recomendada para amarrar conversas e tickets dos dois lados (enviar e receber): enquanto o channelId (ch_…) é o handle do ciclo de vida e muda quando o número é recriado (apagar + reconectar por QR gera outro id), o telefone sobrevive à recriação — 1 telefone = 1 número vivo por tenant. Use channelId só para diagnóstico/ciclo de vida.
function handleEvent(event: NormalizedEvent) {
// Correlacione pela chave estável, não pelo channelId (que muda na recriação).
const conversationKey = event.channelNumber ?? event.channelId
routeToTicket(conversationKey, event)
}| 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) |
function handleEvent(event: NormalizedEvent) {
switch (event.type) {
case 'message.received':
// event.data.from, event.data.message (text | media | ...), profileName?
break
case 'message.sent':
// event.data.to, event.data.messageId (msg_...), event.data.content
// (text | media | template com rendered? | interactive | reaction)
break
case 'message.status':
// event.data.status: 'sent' | 'delivered' | 'read' | 'failed'
// em 'failed', event.data.errorCode traz o código da taxonomia
break
case 'template.updated':
// event.data.name, language, status
break
case 'channel.status':
// event.data.state: 'connected' | 'down' (+ phoneNumber em connected)
break
case 'channel.lifecycle':
// event.data.fromState -> toState, reason, source
break
}
}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. Formato de provider não cruza o contrato.Bloqueios de número não têm webhook
channel.status nem channel.lifecycle) — o channel.status cobre só connected/down, e um número restrito continua com state: 'connected'. Descubra esses bloqueios por polling de GET /v1/channels (campos restricted/limited/quarantine) ou por GET /v1/channels/:id/overview.Exemplos de evento
O corpo exato que chega no seu endpoint, um por tipo — fiel ao que o broker entrega. Todo envelope carrega type, channelKind (official / unofficial), channelNumber e ids de correlação; o específico do evento vive em data.
message.received
Uma mensagem de texto chegou de um contato. O texto vem em data.message.
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.
Mídia chega sem os bytes: data.message traz um mediaId opaco (e metadado como mimeType/filename), que você troca pelos bytes em GET /v1/media/:channelId/:mediaId — passando o channelId do envelope e esse mediaId(veja Mensagens (HTTP) → Baixar mídia recebida).
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.
Campos aditivos (opcionais) de message.received:
replyTo— omsg_…da mensagem citada, quando ela é conhecida do broker.replyToUnknown: true— a citada é de fora do broker; o wamid nunca é entregue.- Além de
text/media, okinddedata.messagepode serinteractive_reply,reaction(traztargetMessageId) ouunsupported.
message.sent
Confirmação de que o próprio broker despachou uma mensagem e o provider a aceitou — o espelho de saída de message.received. Distinto de message.status: aquele é o ciclo de vida de entrega (sent → delivered → read) de uma mensagem já conhecida pelo messageId; este é o registro de que a mensagem foi enviada e com que conteúdo, para você não precisar guardar o que mandou só para exibir depois. data.to é o destinatário (E.164 sem +), data.messageId é sempre um msg_… (nunca o wamid do provider), e data.content traz o mesmo kind do que foi enviado (text, media, template, interactive, reaction) — mais enxuto que o request de envio: sem a url ou o id de provider que o próprio chamador já conhecia.
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.
Em content.kind === 'template', rendered traz o texto humano com os params já substituídos no corpo aprovado — presente quando o broker conseguiu localizar o template no seu repositório; ausente (nunca inventado) quando não.
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.
data.metadata — quando o envio carregou um metadata (mapa string→string opcional, de chaves livres: nº de contrato, nome, documento) — é ecoado aqui idêntico ao que você mandou. O broker trata como totalmente opaco: nunca interpreta, indexa ou loga o conteúdo, só o repassa. Enviou sem metadata? A chave sai omitida do evento (nunca null/{}), como no exemplo acima.
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.
metadata carrega dado seu — LGPD apaga também
metadata costuma trazer dado de correlação (às vezes dado pessoal), o pedido de apagamento (compliance.erase / POST /v1/erasure) remove também o metadata já entregue ou pendente de entrega do contato apagado — não só o texto de mensagens.message.status
O avanço de entrega de uma mensagem que você enviou: sent → delivered → read.
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.
Na falha, o mesmo evento chega com status: 'failed' e um errorCode da taxonomia — o gancho para você alertar ou reenviar.
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.
failed cobre toda falha terminal, do broker ou do provider
message.status: 'failed' não é só o eco de uma rejeição do provider: o broker emite o mesmo evento para toda falha terminal que ele próprio decide — template inválido no dispatch (template_call_invalid), mensagem que expirou sem disparar (message_expired, por ttlSeconds), contato em opt-out (recipient_opted_out) e esgotamento das retentativas — além das que chegam do provider (ex.: channel_restricted em voo, ack 463). Trate message.status: 'failed' como o gancho único para alertar ou reenviar, qualquer que seja a origem do erro.Bloqueios de número: 409 síncrono é a exceção
channel_limited e channel_quarantined chegam como 409 síncrono na resposta do POST .../messages — a mensagem nunca é enfileirada, então nunca vira message.status. É a única classe de falha que não passa por failed: channel_restricted para um contato novo também é 409 síncrono, mas pode ainda aparecer em message.status: 'failed' com errorCode: 'channel_restricted' quando o WhatsApp barra em voo. No lote, toda rejeição na admissão (síncrona, do broker ou por 409 de canal) vem por-posição como { error: { code } }; uma vez admitido, o item segue o mesmo ciclo de message.status de um envio único — inclusive failed para as causas geradas pelo broker acima.channel.status
O sinal grosso up/down de um número. connected traz o phoneNumber pareado.
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.
Quando a sessão cai, o mesmo evento chega com state: 'down'.
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.
channel.lifecycle
A auditoria por-transição do número: cada mudança de estado com fromState → toState, o reason e o source que a disparou (connect, reconnect, disconnect, supervisor, webhook, register, admin, system). Distinto do channel.status, que é só o sinal grosso up/down.
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.
template.updated
Um template mudou de status na plataforma oficial (o resultado assíncrono de criar/editar). Aqui, approved.
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.
Entregas e retries
O broker reentrega em caso de falha e mantem uma DLQ para as que esgotaram as tentativas. Inspecione as suas com deliveries.list (paginado por cursor, metadado de estado — nunca o payload cru):
const page = await wa.deliveries.list({ state: 'dead', limit: 50 })
for (const d of page.items) {
console.log(d.eventType, d.state, d.attempts, d.lastError)
}
if (page.nextCursor) await wa.deliveries.list({ cursor: page.nextCursor })- Estados de
DeliveryInfo.state:pending,dispatching,delivered,retrying,dead. - Responda
2xxrápido; trabalho pesado deve ir para uma fila sua, para não estourar o timeout de entrega e provocar retries. - A
eventsUrlpassa pela guarda SSRF do broker ao ser definida — use um endpoint público e estável.