Pular para o conteúdo principal

Webhooks (com carinho)

Toda atividade vira um webhook assinado. Use sempre o code na lógica; o message é só para humanos (traduzido pelo idioma).

Envelope​

{
"event_id": "evt_...",
"event_type": "message.received",
"timestamp": "2026-06-23T14:14:21Z",
"instance_id": "...",
"client_reference": "lead-42",
"group": { "jid": "[email protected]", "name": "Atendimento" },
"sender": { "jid": "[email protected]", "lid": "...@lid", "name": "Fulano" },
"mentions": ["[email protected]"],
"payload": { "type": "text", "body": "olá", "wa_message_id": "..." }
}

Catálogo de eventos​

Estes são todos os eventos que a API entrega a um webhook de conta/projeto (os eventos de parceiro do Connect estão mais abaixo):

  • message.received (com subtipos no payload.type), message.sent, message.delivered, message.read, message.failed — no trilho oficial, os de status trazem payload.pricing (o que a Meta cobra) e o message.received vindo de anúncio traz payload.referral (ver API oficial)
  • instance.connected / warming / disconnected / logged_out / banned
  • group.joined, group.left, group.participant_added / _removed / _promoted / _demoted, group.subject_changed, group.description_changed
  • contact.opted_out — quando o contato responde uma palavra-chave de opt-out (payload.source = "keyword:inbound") ou, no oficial, para o marketing no próprio WhatsApp (payload.reason = "marketing_opt_out"); contact.opted_in quando ele retoma o marketing (ver API oficial). O opt-out feito por API (POST /contacts/{id}/optout) não emite webhook: o efeito já está na resposta da própria chamada.
  • campaign.paused — pausa automática do Safety Autopilot
  • usage.threshold e advisory.published — escopo de conta (não de projeto)
qr_code e pairing_code não são webhooks

São o segredo do pareamento e saem apenas pelo SSE (GET /stream). Não adianta assinar um webhook para eles. O fim de uma campanha também não gera evento — consulte a campanha (não existe campaign.completed); e cobrança é avisada por e-mail, não por webhook.

Resposta dada pelo celular (message.sent com origin: "external")​

Quando o dono do número responde pelo próprio celular (ou outro aparelho), a mensagem chega como message.sent com payload.origin = "external". O evento traz o conteúdo, não só o aviso de que houve resposta: body (texto ou legenda), type, to, wa_message_id, message_id, quoted_message_id quando é resposta a outra mensagem, e media quando há anexo (mesmo formato do message.received: id, url pré-assinada, ref estável /media/{id}, mime_type, filename, size).

{ "type": "message.sent",
"payload": { "origin": "external", "type": "image", "to": "[email protected]",
"body": "segue o orçamento", "wa_message_id": "...", "message_id": "...",
"media": { "id": "...", "url": "https://...", "ref": "/media/...", "mime_type": "image/jpeg" } } }

Essas mensagens não contam como envio da plataforma. O código do OTP nunca é repassado.

Contatos com @lid​

O WhatsApp às vezes identifica a pessoa só por um @lid (identificador de privacidade), sem o telefone. O bZapper resolve o telefone sozinho — primeiro pelo que o WhatsApp manda junto e, se faltar, pelo mapa LID→telefone que a sessão já aprendeu. Nesses casos chat_jid/sender.jid chegam com o telefone ([email protected]) e o sender.lid continua informado. Só quando o LID ainda é desconhecido o evento chega com ...@lid — ele é estável para aquela pessoa e serve para correlacionar.

Você pode responder para o @lid. O campo to dos envios aceita o @lid do mesmo jeito que aceita telefone ou JID — útil quando é o único endereço que você tem da pessoa (veio assim no evento, ou é um participante de grupo). O bZapper traduz para o telefone quando o número conectado já conhece aquele LID, e aí tudo o que é chaveado por telefone vale igual: a conversa é a mesma, a block list barra do mesmo jeito, o número que já fala com a pessoa é reusado e o contato é o mesmo. Quando o LID ainda é desconhecido, a mensagem sai pelo próprio @lid. O @lid existe só no WhatsApp multi-dispositivo: no trilho oficial (API oficial da Meta) o destino é sempre o telefone, e um @lid recebe 422 official_lid_unsupported.

