Developer docs

Webhooks Guide

Receive real-time batch events from Disbursed with HMAC-signed POSTs to your HTTPS endpoint. Five-attempt retries, replay-resistant signatures, no polling required.

Webhooks let your backend react to batch state changes the moment they happen on BSC — no polling, no GraphQL subscriptions, no chain indexers to maintain. Disbursed POSTs a JSON event to a URL you control whenever something interesting happens to one of your batches.

1. Quick start

  1. Go to Dashboard → Webhooks and create an endpoint. Save the secret it shows you — it's only displayed once.
  2. Subscribe the endpoint to the events you care about (most teams pick batch.confirmed and batch.failed).
  3. Implement the receiver below in your language of choice.
  4. Click Send test event in the dashboard to fire a signed batch.confirmed at your URL and verify everything works.

Endpoints must be HTTPS in production. Plain http:// and private/loopback IPs are blocked by our SSRF guard so we can never be used to probe your internal network.

2. Events

Three batch lifecycle events fire today. Each is sent at-least-once — design your handler to be idempotent on event.id.

EventFired whendata contains
batch.createdA 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.confirmedThe on-chain transaction succeeded. Funds have moved.batch_id, tx_hash, block_number, gas_used
batch.failedThe on-chain transaction reverted or never confirmed within the watch window.batch_id, tx_hash, reason ("revert" | "timeout")

3. Request format

Body

JSON, UTF-8. Top-level shape is identical across every event so you can pattern-match on type:

{
  "id": "evt_018f9c7e-1234-7abc-def0-abcdef012345",
  "type": "batch.confirmed",
  "created_at": "2026-05-14T10:42:13.871Z",
  "data": {
    "batch_id": "bat_018f9c7e-…",
    "tx_hash": "0x9a1b…",
    "block_number": 39482011,
    "gas_used": "1284773"
  }
}

Headers

X-Disbursed-Signaturet=<unix>,v1=<hex> — HMAC-SHA256 over "<timestamp>.<raw body>"
X-Disbursed-TimestampSame unix timestamp (seconds) as inside the signature header.
X-Disbursed-EventThe event type. Same as event.type inside the body.
X-Disbursed-DeliveryUnique delivery id (whd_…). Use for support & log correlation.
X-Disbursed-Attempt1-based attempt number (1 on first try, up to 5 on retries).
User-AgentDisbursed-Webhooks/1.0

4. Verifying the signature

Every request is signed with HMAC-SHA256 using your endpoint's secret as the key. The signed string is the literal concatenation<timestamp>.<raw-body> — note the dot in between, and that the timestamp is the exact value you also receive in the X-Disbursed-Timestamp header.

The verification recipe:

  1. Parse X-Disbursed-Signature as t=<ts>,v1=<hex>.
  2. Reject if |now − ts| exceeds 5 minutes (replay protection).
  3. Compute HMAC_SHA256(secret, ts + "." + raw_body).hex().
  4. Compare against v1 using a constant-time comparison (crypto.timingSafeEqual / hmac.compare_digest).

Read the raw body before any JSON parser touches it. Frameworks like Express, Flask and FastAPI happily re-serialise JSON, which subtly changes the bytes (whitespace, key order) and breaks the HMAC.

5. Code samples

Node.js (Express)

import express from 'express';
import crypto from 'node:crypto';

const app = express();
// IMPORTANT: capture the RAW body. The HMAC is computed over the unparsed
// bytes — if you let Express JSON-decode and re-encode you will get a
// mismatch on whitespace alone.
app.use('/disbursed', express.raw({ type: 'application/json' }));

const SECRET = process.env.DISBURSED_WEBHOOK_SECRET!;
const TOLERANCE_S = 5 * 60;  // accept timestamps within 5 minutes

