Idempotency

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: omit it and the request behaves exactly as before (no idempotency).

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/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. 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
import { randomUUID } from 'crypto';

const idempotencyKey = randomUUID();
// 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/webhook_endpoints, and POST /v1/feedback.

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.

EndpointHeaderDescription
POST /customers/:user_aid/messagesOptional · 24h replay windowSend a customer message; a repeated key returns the original delivery outcome.
POST /bills/:id/statement/refreshOptional · accepted, not yet deduplicatedTrigger 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/feedbackRequiredSubmit 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.

A few unversioned endpoints — including POST /link/token/create, POST /bills/sync/trigger, and POST /request-to-link/create — 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.

Full Example

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

Idempotent request with retry
import { randomUUID } from 'crypto';

async function sendCustomerMessage(userAid, message) {
  // 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/customers/${userAid}/messages`,
        {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'Authorization': 'Bearer $BILLERAPI_API_KEY',
            'Idempotency-Key': idempotencyKey,
          },
          body: JSON.stringify(message),
        }
      );

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

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
// 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();
}

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

Was this page helpful?