Set Up Webhooks

Webhooks

Receive real-time notifications when events occur in your BillerAPI integration.

Overview

Webhooks allow BillerAPI to push event notifications to your server in real time. Instead of polling for changes, register a webhook URL and we'll send HTTP POST requests when events occur.

Registering a Webhook

cURL
curl -X POST https://sandbox.api.billerapi.com/iam/clients/{clientId}/webhooks \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <jwt_token>" \
  -d '{
    "environment": "sandbox",
    "url": "https://your-server.com/webhooks/billerapi",
    "events": [
      "bill.created", "bill.updated", "bill.deleted",
      "link.completed", "link.disconnected",
      "link_request.created", "link_request.cancelled",
      "onboarding.submitted", "onboarding.completed", "onboarding.failed",
      "payment.observed", "biller.unsupported",
      "customer.message.created", "customer.message.complaint"
    ],
    "secret": "whsec_your_signing_secret"
  }'

Payload Format

Every webhook delivery sends a JSON payload with a consistent envelope structure. The data field contains minimal resource IDs — fetch full details from the API.

The mode field carries the env scope of the originating API key: sandbox, development, or production. It is null when the event was produced by an internal system path with no authenticated caller (scheduled cron, background workers). The field is part of the HMAC-signed body.

Payload Envelope
{
  "event_type": "bill.created",
  "event_id": "evt_abc123",
  "timestamp": "2026-03-27T10:00:00Z",
  "client_id": "client_xyz",
  "mode": "sandbox",
  "data": {
    "bill_id": "bill_456",
    "link_id": "link_789",
    "user_id": "user_123"
  }
}

HTTP Headers

HeaderDescription
X-Webhook-Event-TypeEvent type (e.g., bill.created)
X-Webhook-IdUnique event ID for deduplication
X-Webhook-TimestampISO 8601 timestamp of the event
X-Webhook-Client-IdYour client ID
X-Webhook-SourceAlways billerapi
BillButler-SignatureStripe-style timestamped HMAC-SHA256 (if secret provided). Format: t=<unix_ts>,v1=<hex_hmac>. Signed payload is <unix_ts>.<raw_body>.

Event Reference

Click an event to see its full payload example and recommended handling.

EventDescriptionKey Data
bill.createdA new bill was extracted for a linked account
bill_idlink_id+2
bill.updatedA bill's status changed
bill_idlink_id+3
bill.deletedA bill was deleted
bill_idlink_id
link.completedA link is fully established and ready to retrieve bills
link_iduser_id
link.disconnectedA link became unusable
link_iduser_id+2
link_request.createdA link request was created
request_to_link_iduser_id+1
link_request.cancelledA link request was cancelled before linking completed
request_to_link_iduser_id+1
onboarding.submittedA biller onboarding request was accepted
onboarding_request_id
onboarding.completedA biller onboarding request finished successfully
onboarding_request_idbiller_id
onboarding.failedA biller onboarding request could not be completed
onboarding_request_id
customer.message.createdA message to a customer was accepted
message_idcustomer_user_aid+2
customer.message.complaintA customer filed a spam complaint against a message you sent
message_idcustomer_user_aid+2
payment.observedA historical payment was observed on the biller portal
iduser_id+4
biller.unsupportedA biller was marked unsupported by the platform
biller_id
webhook.replay-requestedAn operator triggered a manual replay of a dead-lettered webhook
id

Event Details

Webhook Security

If you provide a secret when registering your webhook, every delivery includes a Stripe-compatible timestamped HMAC-SHA256 signature in theBillButler-Signature header. The header value is t=<unix_ts>,v1=<hex_hmac>, and the signed payload is <unix_ts>.<raw_body>. Reject any request older than 5 minutes to defend against replay attacks. Always verify the signature before processing events.

Verify webhook signature
import crypto from 'crypto';

const TOLERANCE_SECONDS = 300; // 5-minute replay window

function parseSignatureHeader(header) {
  // Format: "t=<unix_ts>,v1=<hex_hmac>"
  let timestamp = null;
  let signature = null;
  for (const part of header.split(',')) {
    const [key, value] = part.trim().split('=', 2);
    if (key === 't') timestamp = parseInt(value, 10);
    else if (key === 'v1') signature = value;
  }
  if (!timestamp || !signature) return null;
  return { timestamp, signature };
}

