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
message.received(with subtypes inpayload.type),message.sent,message.delivered,message.read,message.failedinstance.connected/warming/disconnected/logged_out/bannedqr_code,pairing_code
Account events (no instance_id — delivered to every account webhook):
usage.threshold— reached 80/90/100% of a usage/spend limit for the period (payload:percent,used,included,plan)wallet.low— wallet low while sending in overage (payload:balance_cents)billing.past_due— a charge (top-up/auto top-up) failed (payload:invoice_id)billing.recovered— payment settled
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 locally (Stripe-style relay)
The bZapper CLI ships a webhook relay to your localhost, just like
stripe listen — without exposing any public URL:
# forwards the project's events to your local app (signed)
bzapper listen --forward-to http://localhost:3000/webhooks/bzapper \
--api-key bz_live_...
# fire a test event (e.g. message.received)
bzapper trigger message.received --api-key bz_live_...
listen opens an SSE stream with the API, prints a local signing secret, and,
for each event, POSTs to your --forward-to signing the body with
X-Bzapper-Signature (HMAC-SHA256) — exactly like a real webhook. Validate the
signature with that local secret. It also receives X-Bzapper-Event-Id and
X-Bzapper-Event-Type.
@bzapper/sdk CLI/packageThe CLI ships in the @bzapper/sdk package (the bzapper binary). That package
is currently private ("private": true) — not yet published to npm. Until we
publish it, run the binary from the repo's package (pnpm --filter @bzapper/sdk exec bzapper listen ...); once published, npx @bzapper/sdk listen ... will work
directly. Variables: BZAPPER_API_KEY and BZAPPER_API_URL (default
http://localhost:8080).
Without the CLI: stand up a receiver, register the webhook pointing at it, call
POST /webhooks/{id}/test (or POST /webhooks/trigger), and verify the signature
over the raw body you receive.