# 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.

For Elements flows, use the [webhook confirmations guide](/docs/guides/webhook-confirmations) to decide which link, link-token, and `pay.*` events should update your server-side state.

### Registering a Webhook

**cURL**

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

> **Note — Event names are validated on write**
>
> `events` must name event types we can actually deliver — the list above, plus `*` or `all` to subscribe to everything. Anything else is rejected with a `400` (`error_code` `VALIDATION_ERROR`, `errors[].code` `unknown_event_type`) rather than stored as a subscription that can never fire.
>
> Historic names are still accepted and are **normalized to the form they are delivered under**: subscribing to `bill.paid` stores `bill.updated` (with `data.change` telling you which kind of change it was), `link.expired` stores `link.disconnected`, and `request-to-link.updated` stores `link_request.updated`. The create/update response echoes the stored array, so you always see exactly what was saved.

## 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. Every delivery is also visible in the portal's event inspector.

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**

```json
{
  "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` |
| `BillerAPI-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_id`, `link_id`, `biller_id`, `user_id` |
| `bill.updated` | A bill's status changed | `bill_id`, `link_id`, `biller_id`, `status`, `change` |
| `bill.deleted` | A bill was deleted | `bill_id`, `link_id` |
| `link.completed` | A link is fully established and ready to retrieve bills | `link_id`, `user_id` |
| `link.disconnected` | A link became unusable | `link_id`, `user_id`, `reason`, `credential_rejection_reason?` |
| `link_request.created` | A link request was created | `request_to_link_id`, `user_id`, `biller_id` |
| `link_request.updated` | A link request changed after creation | `request_to_link_id`, `user_id` |
| `link_request.cancelled` | A link request was cancelled before linking completed | `request_to_link_id`, `user_id`, `reason?` |
| `customer.message.created` | A message to a customer was accepted | `message_id`, `customer_user_aid`, `biller_id`, `category` |
| `customer.message.complaint` | A customer filed a spam complaint against a message you sent | `message_id`, `customer_user_aid`, `biller_id`, `reason` |
| `payment.observed` | A historical payment was observed on the biller portal | `id`, `user_id`, `biller_id`, `amount`, `payment_date`, `status` |
| `biller.unsupported` | A biller was marked unsupported by the platform | `biller_id` |

### Event Details

#### `bill.created` — A new bill was extracted for a linked account

**Fires when**

Bill sync finds a new bill for a linked account

**Data fields**

`bill_id`, `link_id`, `biller_id`, `user_id`

**Example payload**

```json
{
  "event_type": "bill.created",
  "event_id": "evt_abc123",
  "timestamp": "2026-03-27T10:30:00Z",
  "client_id": "client_xyz",
  "data": {
    "bill_id": "bill_xyz",
    "link_id": "link_def",
    "biller_id": "sb_utility",
    "user_id": "user_123"
  }
}
```

**Recommended action**

Fetch full bill details from GET /v1/bills/:id using your access token.

#### `bill.updated` — A bill's status changed

**Fires when**

Bill status changes (paid, partially paid, status reverted, statement refreshed, match status changed). Check the `change` field on data to branch: `paid`, `partially_paid`, `status_reverted`, `statement_refreshed`, or `match_status`.

**Data fields**

`bill_id`, `link_id`, `biller_id`, `status`, `change`

**Example payload**

```json
{
  "event_type": "bill.updated",
  "event_id": "evt_def456",
  "timestamp": "2026-03-27T10:35:00Z",
  "client_id": "client_xyz",
  "data": {
    "bill_id": "bill_xyz",
    "link_id": "link_def",
    "biller_id": "sb_utility",
    "status": "PAID",
    "change": "paid",
    "paid_reason": "STATEMENT_CREDIT",
    "paid_at": "2026-03-27T10:35:00Z"
  }
}
```

**Recommended action**

Branch on `change`: for `paid`/`partially_paid` update your paid-state UI; for `status_reverted` surface a prominent warning (PAID is not terminal); for `statement_refreshed` re-fetch the bill for new balance/due date; for `match_status` update your match-state display.

