Webhooks
A API pode notificar seu backend em tempo real quando algo acontece numa
instância — mensagem recebida, confirmação de leitura, QR code novo,
mudança de status, saúde da instância caindo para vermelho, etc. Cada
evento gera um POST HTTP para a URL configurada, com retry automático em
caso de falha.
Se você prefere consumir os eventos direto no seu processo (sem expor um endpoint HTTP), veja WebSocket — ambos os canais recebem exatamente os mesmos eventos, com o mesmo formato de payload.
Configuração
Por instância
PATCH /instance/:name/webhook define a URL e os eventos assinados desta
instância:
curl -X PATCH http://localhost:8080/instance/minha-instancia/webhook \
-H "apikey: SUA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"webhookUrl": "https://meu-backend.com/webhooks/whatsapp",
"webhookEvents": ["message.received", "message.ack", "instance.health"]
}'
GET /instance/:name/webhook consulta a configuração atual ({ webhookUrl, webhookEvents }). Passar webhookUrl: null no PATCH limpa a URL
cadastrada. A URL também pode ser definida já na criação da instância
(POST /instance/create, mesmos dois campos no corpo).
webhookEvents é uma allow-list: só aceita os 9 nomes de evento válidos
(lista abaixo) — qualquer outro valor é rejeitado com 400. Se
webhookEvents vier vazio (ou não for definido), a instância recebe TODOS
os eventos — o filtro só entra em ação quando a lista tem pelo menos um
item.
Fallback global
Se a instância não tiver webhookUrl própria, a API usa a variável de
ambiente WEBHOOK_GLOBAL_URL (quando configurada) como destino. É útil
para operar várias instâncias contra o mesmo endpoint sem repetir a
configuração em cada uma. O filtro de webhookEvents da instância continua
valendo normalmente sobre esse fallback.
instance.health é um caso à parteO evento instance.health (kill switch, ver abaixo) não usa o fallback
global e não respeita o filtro webhookEvents — ele só é entregue quando
a instância tem webhookUrl própria configurada, e nesse caso é sempre
entregue, mesmo que instance.health não esteja na lista de eventos
assinados. Isso é o que o código faz hoje (src/schedulers/index.ts); se
você depende do kill switch, garanta que a instância tenha webhookUrl
própria — o fallback global não cobre esse evento.
Os 9 eventos
Os nomes abaixo são exatamente os do EVENT_MAP/WEBHOOK_EVENTS em
src/ws/index.ts — a mesma allow-list usada para validar webhookEvents
no PATCH acima.
| Evento | Quando dispara |
|---|---|
message.received | Mensagem recebida (inbound) na instância |
message.sent | Mensagem enviada a partir da própria sessão (inclui envios feitos direto no WhatsApp, não só pela API) |
message.ack | Confirmação de entrega/leitura de uma mensagem enviada |
message.dispatched | Um envio feito pela API acabou de ser transmitido — traz o id do WhatsApp e o seu clientRef (ver abaixo) |
qrcode.updated | Novo QR code gerado para pareamento |
connection.update | Mudança de status da sessão (conectando, pronta, desconectada) |
group.participants.update | Participante entrou, saiu, ou o grupo foi atualizado |
call.received | Chamada recebida na sessão do WhatsApp |
instance.health | Kill switch — a instância acabou de entrar em estado vermelho |
Formato do payload
Todo POST de webhook (e toda mensagem de WebSocket) usa o mesmo envelope,
montado por buildPayload (src/services/WebhookService.ts):
{
"instance": "minha-instancia",
"event": "message.received",
"timestamp": "2026-07-15T14:32:10.123Z",
"data": { }
}
O conteúdo de data depende do evento — é o resultado de serializeArgs
(src/ws/index.ts), que extrai só um subconjunto seguro dos dados brutos
do whatsapp-web.js.
message.received e message.sent
{
"instance": "minha-instancia",
"event": "message.received",
"timestamp": "2026-07-15T14:32:10.123Z",
"data": {
"id": "false_5565999998888@c.us_3EB0...",
"from": "5565999998888@c.us",
"to": "5511888887777@c.us",
"body": "Oi, ainda tem esse produto?",
"type": "chat",
"timestamp": 1752590000,
"fromMe": false,
"chatId": "5565999998888@c.us",
"isGroup": false,
"author": null,
"pushName": "Maria",
"hasMedia": false,
"mimetype": null,
"caption": null,
"quotedMsgId": null,
"quotedMsgSerializedId": null,
"quotedBody": null
}
}
Os campos a partir de chatId são aditivos — existem para que um sistema
de atendimento monte a conversa (contato, grupo, mídia, citação) sem uma
chamada extra por mensagem. Nenhum campo do conjunto original foi removido ou
renomeado.
| Campo | O que é |
|---|---|
chatId | JID da conversa: o grupo, quando for mensagem de grupo; o contato, quando for direta |
isGroup | true quando chatId termina em @g.us |
author | Em grupo, quem de fato escreveu; null em conversa direta |
pushName | Nome público do contato, quando o WhatsApp o envia |
hasMedia | Quando true, busque o binário em GET /message/:id/media — a mídia não viaja no payload |
mimetype, caption | Metadados da mídia, quando houver |
quotedMsgId | Id cru (stanza) da mensagem citada. Não serve para chamar as rotas desta API |
quotedMsgSerializedId | Id serializado da mensagem citada — é este que se usa em GET /message/:id/media e no campo quotedMsgId do corpo de um envio |
quotedBody | Texto (ou legenda) da mensagem citada |
As rotas resolvem mensagem por Client.getMessageById, que exige o id
serializado (fromMe_remote_stanza, com um 4º segmento participant
quando a citada é de outra pessoa num grupo) e rejeita qualquer outro formato
com Invalid serialized message id specified. O quotedMsgId histórico é o
stanza cru e falharia nessas chamadas — ele continua no payload só para não
quebrar quem já o lê. Use quotedMsgSerializedId.
quotedMsgSerializedId vem null quando o payload cru do WhatsApp não traz as
três peças necessárias (stanza da citada, jid remoto e o participante que a
escreveu — este último é o que permite deduzir o fromMe da chave). É null
de propósito: um id "quase certo" retornaria 500 na rota de mídia, não o
404 que ela documenta. Nesse caso, mostre quotedBody e trate a citada como
não-resolvível por id.
message.sent tem exatamente o mesmo formato de data (mesma função de
serialização), só com fromMe: true. Ele é disparado a partir do evento
message_create do whatsapp-web.js — que a lib emite para toda mensagem
criada, inbound e outbound (Client.js, o emit(MESSAGE_CREATE) vem antes do
if (msg.id.fromMe) return;). Por isso o WhatsAppManager só repassa esse
evento quando fromMe é verdadeiro: sem esse filtro, cada mensagem recebida
geraria message.received e message.sent. Ou seja, message.sent
sempre traz fromMe: true, e cobre também o que você envia direto pelo
celular pareado — não só o que sai pela API.
data.body contém o texto da mensagemO payload de message.received (e message.sent) carrega o corpo da
mensagem dentro de data.body. Trate a URL do seu webhook como dado
sensível: use HTTPS, valide a origem se possível, e não logue o payload
completo em texto plano num sistema sem controle de acesso — é conteúdo de
conversa real do seu cliente.
message.ack
{
"instance": "minha-instancia",
"event": "message.ack",
"timestamp": "2026-07-15T14:32:15.400Z",
"data": { "id": "true_5565999998888@c.us_3EB0...", "ack": 3 }
}
ack segue a numeração do whatsapp-web.js: 3 = entregue, 4 = lido
(são os dois valores que a própria API usa internamente para incrementar
os contadores delivered/read do AntiBanGuard).
message.dispatched
{
"instance": "minha-instancia",
"event": "message.dispatched",
"timestamp": "2026-07-15T14:32:12.900Z",
"data": {
"outboundId": "clx0a1b2c3...",
"clientRef": "pedido-42",
"waMessageId": "true_5565999998888@c.us_3EB0...",
"chatId": "5565999998888@c.us"
}
}
Este evento existe porque o envio é assíncrono. As rotas de /message/*
não falam com o WhatsApp durante a requisição: elas gravam um
OutboundMessage e respondem { status: "queued" | "scheduled", id } (ou
{ status: "rejected", reason }, se um gate barrar antes da fila), onde id
é o id desse registro — não o id da mensagem no WhatsApp. A mensagem
só sai depois, pelo worker da fila, respeitando o espaçamento e os gates do
AntiBanGuard. O id do WhatsApp (waMessageId) só passa a existir
nesse momento — e é ele, não o outboundId, que aparece depois em
message.ack e no eco de message.sent.
message.dispatched é a ponte entre os dois mundos:
| Campo | O que é |
|---|---|
outboundId | O id que a rota de envio devolveu — o registro na fila |
clientRef | A sua referência opaca, ecoada de volta (null se você não mandou nenhuma) |
waMessageId | O id da mensagem no WhatsApp — o que casa com message.ack e message.sent |
chatId | JID para onde a mensagem foi |
clientRef: sua chave de correlação
clientRef é um campo opcional aceito no corpo de qualquer rota de envio
(sendText, sendMedia, sendAudio, sendSticker, sendLocation,
sendContact, sendPoll) — uma string opaca de até 128 caracteres, guardada
como veio e devolvida sem interpretação. Use o id que o seu sistema já
tem para aquela mensagem (o id da linha na sua tabela de atendimento, por
exemplo):
curl -X POST http://localhost:8080/message/sendText \
-H "apikey: SUA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"instance": "minha-instancia",
"phone": "5565999998888",
"text": "Oi! Ainda temos sim.",
"clientRef": "pedido-42"
}'
Fluxo completo do lado do consumidor:
POST /message/sendTextcomclientRef: "pedido-42"→ resposta imediata{ status: "queued", id: "clx0a1b2c3..." }.- Quando o worker transmite, chega
message.dispatchedcomclientRef: "pedido-42"ewaMessageId. Grave owaMessageIdna sua linha. - Os
message.ackseguintes (3= entregue,4= lido) chegam com esse mesmowaMessageId— e agora você sabe a qual mensagem sua eles se referem.
Sem clientRef a correlação ainda é possível pelo outboundId (que a rota
devolve como id), mas aí você precisa guardá-lo; o clientRef evita esse
mapeamento extra. Lembre que message.dispatched precisa estar em
webhookEvents se você usa a allow-list — se a lista estiver vazia, você já
recebe tudo.
qrcode.updated
{
"instance": "minha-instancia",
"event": "qrcode.updated",
"timestamp": "2026-07-15T14:30:00.000Z",
"data": { "qr": "2@AbCdEf...,base64..." }
}
data.qr é a string bruta do QR code (não a data URL PNG que
GET /instance/connect/:name devolve) — para renderizar visualmente, gere
o PNG você mesmo a partir dessa string (ex.: lib qrcode no Node).
connection.update
{
"instance": "minha-instancia",
"event": "connection.update",
"timestamp": "2026-07-15T14:31:00.000Z",
"data": { "status": "connected" }
}
Disparado nas transições status, ready e disconnected do
WhatsAppManager — todas mapeiam para este mesmo nome de evento público.
group.participants.update e call.received
Estes dois eventos ainda não têm um extrator dedicado em serializeArgs —
caem no caso genérico da função, que devolve { "raw": true } como
data. Ou seja, hoje o payload avisa que o evento aconteceu, mas não
traz detalhes estruturados (quem entrou/saiu do grupo, quem ligou, etc.).
Se sua integração depende desses detalhes, hoje é preciso consultar as
rotas REST de grupo (ver Referência da API) depois de
receber o evento.
instance.health
{
"instance": "minha-instancia",
"event": "instance.health",
"timestamp": "2026-07-15T14:35:00.000Z",
"data": { "healthState": "red" }
}
Disparado pelo scheduler de saúde (roda a cada 5 minutos,
src/schedulers/index.ts) somente na transição para vermelho (estado
anterior diferente de red, novo estado igual a red) — não repete a
cada rodada enquanto a instância permanece vermelha. É o kill switch:
quando a instância entra em vermelho, o AntiBanGuard já pausa os envios
proativos sozinho; este evento é o aviso para o seu sistema também reagir
(alertar um operador, pausar campanhas, etc.).
Retry e log de entregas
A entrega roda numa fila BullMQ (src/core/queues/webhookQueue.ts), fora
do caminho da requisição HTTP que gerou o evento. Configuração real do
job:
{ attempts: 3, backoff: { type: 'exponential', delay: 2000 } }
Ou seja: até 3 tentativas no total, com backoff exponencial a partir de
2000ms — a fórmula padrão do BullMQ (delay * 2^(tentativa - 1)) dá um
intervalo de 2s antes da 2ª tentativa e 4s antes da 3ª (só 2
intervalos, já que são 3 tentativas ao todo, não 3 esperas). Cada POST
tem timeout de 10 segundos (WEBHOOK_TIMEOUT_MS); qualquer resposta fora
da faixa 2xx, erro de rede, timeout, ou URL bloqueada pela guarda de SSRF
conta como falha e aciona o retry.
Cada tentativa individual (não só o resultado final) grava uma linha em
WebhookDelivery, consultável por:
GET /instance/:name/webhooks/deliveries?limit=50
Resposta: lista de { id, event, url, status, attempts, lastError, payload, createdAt, ... }, mais recente primeiro (limit padrão 50, máximo 200).
attempts de cada linha é sempre 1Isso é uma particularidade real do código: cada execução do job (cada
tentativa do BullMQ) chama WebhookService.deliver, que sempre cria uma
nova linha de WebhookDelivery com attempts: 1 — o campo não é
incrementado numa linha existente. Para saber quantas tentativas um evento
específico levou, conte quantas linhas com o mesmo event/url aparecem
próximas no tempo (mesmo createdAt, com poucos segundos de diferença),
em vez de olhar attempts numa única linha.
O payload retornado por este endpoint é o mesmo enviado ao seu webhook —
inclui a mesma ressalva de message.received/message.sent sobre conter
texto de mensagem.
O comportamento acima (retry, backoff, gravação de WebhookDelivery) é o
que o código faz — mas a entrega de webhooks sob volume real de mensagens,
endpoints lentos ou instáveis do lado do cliente, e o comportamento do
BullMQ sob essas condições, ainda não foi validado ponta a ponta em
produção neste projeto. Se você notar entregas fora de ordem, duplicadas
(ex.: uma tentativa que teve sucesso do lado do seu servidor mas timeout do
lado da API antes de receber a resposta) ou atrasadas, isso é esperado de
um sistema de fila com retry — seu endpoint de webhook deve ser
idempotente (tratar o mesmo evento chegando mais de uma vez).