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 -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.
{
"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
| Header | Description |
|---|---|
| X-Webhook-Event-Type | Event type (e.g., bill.created) |
| X-Webhook-Id | Unique event ID for deduplication |
| X-Webhook-Timestamp | ISO 8601 timestamp of the event |
| X-Webhook-Client-Id | Your client ID |
| X-Webhook-Source | Always billerapi |
| BillButler-Signature | Stripe-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.
| Event | Description | Key Data |
|---|---|---|
| bill.created | A new bill was extracted for a linked account | bill_idlink_id+2 |
| bill.updated | A bill's status changed | bill_idlink_id+3 |
| bill.deleted | A bill was deleted | bill_idlink_id |
| link.completed | A link is fully established and ready to retrieve bills | link_iduser_id |
| link.disconnected | A link became unusable | link_iduser_id+2 |
| link_request.created | A link request was created | request_to_link_iduser_id+1 |
| link_request.cancelled | A link request was cancelled before linking completed | request_to_link_iduser_id+1 |
| onboarding.submitted | A biller onboarding request was accepted | onboarding_request_id |
| onboarding.completed | A biller onboarding request finished successfully | onboarding_request_idbiller_id |
| onboarding.failed | A biller onboarding request could not be completed | onboarding_request_id |
| customer.message.created | A message to a customer was accepted | message_idcustomer_user_aid+2 |
| customer.message.complaint | A customer filed a spam complaint against a message you sent | message_idcustomer_user_aid+2 |
| payment.observed | A historical payment was observed on the biller portal | iduser_id+4 |
| biller.unsupported | A biller was marked unsupported by the platform | biller_id |
| webhook.replay-requested | An 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
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-Idheader for deduplication
Tip
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 -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
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_moreis true, passing each response'snext_cursorback in. - Persist the cursor only after
has_moreis false — that is the durable high-water mark for the next sync. - Upsert every bill by
bill_id; applyremovedas 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_code | HTTP | Meaning & recovery |
|---|---|---|
| INVALID_CURSOR | 400 | The 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
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.