function verifyWebhookSignature(rawBody, header, secret) {
  const parsed = parseSignatureHeader(header || '');
  if (!parsed) return false;

  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parsed.timestamp) > TOLERANCE_SECONDS) {
    return false; // expired or replayed
  }

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${parsed.timestamp}.${rawBody}`, 'utf8')
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(expected, 'hex'),
    Buffer.from(parsed.signature, 'hex'),
  );
}

// In your webhook handler — IMPORTANT: pass the raw request body, not JSON.parse(body):
app.post('/webhooks/billerapi',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const isValid = verifyWebhookSignature(
      req.body.toString('utf8'),
      req.headers['billbutler-signature'],
      process.env.WEBHOOK_SECRET,
    );

    if (!isValid) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    const event = JSON.parse(req.body.toString('utf8'));
    // Process the event...
    res.status(200).json({ received: true });
  });

Always verify signatures

Without signature verification, an attacker could forge webhook payloads to your endpoint. Use a timing-safe comparison to prevent timing attacks.

Retry Policy

If your endpoint returns a non-2xx status code or doesn't respond within 10 seconds, we retry with exponential backoff.

  • Your endpoint must respond within 10 seconds
  • Return any 2xx status to acknowledge receipt
  • Failed deliveries are retried with exponential backoff
  • Use the X-Webhook-Id header for deduplication

Tip

Process webhooks asynchronously. Acknowledge receipt immediately with a 200 response, then process the event in a background job to avoid timeout issues.

Testing with Sandbox

Use the sandbox trigger endpoint — POST /v1/triggers/{event_type} — to simulate webhook events and test your handler. The event type goes in the path; the request body becomes the event’s data. The synthetic event is delivered to every sandbox webhook subscription you’ve registered for that client (no webhook_url in the request).

Trigger a test webhook event
cURL
curl -X POST https://sandbox.api.billerapi.com/v1/triggers/bill.created \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $BILLERAPI_API_KEY" \
  -d '{
    "bill_id": "bill_sandbox_123",
    "amount_due": 4200,
    "currency": "usd"
  }'

Note

Sandbox webhook delivery includes all the same headers as production, including HMAC-SHA256 signatures when you provide a secret.

Webhooks vs. Sync

Webhooks are the doorbell: a low-latency nudge that something changed. The bill sync endpoint is the source of truth: a Plaid-style delta you can replay to reconcile state after a missed, dropped, or out-of-order webhook. Treat webhooks as a hint to sync sooner — never as your only path to correctness. In both cases the dedupe key is bill_id: upsert on it so a webhook and a sync that describe the same bill converge.

GET /v1/account-links/:link_id/bills/sync returns three buckets — added, modified, and removed — plus a next_cursor and has_more flag. The canonical loop:

  • Omit the cursor on the first sync — that pulls full history from zero.
  • Loop while has_more is true, passing each response's next_cursor back in.
  • Persist the cursor only after has_more is false — that is the durable high-water mark for the next sync.
  • Upsert every bill by bill_id; apply removed as deletes/cancellations.
Drain the sync loop, persist the cursor at the end
// cursor is null on the very first sync (full history from zero).
let cursor = await loadCursor(linkId); // null or your stored high-water mark
let hasMore = true;

while (hasMore) {
  const params = new URLSearchParams({ limit: '200' });
  if (cursor) params.set('cursor', cursor);

  const res = await fetch(
    `https://sandbox.api.billerapi.com/v1/account-links/${linkId}/bills/sync?${params}`,
    {
      headers: {
        'Authorization': 'Bearer $BILLERAPI_API_KEY',
      },
    },
  );

  if (!res.ok) {
    const error = await res.json();
    // INVALID_CURSOR → your stored cursor is corrupt. Drop it and re-sync
    // from zero (omit the cursor); a full sync is idempotent via bill_id.
    if (error.error_code === 'INVALID_CURSOR') {
      cursor = null;
      continue;
    }
    throw new Error(error.error_message);
  }

  const { data } = await res.json();

  // Dedupe key is bill_id — upsert added + modified, delete removed.
  for (const bill of [...data.added, ...data.modified]) upsertBill(bill);
  for (const bill of data.removed) removeBill(bill.id);

  cursor = data.next_cursor;
  hasMore = data.has_more;
}

// Persist ONLY after the drain completes (has_more === false).
await saveCursor(linkId, cursor);

Errors

error_codeHTTPMeaning & recovery
INVALID_CURSOR400The supplied cursor is malformed or garbled. Drop your stored cursor and re-sync from zero (omit the cursor) — a full sync is idempotent via bill_id.

Webhook arrives → sync, don't trust the payload as final

When you receive a bill.created or bill.updated webhook, kick off a sync for that link rather than treating the webhook body as the complete, final state. The sync reconciles anything the doorbell missed and is safe to run repeatedly.

Related

Was this page helpful?