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", "account-link.created"],
"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 (added 2026-05-19, issue #2448) 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). Filter on this just as you would on Stripe's livemode. The field is part of the HMAC-signed body; existing integrators ignoring unknown fields are unaffected.
{
"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",
"account_link_id": "link_789",
"client_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 |
|---|---|---|
| request-to-link.created | A new link request was created | request_to_link_idclient_user_id+2 |
| request-to-link.updated | A link request status changed | request_to_link_idclient_user_id |
| account-link.created | An account was successfully linked | account_link_idclient_user_id+1 |
| account-link.updated | A linked account status changed | account_link_idclient_user_id |
| link.created | A link session was initiated | link_idlink_session_id |
| link.updated | A link session state changed | link_idlink_session_id |
| bill.created | A new bill was discovered | bill_idaccount_link_id+1 |
| bill.updated | A bill was updated | bill_idaccount_link_id |
| link-session.created | A Connect session started | link_session_id |
| link-session.updated | A Connect session progressed | link_session_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 to simulate webhook events and test your handler.
Trigger a test webhook event
curl -X POST https://sandbox.api.billerapi.com/sandbox/trigger-event \
-H "Content-Type: application/json" \
-H "X-Client-ID: your_client_id" \
-H "X-Client-Secret: your_client_secret" \
-d '{
"event_type": "bill.created",
"client_id": "your_client_id",
"webhook_url": "https://your-server.com/webhooks/billerapi",
"webhook_secret": "whsec_your_signing_secret"
}'Note