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 nopayload.type),message.sent,message.delivered,message.read,message.failed— no trilho oficial, os de status trazempayload.pricing(o que a Meta cobra) e omessage.receivedvindo de anúncio trazpayload.referral(ver API oficial)instance.connected/warming/disconnected/logged_out/bannedgroup.joined,group.left,group.participant_added/_removed/_promoted/_demoted,group.subject_changed,group.description_changedcontact.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_inquando 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 Autopilotusage.thresholdeadvisory.published— escopo de conta (não de projeto)
qr_code e pairing_code não são webhooksSã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.
@lid fica fora da block listA 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 agoratemplate.rejected— opayloadtrazrejected_reasontemplate.paused— a Meta pausou por qualidade baixa; ele para de enviartemplate.categorized— a Meta reclassificou o template. Isso muda o preço da mensagem, e é a razão de este evento existir separado dos outrostemplate.quality_changed— a qualidade do template mudou (payload.quality:GREEN,YELLOWouRED). OREDque leva à pausa chega comotemplate.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,rejectedoudeferred;requested_name,reason)account.status_changed— a conta passou aactive,suspended,awaiting_paymentoupending(status,previous_status,reason,cause)account.alert— aviso da Meta sobre a conta (severity:critical,warningouinformational;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-only | nã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.
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:
- 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, comX-Bzapper-Signaturereal. - Disparar um evento avulso —
POST /webhooks/triggercom{"event_type":"message.received"}entrega aos webhooks cadastrados do projeto. - Playground do painel — chama os dois endpoints acima sem você escrever
curl, e mostra a resposta crua. - SSE —
GET /streammostra 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) oufailed(esgotou as tentativas;errordiz por quê).- Do mais novo ao mais antigo;
limit(1–200, padrão 50) ebefore(o últimoidda 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.