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 inpayload.type),message.sent,message.delivered,message.read,message.failedinstance.connected/warming/disconnected/logged_out/bannedgroup.joined,group.left,group.participant_added/_removed/_promoted/_demoted,group.subject_changed,group.description_changedcontact.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 Autopilotusage.thresholdandadvisory.published— account scope (not project)
qr_code and pairing_code are not webhooksThey 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-only | forwards 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.
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:
- 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 realX-Bzapper-Signature. - Fire a one-off event —
POST /webhooks/triggerwith{"event_type":"message.received"}delivers to the project's registered webhooks. - Panel Playground — calls both endpoints above without you writing any
curl, and shows the raw response. - SSE —
GET /streamshows 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).