Skip to main content

Webhooks (handled with care)

Every activity becomes a signed webhook. Always use the code in your logic; the message is for humans only (translated by language).

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": "Support" },
"sender": { "jid": "[email protected]", "lid": "...@lid", "name": "Jane" },
"mentions": ["[email protected]"],
"payload": { "type": "text", "body": "hi", "wa_message_id": "..." }
}

Event catalog​

These are all the events the API delivers to an account/project webhook (partner Connect events are listed further down):

  • message.received (with subtypes in 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 — only when the contact replies with an opt-out keyword (payload.source = "keyword:inbound"). Opt-out done through the API (POST /contacts/{id}/optout) emits no webhook: the effect is already in that call's own response.
  • campaign.paused — automatic pause by the Safety Autopilot
  • usage.threshold and advisory.published — account scope (not project)
qr_code and pairing_code are not webhooks

They are the pairing secret and are delivered only over SSE (GET /stream). Subscribing a webhook to them does nothing. The end of a campaign also raises no event — poll the campaign (there is no campaign.completed); and billing is announced by e-mail, not by webhook.

Replies typed on the phone (message.sent with origin: "external")​

When the number's owner replies from the phone itself (or another linked device), the message arrives as message.sent with payload.origin = "external". The event carries the content, not just the fact that a reply happened: body (text or caption), type, to, wa_message_id, message_id, quoted_message_id when it replies to another message, and media when there is an attachment (same shape as message.received: id, presigned url, stable ref /media/{id}, mime_type, filename, size).

{ "type": "message.sent",
"payload": { "origin": "external", "type": "image", "to": "[email protected]",
"body": "here is the quote", "wa_message_id": "...", "message_id": "...",
"media": { "id": "...", "url": "https://...", "ref": "/media/...", "mime_type": "image/jpeg" } } }

These messages don't count as platform sends. The OTP code is never forwarded.

Contacts with @lid​

WhatsApp sometimes identifies a person only by an @lid (privacy identifier), without the phone number. bZapper resolves the phone on its own — first from what WhatsApp sends alongside and, if missing, from the LID→phone map the session has already learned. In those cases chat_jid/sender.jid carry the phone ([email protected]) and sender.lid is still present. Only when the LID is still unknown does the event arrive with ...@lid — it is stable for that person and can be used for correlation.

Account events (no instance_id — delivered to every account webhook):

  • usage.threshold — reached 80/90/100% of a plan allowance (payload: percent, used, included, plan)
  • advisory.published — action required on your integration: a change on our side requires you to update your code (SDK to upgrade, payload or endpoint that changed). It only reaches accounts that are affected — we cross the SDK version your account runs with the features it actually uses. It is never a changelog or a news blast. (payload: advisory_id, title, impact, action, link, published_at)

bZapper Connect events (partner software only — delivered to the partner webhook, not the account's): connect.completed, connect.suspended, connect.resumed, connect.revoked. See bZapper Connect.

HMAC-SHA256 signature (over the RAW body)​

The X-Bzapper-Signature: sha256=<hex> header is the HMAC-SHA256 of the raw body with your webhook's secret. Validate it before parsing the 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));

Idempotency​

Redeliveries happen (retry with backoff up to 5x when your endpoint fails). Deduplicate by event_id: apply the effect exactly once per id.

Rule: at most 1 webhook per event​

Within a project, each event type can only have one webhook. When creating or updating a webhook whose event_types collide with an existing one, the API responds 409 with the code event_taken (the message says which event conflicted). A webhook with empty event_types listens to all events — and so it conflicts with any other in the project. To re-subscribe an event, remove or edit the webhook that already listens to it.

Test your integration​

Relay to your localhost (bzapper listen)​

No public URL required. The Node SDK ships an executable that opens your project's event stream and forwards every event to your local server, signed exactly like production does:

npx @bzapper/client listen --forward-to http://localhost:3000/webhooks/bzapper
bZapper — webhook relay to localhost
listening https://api.bzapper.com.br/webhooks/listen
forwarding http://localhost:3000/webhooks/bzapper
secret whsec_Hs3…

✓ connected to the event stream. Ctrl+C to quit.

14:02:11 message.received evt_01HZX… → 200 12ms
Option
-f, --forward-to <url>local URL that receives the POSTs (required, unless --print-only)
--api-key <key>your bz_live_… key; defaults to $BZAPPER_API_KEY
--base-url <url>API base; defaults to $BZAPPER_BASE_URL or production
--project <id>active project (session credentials only — an API key already carries its own)
--events <a,b,c>only these types (e.g. message.received,message.sent)
--secret <whsec_…>signing secret; defaults to a fresh one, printed at startup
--print-onlyforwards nothing, just prints what arrives

No registered webhook needed: the stream (GET /webhooks/listen) mirrors every event in the project, subscription or not. All it takes is an API key with access to the project — and the key travels in the Authorization header, never in the URL.

Every POST carries the same headers production sends (X-Bzapper-Signature, X-Bzapper-Event-Id, X-Bzapper-Event-Type, Content-Type: application/json), so your code validates the relay with the very same verifier from the section above.

The secret is yours, not ours

Without --secret, the CLI generates one on the spot and prints it — use it in your app while testing. The signature proves the POST came from that CLI, not from bZapper: it is worth exactly what the secret is worth. In production the secret is the registered webhook's.

The connection heals itself (exponential backoff, capped at 30 s), Ctrl+C exits cleanly, and a rejected credential stops right away with a non-zero exit code.

Other paths​

All of them deliver the same signed envelope production does:

  1. Test endpoint — register the webhook pointing at a receiver of yours (a tunnel such as ngrok/Cloudflare Tunnel works) and call POST /webhooks/{id}/test. It fires a sample event at your endpoint, with a real X-Bzapper-Signature.
  2. Fire a one-off event — POST /webhooks/trigger with {"event_type":"message.received"} delivers to the project's registered webhooks.
  3. Panel Playground — calls both endpoints above without you writing any curl, and shows the raw response.
  4. SSE — GET /stream shows events live, with no public URL at all; great for checking what arrives before you write the receiver.

Always validate the signature over the raw body you receive (see the signature section above).