#### `bill.deleted` — A bill was deleted

**Fires when**

A bill record is removed from the platform

**Data fields**

`bill_id`, `link_id`

**Example payload**

```json
{
  "event_type": "bill.deleted",
  "event_id": "evt_ghi789",
  "timestamp": "2026-03-27T10:40:00Z",
  "client_id": "client_xyz",
  "data": {
    "bill_id": "bill_xyz",
    "link_id": "link_def"
  }
}
```

**Recommended action**

Record the deletion. A follow-up GET /v1/bills/:id will return 404 — acknowledge with 200.

#### `link.completed` — A link is fully established and ready to retrieve bills

**Fires when**

A user completes the Connect flow and a link is established

**Data fields**

`link_id`, `user_id`

**Example payload**

```json
{
  "event_type": "link.completed",
  "event_id": "evt_jkl012",
  "timestamp": "2026-03-27T10:10:00Z",
  "client_id": "client_xyz",
  "data": {
    "link_id": "link_def",
    "user_id": "user_123"
  }
}
```

**Recommended action**

Store the link_id. You can now fetch bills for this account via GET /v1/bills.

#### `link.disconnected` — A link became unusable

**Fires when**

A link expires, credentials are rejected, or the user/biller revokes access. Check the `reason` field: `expired` (user can reconnect), `credentials_rejected` (user must re-enter credentials), `revoked` (terminal — prompt re-link).

**Data fields**

`link_id`, `user_id`, `reason`, `credential_rejection_reason?`

**Example payload**

```json
{
  "event_type": "link.disconnected",
  "event_id": "evt_mno345",
  "timestamp": "2026-03-27T10:15:00Z",
  "client_id": "client_xyz",
  "data": {
    "link_id": "link_def",
    "user_id": "user_123",
    "reason": "credentials_rejected",
    "credential_rejection_reason": "INVALID_CREDENTIALS"
  }
}
```

**Recommended action**

Branch on `reason`. For `expired`/`credentials_rejected`: prompt the user to update credentials or reconnect. For `revoked`: treat the link as permanently gone and offer a new Connect flow.

#### `link_request.created` — A link request was created

**Fires when**

A client creates a link request for a user and the biller has been identified

**Data fields**

`request_to_link_id`, `user_id`, `biller_id`

**Example payload**

```json
{
  "event_type": "link_request.created",
  "event_id": "evt_pqr678",
  "timestamp": "2026-03-27T10:00:00Z",
  "client_id": "client_xyz",
  "data": {
    "request_to_link_id": "rtl_456",
    "user_id": "user_123",
    "biller_id": "sb_utility"
  }
}
```

**Recommended action**

Track the link request in your system. A matching `link.completed` will follow when the user finishes the Connect flow.

#### `link_request.updated` — A link request changed after creation

**Fires when**

Requested biller onboarding resolves or fails

**Data fields**

`request_to_link_id`, `user_id`

**Example payload**

```json
{
  "event_type": "link_request.updated",
  "event_id": "evt_rst789",
  "timestamp": "2026-03-27T10:10:00Z",
  "client_id": "client_xyz",
  "data": {
    "request_to_link_id": "rtl_456",
    "user_id": "user_123"
  }
}
```

**Recommended action**

Refresh the request-to-link resource. Start Connect only when can_start_connect is true.

#### `link_request.cancelled` — A link request was cancelled before linking completed

**Fires when**

The link request reaches a non-successful terminal state

**Data fields**

`request_to_link_id`, `user_id`, `reason?`

**Example payload**

```json
{
  "event_type": "link_request.cancelled",
  "event_id": "evt_stu901",
  "timestamp": "2026-03-27T10:05:00Z",
  "client_id": "client_xyz",
  "data": {
    "request_to_link_id": "rtl_456",
    "user_id": "user_123",
    "reason": "user_cancelled"
  }
}
```

**Recommended action**

Mark the link request as cancelled in your projection. Offer the user a retry if appropriate.

#### `customer.message.created` — A message to a customer was accepted

**Fires when**

A client sends a message to a customer via POST /v1/customers/{user_aid}/messages

