# Idempotency

Idempotency in BillerAPI has two sides. On **your outbound API calls** to BillerAPI, send an `Idempotency-Key` header so retries do not create duplicates. On **inbound webhooks** from BillerAPI to your server, dedupe on the envelope's `id` so duplicate deliveries do not double-process.

## Outbound: `Idempotency-Key` on mutating requests

Every mutating request to a versioned `/v1/` endpoint — any `POST`, `PATCH`, `PUT`, or `DELETE` — honors an `Idempotency-Key` header. Include the header with a unique value (UUID v4 recommended). If the same key is sent again within the **24-hour** replay window, the API replays the original response instead of processing the request a second time, and marks the replayed response with an `Idempotency-Replayed: true` header.

This means you can safely retry any supported request after a network timeout or connection error without duplicate side effects. The header is optional for most routes: omit it and the request behaves exactly as before (no idempotency). It is **required** when creating or updating a webhook endpoint, or rotating its signing secret. Webhook mutations recover from a durable owner receipt using the same key. Secret-returning webhook routes bypass the generic HTTP replay cache, so those responses do not add the `Idempotency-Replayed` header.

```http
POST /v1/account-links
Content-Type: application/json
Authorization: Bearer bak_test_xxxxxxxx
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
```

On replay, the response carries:

```http
HTTP/1.1 201 Created
Idempotency-Replayed: true
X-Request-Id: d94f5e2a-8c3b-4f1e-9a7d-6b2c1e0f8a34
```

## How replay + conflict detection works

BillerAPI stores a fingerprint of your request — a hash of your client id, the HTTP method, the path, and the request body — keyed by your `Idempotency-Key` for 24 hours. On a retry:

- **Same key, byte-identical body** → the stored status code and response body are replayed verbatim, with `Idempotency-Replayed: true`. The handler does **not** run again.
- **Same key, different body** → a `409 Conflict` with error code [IDEMPOTENCY_KEY_MISMATCH](/docs/errors/IDEMPOTENCY_KEY_MISMATCH). Use a fresh key for a genuinely new request, or resend the identical body to replay.
- **A concurrent retry while the first is still in flight** → the second request waits for the first to finish, then replays its response — you never get two executions racing under one key.

Body key ordering does not matter: `{"a":1,"b":2}` and `{"b":2,"a":1}` fingerprint identically and replay, not conflict.

## Generating Idempotency Keys

Use a UUID v4 for each unique operation. Do not reuse keys across different operations — each distinct action should have its own key.

**Generate a UUID v4**

**JavaScript**

```javascript
import { randomUUID } from 'crypto';

const idempotencyKey = randomUUID();
// e.g., '550e8400-e29b-41d4-a716-446655440000'
```

**Python**

```python
import uuid

idempotency_key = str(uuid.uuid4())
# e.g., '550e8400-e29b-41d4-a716-446655440000'
```

**Go**

```go
import "github.com/google/uuid"

idempotencyKey := uuid.New().String()
// e.g., "550e8400-e29b-41d4-a716-446655440000"
```

## Supported Endpoints

**All mutating `/v1/` endpoints** — the versioned client API surface — honor the `Idempotency-Key` header uniformly through the gateway, with the 24-hour replay window and `IDEMPOTENCY_KEY_MISMATCH` conflict behavior described above. That covers, among others, `POST /v1/account-links`, `POST /v1/billers`, `POST /v1/pay/payments`, and `POST /v1/feedback`.

Two narrow exceptions apply. Requests that carry **no authenticated identity** cannot be assigned a replay scope, so the header is ignored on them. And routes that return a **one-time secret in the response body** — `POST /v1/webhook-endpoints`, `POST /v1/webhook-endpoints/:id/rotate-secret`, and the `/v1/iam/clients/:id/client-secrets` mint and rotate calls — never store their response body. Client-secret routes still claim the key and store a secret-free completion receipt; a same-key retry returns `IDEMPOTENCY_KEY_NOT_REPLAYABLE` (409) without minting again. Webhook secret routes retain their endpoint-owned idempotency behavior.

A handful of **older, unversioned** endpoints have their own, endpoint-specific idempotency behavior that predates the gateway-wide layer. The table below states exactly what each one does today.

| Endpoint | Header | Description |
| --- | --- | --- |
| [POST /bills/:id/statement/refresh](/docs/api/bills) | Optional · accepted, not yet deduplicated | Trigger statement extraction. This unversioned route sits outside the gateway-wide `/v1/` layer: the header is accepted and recorded for correlation, but does not yet collapse duplicate refreshes — each call returns a new request_id. Dedupe on your side until in-flight collapsing ships. |
| [POST /v1/feedback](/docs/api/feedback) | Required | Submit resource feedback; the key is mandatory (sent as the `idempotency_key` body field). Covered by both its own dedupe and the gateway-wide `/v1/` layer; the same key + body returns the existing record. |

Unversioned endpoints such as `POST /bills/sync/trigger` sit outside the versioned surface and do not currently read the `Idempotency-Key` header. Retries against them may create duplicates; dedupe on your side, or call the `/v1/` equivalent where one exists. `POST /v1/link-tokens` is a client-authenticated `/v1/` route and is covered by the gateway-wide layer described above.

## Full Example

Here is a complete example showing how to use an idempotency key with retry logic:

