Seções

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

As entregas de webhook podem originar de um IP dedicado — 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:

ts
// 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 é reexibido

Resposta 200

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

Resposta 200

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

Resposta 200

json
{
  "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:

ts
// 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 url de cada destino passa pela mesma guarda SSRF da config singular ao ser criada ou atualizada.

Limites da coleção

Criar um 6º destino devolve 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):

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), 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.

ts
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

Sem segredo, com corpo adulterado, fora da janela anti-replay, ou com header ausente/malformado, a verificação sempre falha — e você deve recusar 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), 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.

ts
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)
}
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)
ts
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

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. Formato de provider não cruza o contrato.

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

Restrição, limitação e quarentena não produzem webhook (nem 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

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.

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

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.

Campos aditivos (opcionais) de message.received:

  • replyTo — o msg_… 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, o kind de data.message pode ser interactive_reply, reaction (traz targetMessageId) ou unsupported.

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

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.

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

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.

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

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.

metadata carrega dado seu — LGPD apaga também

Como 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

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.

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

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.

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

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.

Quando a sessão cai, o mesmo evento chega com state: 'down'.

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.

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

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.

template.updated

Um template mudou de status na plataforma oficial (o resultado assíncrono de criar/editar). Aqui, approved.

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.

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):

ts
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 2xx rápido; trabalho pesado deve ir para uma fila sua, para não estourar o timeout de entrega e provocar retries.
  • A eventsUrl passa pela guarda SSRF do broker ao ser definida — use um endpoint público e estável.