Navigation

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", "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.

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",
    "account_link_id": "link_789",
    "client_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
request-to-link.createdA new link request was created
request_to_link_idclient_user_id+2
request-to-link.updatedA link request status changed
request_to_link_idclient_user_id
account-link.createdAn account was successfully linked
account_link_idclient_user_id+1
account-link.updatedA linked account status changed
account_link_idclient_user_id
link.createdA link session was initiated
link_idlink_session_id
link.updatedA link session state changed
link_idlink_session_id
bill.createdA new bill was discovered
bill_idaccount_link_id+1
bill.updatedA bill was updated
bill_idaccount_link_id
link-session.createdA Connect session started
link_session_id
link-session.updatedA 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

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 to simulate webhook events and test your handler.

Trigger a test webhook event
cURL
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

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

Related