**Data fields**

`message_id`, `customer_user_aid`, `biller_id`, `category`

**Example payload**

```json
{
  "event_type": "customer.message.created",
  "event_id": "evt_efg123",
  "timestamp": "2026-05-31T14:30:00Z",
  "client_id": "client_xyz",
  "data": {
    "message_id": "msg_01J3...",
    "customer_user_aid": "user_42",
    "biller_id": "sb_utility",
    "category": "account_update",
    "created_at": "2026-05-31T14:30:00Z"
  }
}
```

**Recommended action**

Store the message_id for tracking. No delivery action needed — the platform handles push notification and in-app inbox delivery.

#### `customer.message.complaint` — A customer filed a spam complaint against a message you sent

**Fires when**

A customer taps Report on a customer.message.created in their BillEBox inbox

**Data fields**

`message_id`, `customer_user_aid`, `biller_id`, `reason`

**Example payload**

```json
{
  "event_type": "customer.message.complaint",
  "event_id": "evt_hij456",
  "timestamp": "2026-05-31T14:35:00Z",
  "client_id": "client_xyz",
  "data": {
    "message_id": "msg_01J3...",
    "customer_user_aid": "user_42",
    "biller_id": "sb_utility",
    "reason": "spam",
    "complained_at": "2026-05-31T14:35:00Z"
  }
}
```

**Recommended action**

Mirror the suppression in your CRM — do not contact this customer via any channel (email, SMS, mail) for marketing purposes. BillerAPI has already added them to your suppression list.

#### `payment.observed` — A historical payment was observed on the biller portal

**Fires when**

Bill sync discovers a payment record in the biller's Payment History

**Data fields**

`id`, `user_id`, `biller_id`, `amount`, `payment_date`, `status`

**Example payload**

```json
{
  "event_type": "payment.observed",
  "event_id": "evt_klm789",
  "timestamp": "2026-04-29T14:30:00Z",
  "client_id": "client_xyz",
  "data": {
    "id": "opay_01HX5...",
    "user_id": "user_123",
    "biller_id": "sb_utility",
    "account_link_id": "alink_01HX...",
    "amount": "123.45",
    "currency": "USD",
    "payment_date": "2026-04-15",
    "status": "POSTED",
    "source": "SCRAPED"
  }
}
```

**Recommended action**

Use the stable `id` for deduplication. No ordering guarantee with bill.created — tolerate either arrival order.

#### `biller.unsupported` — A biller was marked unsupported by the platform

**Fires when**

The biller's website changes in a way that makes automation impossible

**Data fields**

`biller_id`

**Example payload**

```json
{
  "event_type": "biller.unsupported",
  "event_id": "evt_nop012",
  "timestamp": "2026-04-21T12:00:00Z",
  "client_id": "client_xyz",
  "data": {
    "biller_id": "sb_utility"
  }
}
```

**Recommended action**

Prompt affected users to manage this biller manually until the integration is restored. Consider disabling automated bill-fetch for this biller in your UI.


## Webhook Security

