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.failed— on the official rail, status events carrypayload.pricing(what Meta charges) and amessage.receivedcoming from an ad carriespayload.referral(see Official API)instance.connected/warming/disconnected/logged_out/bannedgroup.joined,group.left,group.participant_added/_removed/_promoted/_demoted,group.subject_changed,group.description_changedcontact.opted_out— when the contact replies with an opt-out keyword (payload.source = "keyword:inbound") or, on the official rail, stops marketing inside WhatsApp (payload.reason = "marketing_opt_out");contact.opted_inwhen they resume marketing (see Official API). 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.
You can reply to the @lid. The to field of a send accepts a @lid just like a
phone number or a JID — handy when it is the only address you have for that person (it
arrived that way in the event, or they are a group participant). bZapper translates it to
the phone number when the connected number already knows that LID, and then everything
keyed by phone behaves the same: same conversation, same block list, the number that
already talks to them is reused, same contact record. While the LID is still unknown, the
message goes out to the @lid itself. The @lid only exists in multi-device WhatsApp: on
the official rail (Meta's official API) the destination is always a phone number, and a
@lid gets 422 official_lid_unsupported.
To try it before writing code, the panel's Playground has a destination picker: choose
@lid, paste the identifier (or just the digits) and send.
@lid is outside the block listSuppression (opt-out, manual block, bounce) is keyed by phone number, because the phone
is what identifies a person across all of your numbers. While the phone behind a LID is
unknown there is nothing to check — and a STOP in that conversation isn't recorded
either. As soon as the phone shows up, both ends apply again.
Official rail events (projects on Meta's official API). Meta's decision on a template is asynchronous and can take up to 24 hours — without these events you would have to poll to know whether you can send yet. See Meta's official API.
template.approved— you can send by that name from now ontemplate.rejected— thepayloadcarriesrejected_reasontemplate.paused— Meta paused it for low quality; it stops sendingtemplate.categorized— Meta reclassified the template. That changes what the message costs, which is why this event stands apart from the otherstemplate.quality_changed— the template quality changed (payload.quality:GREEN,YELLOWorRED). TheREDthat leads to a pause arrives astemplate.paused
And about the official number and account — things that, without these events, you only find out in WhatsApp Manager:
instance.quality_changed— number quality (quality,previous_quality)instance.limit_changed— the number's messaging limit (limit,previous_limit; e.g.TIER_250→TIER_2K)instance.name_decided— decision on the requested display name (decision:approved,rejectedordeferred;requested_name,reason)account.status_changed— the account becameactive,suspended,awaiting_paymentorpending(status,previous_status,reason,cause)account.alert— a Meta notice about the account (severity:critical,warningorinformational;type,description)
When Meta suspends the account, the number quality drops or a critical notice arrives, the account admins also get an email.
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).
Once the attempts run out the delivery is failed. After fixing your endpoint you can
redeliver it — from the dashboard (Recent deliveries → Redeliver) or the API:
POST /webhooks/{id}/deliveries/{delivery_id}/redeliver. Same body and same event_id,
signed with the current secret. Failures from the last 7 days only: the body carries
conversation content and is not kept longer.
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).
Official rail: what Meta sent
On the official rail there is one link before your webhook: Meta delivers the
event to bZapper, and only then does it become a bZapper event for your system.
When something does not arrive, GET /official/webhook-events shows that
first link — every event Meta sent for the project's accounts, raw, as Meta
signed it, and what bZapper did with it:
{
"data": [
{
"id": 4812,
"received_at": "2026-10-17T14:03:11Z",
"fields": "messages",
"status": "done",
"attempts": 1,
"body": { "object": "whatsapp_business_account", "entry": [ … ] }
}
]
}
status:pending(queued),running,done(applied) orfailed(ran out of attempts;errorsays why).- Newest first;
limit(1–200, default 50) andbefore(the page's lastid) to page back. - Events are kept for 3 days after being applied, 30 when they failed.
- Requires the
webhooks:managescope, the same as managing webhooks.
Read together with GET /webhooks/{id}/deliveries, it tells you where the chain
stopped: not here, Meta never sent it; here as done but missing at your
endpoint, check the deliveries.