Para experimentar antes de escrever código, o Playground do painel tem um seletor de destino: escolha @lid, cole o identificador (ou só os dígitos) e envie.

Conversa que só tem @lid fica fora da block list

A supressão (opt-out, bloqueio manual, bounce) é chaveada por telefone, porque é o telefone que identifica a pessoa em todos os seus números. Enquanto o telefone de um LID não é conhecido, não há o que conferir: o PARAR dessa conversa também não é gravado. Assim que o telefone aparece, as duas pontas voltam a valer.

Eventos do trilho oficial (projetos na API oficial da Meta). A decisão dela sobre um template é assíncrona e pode levar até 24 horas — sem estes eventos você teria que ficar consultando para saber se já pode enviar. Ver API oficial.

  • template.approved — dá para enviar por esse nome a partir de agora
  • template.rejected — o payload traz rejected_reason
  • template.paused — a Meta pausou por qualidade baixa; ele para de enviar
  • template.categorized — a Meta reclassificou o template. Isso muda o preço da mensagem, e é a razão de este evento existir separado dos outros
  • template.quality_changed — a qualidade do template mudou (payload.quality: GREEN, YELLOW ou RED). O RED que leva à pausa chega como template.paused

E sobre o número e a conta oficiais — o que, sem estes eventos, só se descobre no WhatsApp Manager:

  • instance.quality_changed — qualidade do número (quality, previous_quality)
  • instance.limit_changed — limite de envio do número (limit, previous_limit; ex.: TIER_250 → TIER_2K)
  • instance.name_decided — decisão sobre o nome de exibição pedido (decision: approved, rejected ou deferred; requested_name, reason)
  • account.status_changed — a conta passou a active, suspended, awaiting_payment ou pending (status, previous_status, reason, cause)
  • account.alert — aviso da Meta sobre a conta (severity: critical, warning ou informational; type, description)

Quando a Meta suspende a conta, a qualidade do número cai ou chega um aviso crítico, os administradores da conta também recebem um e-mail.

Eventos de conta (sem instance_id — entregues a todos os webhooks da conta):

  • usage.threshold — atingiu 80/90/100% de uma franquia do plano (payload: percent, used, included, plan)
  • advisory.published — ação necessária na sua integração: uma mudança nossa exige que você atualize seu código (SDK a atualizar, payload ou endpoint que mudou). Só chega para quem é afetado — cruzamos a versão de SDK que sua conta usa com os recursos que ela de fato utiliza. Nunca é changelog nem novidade. (payload: advisory_id, title, impact, action, link, published_at)

Eventos do bZapper Connect (só para softwares parceiros — chegam no webhook do parceiro, não no da conta): connect.completed, connect.suspended, connect.resumed, connect.revoked. Veja bZapper Connect.

Assinatura HMAC-SHA256 (sobre o corpo CRU)​

O header X-Bzapper-Signature: sha256=<hex> é o HMAC-SHA256 do corpo cru com o secret do seu webhook. Valide antes de dar parse no JSON.

import hmac, hashlib

def valid(secret: bytes, raw_body: bytes, header: str) -> bool:
expected = "sha256=" + hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header)
import crypto from 'node:crypto';
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(header));

Idempotência​

Reentregas acontecem (retry com backoff até 5x quando seu endpoint falha). Deduplique por event_id: aplique o efeito uma única vez por id.

Esgotadas as tentativas, a entrega fica failed. Depois de consertar o seu endpoint, dá para reenviá-la — pelo painel (Entregas recentes → Reenviar) ou pela API: POST /webhooks/{id}/deliveries/{delivery_id}/redeliver. Vai o mesmo corpo e o mesmo event_id, assinado com o segredo atual. Só falhas dos últimos 7 dias: o corpo traz conteúdo de conversa e não é guardado além disso.

Regra: no máximo 1 webhook por evento​

Dentro de um projeto, cada tipo de evento só pode ter um webhook. Ao criar ou atualizar um webhook cujos event_types colidem com outro já existente, a API responde 409 com o código event_taken (a mensagem diz qual evento conflitou). Um webhook com event_types vazio escuta todos os eventos — e por isso conflita com qualquer outro do projeto. Para reassinar um evento, remova ou edite o webhook que já o escuta.