If you provide a `secret` when registering your webhook, every delivery includes a Stripe-compatible timestamped HMAC-SHA256 signature in the `BillerAPI-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.

**The header can carry more than one `v1`.** While a rotated secret is inside its grace window (see `POST /v1/webhook-endpoints/{id}/rotate-secret` and its `previous_secret_expires_at`), deliveries are signed with every live secret at once:

```
BillerAPI-Signature: t=<unix_ts>,v1=<sig_new_secret>,v1=<sig_previous_secret>
```

Each entry signs the same payload with a different secret, so the one secret you hold matches exactly one entry; that is what lets you move to the new secret at your own pace instead of on a flag day. After the window passes, deliveries carry a single `v1` again.

So **parse every `v1` and accept if any of them matches**. A verifier that keeps only one entry works right up until your first rotation and then rejects live traffic, because which entry it kept has nothing to do with which secret you hold. Every example below does this.

**Verify webhook signature**

**JavaScript**

````javascript
import crypto from 'crypto';

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

function parseSignatureHeader(header) {
  // Format: "t=<unix_ts>,v1=<hex_hmac>[,v1=<hex_hmac>...]"
  let timestamp = null;
  // Collect EVERY v1 — a rotation grace window emits one per live secret.
  const signatures = [];
  for (const part of header.split(',')) {
    const trimmed = part.trim();
    const eq = trimmed.indexOf('=');
    if (eq === -1) continue;
    const key = trimmed.slice(0, eq);
    const value = trimmed.slice(eq + 1);
    if (key === 't') timestamp = parseInt(value, 10);
    else if (key === 'v1' && value) signatures.push(value);
  }
  if (!timestamp || signatures.length === 0) return null;
  return { timestamp, signatures };
}

function constantTimeHexEqual(a, b) {
  if (a.length !== b.length) return false;
  try {
    return crypto.timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'));
  } catch {
    return false;
  }
}

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');

  // Accept if OUR secret matches ANY entry — during a rotation the other
  // entries belong to a secret we do not hold.
  return parsed.signatures.some((sig) => constantTimeHexEqual(expected, sig));
}

// 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['billerapi-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 });
  });
````

**Python**

```python
import hmac
import hashlib
import time

TOLERANCE_SECONDS = 300  # 5-minute replay window

def parse_signature_header(header: str):
    # Format: "t=<unix_ts>,v1=<hex_hmac>[,v1=<hex_hmac>...]"
    timestamp = None
    # Collect EVERY v1 — a rotation grace window emits one per live secret.
    signatures = []
    for part in header.split(','):
        key, _, value = part.strip().partition('=')
        if key == 't':
            try:
                timestamp = int(value)
            except ValueError:
                return None
        elif key == 'v1' and value:
            signatures.append(value)
    if timestamp is None or not signatures:
        return None
    return timestamp, signatures

def verify_webhook_signature(raw_body: bytes, header: str, secret: str) -> bool:
    parsed = parse_signature_header(header or '')
    if not parsed:
        return False
    timestamp, signatures = parsed

    if abs(int(time.time()) - timestamp) > TOLERANCE_SECONDS:
        return False  # expired or replayed

    signed_payload = f"{timestamp}.{raw_body.decode('utf-8')}".encode('utf-8')
    expected = hmac.new(
        secret.encode('utf-8'),
        signed_payload,
        hashlib.sha256,
    ).hexdigest()

    # Accept if OUR secret matches ANY entry (rotation grace window).
    return any(hmac.compare_digest(expected, sig) for sig in signatures)

# In your webhook handler — IMPORTANT: read the raw body, not the parsed JSON:
@app.post('/webhooks/billerapi')
async def handle_webhook(request: Request):
    raw_body = await request.body()
    header = request.headers.get('billerapi-signature', '')

    if not verify_webhook_signature(raw_body, header, WEBHOOK_SECRET):
        raise HTTPException(status_code=401, detail='Invalid signature')

    event = json.loads(raw_body)
    # Process the event...
    return {'received': True}
```

**Go**

```go
import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"strconv"
	"strings"
	"time"
)

const toleranceSeconds = 300 // 5-minute replay window

func parseSignatureHeader(header string) (int64, []string, bool) {
	// Format: "t=<unix_ts>,v1=<hex_hmac>[,v1=<hex_hmac>...]"
	var ts int64
	// Collect EVERY v1 — a rotation grace window emits one per live secret.
	var sigs []string
	for _, part := range strings.Split(header, ",") {
		kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
		if len(kv) != 2 {
			continue
		}
		switch kv[0] {
		case "t":
			parsed, err := strconv.ParseInt(kv[1], 10, 64)
			if err == nil {
				ts = parsed
			}
		case "v1":
			if kv[1] != "" {
				sigs = append(sigs, kv[1])
			}
		}
	}
	if ts == 0 || len(sigs) == 0 {
		return 0, nil, false
	}
	return ts, sigs, true
}

