Webhooks let your backend react to batch state changes the moment they happen on BSC — no polling, no GraphQL subscriptions, and no chain indexers to maintain yourself. Disbursed POSTs a JSON event to an HTTPS URL you control whenever something interesting happens to one of your batches.
Quickstart
- Go to Dashboard → Webhooks and create an endpoint. Save the signing secret — it is only shown once.
- Subscribe the endpoint to the events you care about (most teams pick
batch.confirmedandbatch.failed). - Implement the verifier in your language of choice using the recipe below.
- Click Send test event in the dashboard to deliver a signed
batch.confirmedand confirm your handler returns2xxpromptly.
Events
Three batch lifecycle events ship today. Payloads are UTF-8 JSON and the envelope is identical for every type so you can branch on event.type.
| Event | Fired when | data contains |
|---|---|---|
batch.created | A new batch has been recorded and is awaiting on-chain confirmation. | batch_id, tx_hash, token_address, token_symbol, total_amount (wei), recipient_count |
batch.confirmed | The on-chain transaction succeeded — funds have moved. | batch_id, tx_hash, block_number, gas_used |
batch.failed | The on-chain transaction reverted or never confirmed within the watch window. | batch_id, tx_hash, reason ("revert" | "timeout") |
Request format
JSON, UTF-8. The top-level envelope is shared by every event:
Field guide
Hover/long-press highlighted keys inside your editor while iterating — this rail mirrors JSON fields for rapid lookup.
- id
- Unique UUIDv7 (evt_*); treat as canonical idempotency key.
- type
- Emitted after the indexer observes a mined success receipt.
- created_at
- Timestamp when Disbursed marked the watcher success.
- batch_id
- Batch identifier surfaced across exports + dashboards.
- tx_hash
- BSC consensus hash for the disbursement txn.
- block_number
- Block carrying the disbursement txn.
- gas_used
- Actual gas burned for execution.
Headers Disbursed mirrors key metadata on the wire for easier logging and correlation.
| Header | Value |
|---|---|
X-Disbursed-Signature | t=<unix>,v1=<hex> — HMAC-SHA256 over "<timestamp>.<raw body>" |
X-Disbursed-Timestamp | Same unix timestamp (seconds) referenced in the signature header. |
X-Disbursed-Event | The event type; identical to event.type in the JSON body. |
X-Disbursed-Delivery | Unique delivery id (whd_…). Use for support tickets and log correlation. |
X-Disbursed-Attempt | 1-based attempt counter (1 on the first try, up to 5 on retries). |
User-Agent | Disbursed-Webhooks/1.0 |
Verifying the signature
Every request is signed with HMAC-SHA256 using your endpoint's secret. The signed material is the literal string <timestamp>.<raw-body> — note the dot delimiter. The timestamp matches X-Disbursed-Timestamp and the t= parameter inside X-Disbursed-Signature.
Verification recipe
- Parse
X-Disbursed-Signatureast=<ts>,v1=<hex>. - Reject the request if
|now − ts|exceeds five minutes (replay protection). - Compute
HMAC_SHA256(secret, ts + "." + raw_body).hexdigest(). - Compare the digest to
v1with a constant-time helper (crypto.timingSafeEqual,hmac.compare_digest,subtle.ConstantTimeCompare, etc.).
Code samples
Each sample keeps the raw payload bytes intact, rejects stale timestamps, compares digests in constant time, parses JSON only after verification, and returns 2xx before doing slow work asynchronously.
Retries and failure policy
Successful delivery requires any 2xx response within ten seconds. Everything else — 4xx, 5xx, network failure, TLS problems, or exceeding the timeout — counts as failure and schedules a retry.
Attempts
Disbursed retries up to five times per delivery id before marking the event as failed.
Backoff
Scheduling after each failure: ≈30 seconds → 2 minutes → 10 minutes → 60 minutes between attempts.
Timeout
Every attempt must receive a 2xx acknowledgement within 10 seconds or it is treated as a failure.
Auto-pause
Ten consecutive endpoint failures flip the endpoint status to failed—fix the handler, then re-enable in the dashboard.
After each attempt you can inspect the stored body, response code, and timing under Dashboard → Webhooks → Deliveries.
Idempotency and ordering
Disbursed guarantees at-least-once delivery rather than exactly-once. Short network blips and handler timeouts can both cause the exact same logical event (same event.id) to arrive twice. Persist the id the first time you observe it (evt_* prefixes a UUIDv7) and safely ignore duplicates thereafter.
Because deliveries can be replayed out of arrival order, treat created_at (ISO-8601 UTC emitted by Disbursed) as the canonical ordering signal when collapsing state transitions.
Security checklist
- Serve production webhooks strictly over HTTPS—plain HTTP and private addresses fail validation before dispatch.
- Verify the Disbursed HMAC on every inbound request before interpreting `event` fields.
- Reject timestamps outside of a ±5‑minute freshness window so captured deliveries cannot be replayed forever.
- Compare digests using constant‑time primitives (`timingSafeEqual`, `hash_equals`, `subtle.ConstantTimeCompare`, …).
- Rotate webhook secrets promptly if exposure is suspected—recreating an endpoint issues a fresh key instantly.
- Treat payloads as hints: reconcile high-impact business logic against on-chain confirmations via `tx_hash` when warranted.
- Keep listener routes dedicated and hard to guess; avoid multiplexing webhook traffic inside generic POST handlers.
Testing locally
ngrok http 3000Forward the HTTPS URL shown in ngrok or cloudflared into the dashboard webhook form, then confirm deliveries land on your machine with valid signatures before shipping to staging.
Community
Questions, clarifications, or suspected doc bugs belong in GitHub (open an issue). The webhook sender lives in-repo at api/src/lib/webhooks.ts if you prefer reading implementation details directly.