Testar a sua integração​

Relay para o seu localhost (bzapper listen)​

Sem expor URL pública nenhuma. O executável do SDK Node abre o stream de eventos do seu projeto e reenvia cada um ao seu servidor local, assinado igualzinho à produção:

npx @bzapper/client listen --forward-to http://localhost:3000/webhooks/bzapper
bZapper — relay de webhooks para o localhost
ouvindo https://api.bzapper.com.br/webhooks/listen
reenviando http://localhost:3000/webhooks/bzapper
secret whsec_Hs3…

✓ conectado ao stream de eventos. Ctrl+C para sair.

14:02:11 message.received evt_01HZX… → 200 12ms
Opção
-f, --forward-to <url>URL local que recebe os POSTs (obrigatória, salvo com --print-only)
--api-key <key>a key bz_live_…; padrão $BZAPPER_API_KEY
--base-url <url>base da API; padrão $BZAPPER_BASE_URL ou produção
--project <id>projeto ativo (só para credencial de sessão — a API key já traz o seu)
--events <a,b,c>só estes tipos (ex.: message.received,message.sent)
--secret <whsec_…>segredo de assinatura; padrão: um novo, impresso ao iniciar
--print-onlynão reenvia nada, só imprime o que chegar

Não precisa de webhook cadastrado: o stream (GET /webhooks/listen) espelha todo evento do projeto, exista ou não uma assinatura. Precisa só de uma API key com acesso ao projeto — e ela vai no header Authorization, nunca na URL.

Cada POST leva os mesmos headers da produção (X-Bzapper-Signature, X-Bzapper-Event-Id, X-Bzapper-Event-Type, Content-Type: application/json), então o seu código valida o relay com o mesmo verificador da seção acima.

O secret é seu, não nosso

Sem --secret, a CLI gera um na hora e o imprime — use-o no seu app durante o teste. A assinatura prova que o POST veio daquela CLI, não da bZapper: ela vale exatamente o que o secret vale. Em produção, o secret é o do webhook cadastrado.

A conexão se reergue sozinha (backoff exponencial, teto de 30 s), Ctrl+C sai limpo e credencial recusada encerra com código diferente de zero.

Outros caminhos​

Todos entregam o mesmo envelope assinado que a produção:

  1. Endpoint de teste — registre o webhook apontando para um receptor seu (um túnel como ngrok/Cloudflare Tunnel serve) e chame POST /webhooks/{id}/test. Ele dispara um evento de exemplo no seu endpoint, com X-Bzapper-Signature real.
  2. Disparar um evento avulso — POST /webhooks/trigger com {"event_type":"message.received"} entrega aos webhooks cadastrados do projeto.
  3. Playground do painel — chama os dois endpoints acima sem você escrever curl, e mostra a resposta crua.
  4. SSE — GET /stream mostra os eventos ao vivo, sem precisar de URL pública nenhuma; ótimo para conferir o que chega antes de escrever o receptor.

Valide sempre a assinatura sobre o corpo cru recebido (ver a seção de assinatura acima).

Trilho oficial: o que a Meta mandou​

No trilho oficial há um elo antes do seu webhook: a Meta entrega o evento ao bZapper, e só então ele vira um evento do bZapper para o seu sistema. Quando algo não chega, GET /official/webhook-events mostra esse primeiro elo — cada evento que a Meta mandou para as contas do projeto, cru, como ela assinou, e o que o bZapper fez com ele:

{
"data": [
{
"id": 4812,
"received_at": "2026-10-17T14:03:11Z",
"fields": "messages",
"status": "done",
"attempts": 1,
"body": { "object": "whatsapp_business_account", "entry": [ … ] }
}
]
}
  • status: pending (na fila), running, done (aplicado) ou failed (esgotou as tentativas; error diz por quê).
  • Do mais novo ao mais antigo; limit (1–200, padrão 50) e before (o último id da página) para paginar.
  • O evento fica guardado por 3 dias depois de aplicado, 30 se falhou.
  • Exige o escopo webhooks:manage, o mesmo de quem gere webhooks.

Lido junto com GET /webhooks/{id}/deliveries, diz em qual ponta a cadeia parou: não está aqui, a Meta não mandou; está aqui como done mas não chegou ao seu endpoint, olhe as entregas.