func verifyWebhookSignature(rawBody []byte, header, secret string) bool {
	ts, sigs, ok := parseSignatureHeader(header)
	if !ok {
		return false
	}
	now := time.Now().Unix()
	if (now - ts) > toleranceSeconds || (ts - now) > toleranceSeconds {
		return false // expired or replayed
	}

	signedPayload := []byte(fmt.Sprintf("%d.%s", ts, rawBody))
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write(signedPayload)
	expected := hex.EncodeToString(mac.Sum(nil))

	expectedBytes, err := hex.DecodeString(expected)
	if err != nil {
		return false
	}
	// Accept if OUR secret matches ANY entry (rotation grace window).
	for _, sig := range sigs {
		receivedBytes, err := hex.DecodeString(sig)
		if err != nil {
			continue
		}
		if hmac.Equal(expectedBytes, receivedBytes) {
			return true
		}
	}
	return false
}
```

**curl + openssl**

```bash
# Quick sanity check from the command line — given a captured webhook
# request, verify the signature against your stored secret. Useful when
# debugging a "signature mismatch" report from a customer.

# 1. Pull the timestamp + EVERY signature out of the header value. There is
#    more than one v1 during a secret-rotation grace window, so collect them
#    all rather than assuming a single line.
SIG_HEADER='t=1700000000,v1=abcdef0123...'   # value of BillerAPI-Signature
TIMESTAMP=$(echo "$SIG_HEADER" | tr ',' '\n' | awk -F= '$1=="t"{print $2}')
RECEIVED_SIGS=$(echo "$SIG_HEADER" | tr ',' '\n' | awk -F= '$1=="v1"{print $2}')

# 2. RAW_BODY must be exactly what we received — no re-serialization, no
#    trimming. If your framework parsed it to JSON, capture the raw bytes
#    before parsing.
RAW_BODY='{"id":"evt_123","type":"link.completed",...}'
SECRET='whsec_...'

# 3. The signed payload is "<unix_ts>.<raw_body>".
EXPECTED=$(printf '%s.%s' "$TIMESTAMP" "$RAW_BODY" \
  | openssl dgst -sha256 -hmac "$SECRET" -hex \
  | awk '{print $2}')

# 4. Accept if EXPECTED matches ANY of the received v1 entries. Use
#    [ "$X" = "$Y" ] (shell string equality) for sanity checks only —
#    production code MUST use a timing-safe comparison (see the
#    JavaScript / Python / Go examples).
if echo "$RECEIVED_SIGS" | grep -qxF "$EXPECTED"; then
  echo "signature ok"
else
  echo "signature mismatch: expected=$EXPECTED got=$(echo "$RECEIVED_SIGS" | tr '\n' ' ')"
fi

# 5. Also reject if (now - TIMESTAMP) > 300 seconds.
```

> **Warning — 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
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**

**Node**

````javascript
// 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);
````

**Python**

```python
import requests

cursor = load_cursor(link_id)  # None on the first sync (full history from zero)
has_more = True

while has_more:
    params = {'limit': 200}
    if cursor:
        params['cursor'] = cursor

    res = requests.get(
        f'https://sandbox.api.billerapi.com/v1/account-links/{link_id}/bills/sync',
        params=params,
        headers={
            'Authorization': 'Bearer $BILLERAPI_API_KEY',
        },
    )

    if res.status_code == 400 and res.json().get('error_code') == 'INVALID_CURSOR':
        # Stored cursor is corrupt — drop it and re-sync from zero.
        cursor = None
        continue
    res.raise_for_status()

    data = res.json()['data']

    # Dedupe key is bill_id — upsert added + modified, delete removed.
    for bill in data['added'] + data['modified']:
        upsert_bill(bill)
    for bill in data['removed']:
        remove_bill(bill['id'])

    cursor = data['next_cursor']
    has_more = data['has_more']

# Persist ONLY after the drain completes (has_more is False).
save_cursor(link_id, 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`. |

> **Tip — 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

- [Webhooks API Reference](/docs/api/webhooks) — Registration and management endpoints
- [Bills API Reference](/docs/api/bills) — Fetch bill details from webhook data
- [Getting Started](/docs/guides/getting-started) — End-to-end integration walkthrough
- [Authentication Guide](/docs/guides/authentication) — JWT tokens for webhook registration
