# Rate Limits

BillerAPI enforces rate limits to ensure fair usage and platform stability. A default limit covers the API data plane, with a few sensitive auth and security endpoints carrying tighter per-route limits. All limits are applied **per API key**, so your traffic is never affected by other developers.

## The limits

| Scope | Limit | Window |
| --- | --- | --- |
| Default (all data-plane endpoints, per API key) | 100 requests | Per 60 seconds |
| Sensitive auth/security endpoints (sign-in, password reset, email verification, sign-up, etc.) | 5–10 requests | Per 60 seconds |

Each window is a fixed 60-second window that resets once it elapses. The default 100/60s limit applies to the API data plane and is not split by read vs. write. A small set of sensitive authentication and security endpoints (such as sign-in, password reset, and email verification) carry tighter per-route limits to protect your account. The limits are the same in sandbox and production.

Requests are counted **per API key** (your client ID). Requests that arrive without credentials are counted per source IP address instead. If you need a higher limit, contact us at `support@billerapi.com`.

## Rate limit headers

**Every** response — not just 429s — carries your current budget so you can throttle yourself before you ever hit the limit:

| Header | Meaning |
| --- | --- |
| `X-RateLimit-Limit` | Maximum requests allowed in the window (currently `100`). |
| `X-RateLimit-Remaining` | Requests you have left in the current window. |
| `X-RateLimit-Reset` | Seconds until the current window resets and your budget refills. |
| `Retry-After` | Only on a `429` — seconds to wait before retrying. |

A successful response looks like this:

```http
HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 97
X-RateLimit-Reset: 42
Content-Type: application/json
```

### When you exceed the limit

The API returns `429 Too Many Requests` with the `RATE_LIMITED` error code. The seconds to wait before retrying are surfaced two ways: the `Retry-After` response header *and* a numeric `retry_after` field in the JSON error body. Prefer the body field; fall back to the header.

```http
HTTP/1.1 429 Too Many Requests
Retry-After: 42
Content-Type: application/json

{
    "error_code": "RATE_LIMITED",
    "error_type": "rate_limit",
    "error_message": "ThrottlerException: Too Many Requests",
    "hint": "You have exceeded the allowed request rate. Back off and retry after the window in the retry_after field / Retry-After header.",
    "docs_url": "https://docs.billerapi.com/errors/RATE_LIMITED",
    "request_id": "d94f5e2a-8c3b-4f1e-9a7d-6b2c1e0f8a34",
    "retry_after": 42
}
```

Quote the `request_id` in any support request. See [Error Handling](/docs/concepts/errors) for the full error envelope.

## Backoff strategy

When you get a 429, wait the number of seconds in `retry_after` (or the `Retry-After` header) before retrying. If neither is present, fall back to exponential backoff with jitter. Never retry immediately.

**Respect retry_after, with exponential-backoff fallback**

**JavaScript**

```javascript
async function fetchWithRetry(url, options, maxRetries = 3) {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    const response = await fetch(url, options);

    if (response.status !== 429) {
      return response;
    }

    if (attempt === maxRetries) {
      throw new Error('Max retries exceeded');
    }

    // Prefer the retry_after body field, then the Retry-After header,
    // then exponential backoff.
    let delaySeconds;
    try {
      const body = await response.clone().json();
      delaySeconds = typeof body.retry_after === 'number' ? body.retry_after : undefined;
    } catch {
      delaySeconds = undefined;
    }
    if (delaySeconds === undefined) {
      const header = response.headers.get('Retry-After');
      delaySeconds = header ? parseInt(header, 10) : Math.min(Math.pow(2, attempt), 30);
    }

    // Add jitter to prevent thundering herd.
    const jitter = Math.random() * 1000;
    await new Promise(resolve => setTimeout(resolve, delaySeconds * 1000 + jitter));
  }
}
```

**Python**

```python
import time
import random
import requests

def fetch_with_retry(url, headers, max_retries=3):
    for attempt in range(max_retries + 1):
        response = requests.get(url, headers=headers)

        if response.status_code != 429:
            return response

        if attempt == max_retries:
            raise Exception('Max retries exceeded')

        # Prefer the retry_after body field, then the Retry-After header,
        # then exponential backoff.
        delay = None
        try:
            delay = response.json().get('retry_after')
        except ValueError:
            delay = None
        if delay is None:
            retry_after = response.headers.get('Retry-After')
            delay = int(retry_after) if retry_after else min(2 ** attempt, 30)

        # Add jitter to prevent thundering herd.
        time.sleep(delay + random.uniform(0, 1))
```

## Best practices

1. **Watch the headers** — read `X-RateLimit-Remaining` on every response and slow down as it approaches zero, rather than waiting for a 429.
2. **Use webhooks instead of polling** — subscribe to events like `bill.created` instead of polling for new bills.
3. **Cache and batch** — biller metadata and account details change infrequently; cache them, and fetch multiple records in a single paginated request instead of one-by-one.
4. **Always back off** — never retry immediately after a 429. Wait at least the `retry_after` duration.

## Related

- [Error Handling](/docs/concepts/errors) — error response format and codes
- [Authentication](/docs/guides/authentication) — API key and bearer token auth
- [Webhooks](/docs/guides/webhooks) — event-driven alternative to polling