app.post('/disbursed', (req, res) => {
  const sig = req.header('X-Disbursed-Signature') ?? '';
  const m = /^t=(\d+),v1=([a-f0-9]+)$/.exec(sig);
  if (!m) return res.status(400).send('bad signature header');
  const [, tsStr, v1] = m;
  const ts = parseInt(tsStr, 10);

  // 1. Replay protection — reject anything outside the freshness window.
  if (Math.abs(Date.now() / 1000 - ts) > TOLERANCE_S) {
    return res.status(400).send('timestamp out of range');
  }

  // 2. Recompute the HMAC and compare in constant time.
  const signed = Buffer.from(`${ts}.${req.body.toString('utf8')}`);
  const expected = crypto.createHmac('sha256', SECRET).update(signed).digest('hex');
  const ok =
    v1.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(v1, 'hex'), Buffer.from(expected, 'hex'));
  if (!ok) return res.status(401).send('signature mismatch');

  // 3. Parse the verified payload.
  const event = JSON.parse(req.body.toString('utf8'));

  // 4. Acknowledge IMMEDIATELY (under 10 seconds). Do real work in a queue.
  res.status(200).send('ok');

  // 5. Process. Use event.id (e.g. evt_…) as your idempotency key.
  processAsync(event).catch(console.error);
});

Python (Flask)

import hmac, hashlib, json, os, time, re
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ['DISBURSED_WEBHOOK_SECRET'].encode()
TOLERANCE_S = 5 * 60

SIG_RE = re.compile(r"^t=(\d+),v1=([a-f0-9]+)$")

@app.post("/disbursed")
def disbursed():
    raw = request.get_data()  # MUST be the raw bytes, not request.json
    sig = request.headers.get("X-Disbursed-Signature", "")
    m = SIG_RE.match(sig)
    if not m:
        abort(400, "bad signature header")
    ts, v1 = int(m.group(1)), m.group(2)

    if abs(time.time() - ts) > TOLERANCE_S:
        abort(400, "timestamp out of range")

    signed = f"{ts}.{raw.decode()}".encode()
    expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(v1, expected):
        abort(401, "signature mismatch")

    event = json.loads(raw)
    # Use event["id"] as the idempotency key.
    return "", 200

6. Retries & failure policy

We treat any 2xx response code as success. Anything else — including 4xx, 5xx, network errors and timeouts beyond 10 seconds — is a failure and triggers a retry.

  • Up to 5 delivery attempts per event.
  • Backoff schedule (time since the previous failure):30s → 2 min → 10 min → 1 hour. After attempt 5 the delivery is marked failed.
  • Per-attempt timeout: 10 seconds. If your handler does heavy work, acknowledge with a quick 200 OK first and process the job from a queue.
  • Auto-pause: after 10 consecutive failed deliveries we set the endpoint to failed. Fix the receiver, then re-enable from the dashboard.
  • Every attempt is logged. You can inspect bodies, status codes and timing under Dashboard → Webhooks → Deliveries.

7. Idempotency & ordering

Disbursed guarantees at-least-once delivery, not exactly-once. Network blips and 10 s timeouts can both cause the same event to be redelivered. Use event.id (a UUIDv7 prefixed with evt_) as your idempotency key — store it on first receipt and skip any duplicates.

Events are not guaranteed to arrive in source order. If you need strict ordering, sort by created_at (server-generated ISO-8601 UTC) before applying side effects, and treat older states as superseded.

8. Security checklist

  • HTTPS only. Plain HTTP and private addresses are blocked at send time.
  • Always verify the signature before reading any field of the payload — a missing/invalid HMAC means a 401 from you and the request is dropped.
  • Always check the timestamp against a freshness window (5 minutes recommended). Without this an attacker who captures one delivery can replay it forever.
  • Use constant-time comparison. Standard == on strings leaks bytes through timing. Always use crypto.timingSafeEqual /hmac.compare_digest.
  • Rotate the secret if it ever leaks. The dashboard lets you delete and recreate an endpoint with a fresh secret in one click.
  • Don't trust amounts from webhooks alone. For high-stakes logic, treat the webhook as a notification and reconcile against the on-chain tx_hash via a public RPC before releasing goods or credits.
  • Scope your handler. A webhook endpoint should accept POSTs from Disbursed and nothing else — gate it behind a unique random path or an additional shared header if it lives next to other services.

9. Testing locally

Tunnels like ngrok or cloudflared are the easiest way to expose a localhost handler to Disbursed during development. Once your tunnel URL is live, paste it into the dashboard and use Send test event to fire a real signed delivery — same headers, same HMAC, same retry rules as production.

10. Questions?

The implementation lives in our public repo under api/src/lib/webhooks.ts. If anything in this guide is unclear or seems wrong, please open a GitHub issue — we'd rather fix the docs than have anyone ship a vulnerable handler.