**Idempotent request with retry**

**JavaScript**

```javascript
import { randomUUID } from 'crypto';

async function submitFeedback(feedback) {
  // Generate key once — reuse on retries for the SAME operation
  const idempotencyKey = randomUUID();

  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      const response = await fetch(
        'https://sandbox.api.billerapi.com/v1/feedback',
        {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'Authorization': 'Bearer $BILLERAPI_API_KEY',
            'Idempotency-Key': idempotencyKey,
          },
          body: JSON.stringify({ ...feedback, idempotency_key: idempotencyKey }),
        }
      );

      if (response.ok) {
        return await response.json();
      }

      // Don't retry client errors (except 429)
      if (response.status < 500 && response.status !== 429) {
        throw new Error(`Client error: ${response.status}`);
      }
    } catch (err) {
      if (attempt === 2) throw err;
    }

    // Exponential backoff before retry
    await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt)));
  }
}
```

**Python**

```python
import uuid
import time
import requests
import os

def submit_feedback(feedback):
    # Generate key once — reuse on retries for the SAME operation
    idempotency_key = str(uuid.uuid4())

    for attempt in range(3):
        try:
            response = requests.post(
                'https://sandbox.api.billerapi.com/v1/feedback',
                headers={
                    'Content-Type': 'application/json',
                    'Authorization': 'Bearer $BILLERAPI_API_KEY',
                    'Idempotency-Key': idempotency_key,
                },
                json={**feedback, 'idempotency_key': idempotency_key},
            )

            if response.ok:
                return response.json()

            # Don't retry client errors (except 429)
            if response.status_code < 500 and response.status_code != 429:
                raise Exception(f'Client error: {response.status_code}')

        except requests.exceptions.ConnectionError:
            if attempt == 2:
                raise

        # Exponential backoff before retry
        time.sleep(2 ** attempt)
```

## Inbound: dedupe webhooks by `event.id`

Webhook delivery is at-least-once. Network failures, your endpoint returning a non-2xx, or an SQS visibility-timeout redelivery on our side can all cause the same webhook to arrive twice. Every webhook envelope carries a unique `id` (prefixed `evt_`); use it as your dedupe key.

Persist the `id` atomically with whatever side effect your handler performs (database row, downstream API call, queue enqueue). On every delivery, check if you have already seen the id: if yes, return `200 OK` without re-processing; if no, process and record the id in the same transaction. Keep dedupe records for at least 7 days.

**Webhook handler with envelope dedupe**

**JavaScript**

```javascript
// Pseudo-code: replace store calls with your DB / Redis / etc.
async function handleWebhook(req, res) {
  const envelope = req.body; // already signature-verified upstream

  // Check for prior delivery with the same envelope id.
  const seen = await store.get(`webhook:${envelope.id}`);
  if (seen) {
    // Idempotent replay — return 200 so we are not retried.
    return res.status(200).end();
  }

  // Atomic: persist the dedupe record + the projection in one transaction
  // so a crash between them does not leak duplicate side effects.
  await store.transaction(async (tx) => {
    await tx.put(`webhook:${envelope.id}`, { ts: Date.now() }, { ttl: 7 * 86400 });
    await applyProjection(tx, envelope);
  });

  res.status(200).end();
}
```

**Python**

```python
# Pseudo-code: replace store calls with your DB / Redis / etc.
def handle_webhook(envelope):
    # Check for prior delivery with the same envelope id.
    if store.get(f"webhook:{envelope['id']}"):
        # Idempotent replay — return 200 so we are not retried.
        return 200

    # Atomic: persist the dedupe record + the projection together.
    with store.transaction() as tx:
        tx.put(f"webhook:{envelope['id']}", {'ts': time.time()}, ttl=7 * 86400)
        apply_projection(tx, envelope)

    return 200
```

BillerAPI keeps internal dedupe records for 7 days; a redelivered `event.id` outside that window is rare but possible. Matching the 7-day TTL on your side keeps both ends consistent.

## Important Notes

- On the versioned `/v1/` surface, replay is **gateway-wide and uniform**: any mutating request carrying the key is protected for 24 hours. A few older unversioned endpoints keep their own per-endpoint behavior (see the Supported Endpoints table). Statement refresh, for instance, accepts the header but does not yet collapse duplicates — dedupe on your side there.
- Use the **same key for retries** of the same operation. Generate a **new key for each distinct operation**.
- Sending the **same key with a different request body** returns a `409 Conflict` with error code `IDEMPOTENCY_KEY_MISMATCH`. This holds across the whole `/v1/` surface (and on `/v1/feedback` specifically).
- GET requests are naturally idempotent and ignore the header. Mutating requests (POST, PATCH, PUT, DELETE) on `/v1/` routes all honor it — useful when a PUT or DELETE has side effects you want a retry to collapse.
- For inbound webhooks, the dedupe key is the envelope's `id`, not `request.idempotency_key`. The latter echoes the originating client's POST key (when there was one) and is for tracing, not dedupe.

## Related

- [Error Handling](/docs/concepts/errors) — error response format and retry behavior
- [Rate Limits](/docs/concepts/rate-limits) — rate limits and backoff strategies
- [API Reference: Link Sessions](/docs/api/link-sessions) — link token creation endpoint
