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
  • 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 — só quando o contato responde uma palavra-chave de opt-out (payload.source = "keyword:inbound"). 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.

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.

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