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

  • message.received (with subtypes in payload.type), message.sent, message.delivered, message.read, message.failed
  • instance.connected / warming / disconnected / logged_out / banned
  • qr_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.

Requires the @bzapper/sdk CLI/package

The 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.