Technical

Building Idempotent Financial APIs

Network timeouts happen. Retries must be safe. Here's how to implement idempotency keys for financial APIs so duplicate requests produce the same result.

William Murray · 27 February 2026 · 4 min read
Two scenarios compared: without idempotency keys a retry creates a duplicate invoice, with idempotency keys a retry returns the original response safely

A mobile client sends a “create invoice” request. The server processes it, writes to the database, and starts sending the response. The connection drops. The client never receives the 201. So it retries.

Without idempotency, you now have two invoices. Your user’s client gets charged twice. The ledger is wrong. Someone has to clean it up manually.

This is not a theoretical problem. It happens every day, on every network, to every API that accepts mutations over HTTP. The question is whether your API handles it gracefully or leaves the mess for your users.

What Idempotency Means

An operation is idempotent if performing it multiple times produces the same result as performing it once. HTTP GET is naturally idempotent — fetching a resource ten times gives you the same resource. HTTP POST is not — posting ten times can create ten resources.

For financial APIs, idempotency means: if a client sends the same mutation twice (intentionally or not), the second request returns the result of the first without creating a duplicate.

First request:   POST /invoices  →  201 Created  { id: "inv_42" }
Retry:           POST /invoices  →  200 OK       { id: "inv_42" }  (same invoice)

The second request doesn’t create inv_43. It recognises it’s a retry and returns the original response.

The Idempotency Key Pattern

The standard approach is a client-supplied idempotency key sent as a header:

curl -X POST https://api.speybooks.com/v1/invoices \
  -H "Authorization: Bearer sk_live_..." \
  -H "Idempotency-Key: inv-feb-acme-2026" \
  -H "Content-Type: application/json" \
  -d '{
    "contactId": "cont_42",
    "lineItems": [
      {
        "description": "February consultancy",
        "quantity": 1,
        "unitPricePence": 500000,
        "taxCode": "T1"
      }
    ]
  }'

The client generates the key. It can be anything unique — a UUID, a structured string, a hash of the request body. The important thing is that retries use the same key.

How It Works Internally

The server-side implementation has five steps:

1. Receive request with Idempotency-Key header
2. Check if this key already exists in the store
3a. If NOT found → process the request, store the key + response, return 201
3b. If found → return the stored response, return 200
4. Key expires after TTL (typically 24–48 hours)

In PostgreSQL, this looks like:

CREATE TABLE idempotency_keys (
  key           TEXT NOT NULL,
  organisation_id UUID NOT NULL,
  response_code INTEGER NOT NULL,
  response_body JSONB NOT NULL,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  expires_at    TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '48 hours',
  PRIMARY KEY (key, organisation_id)
);

The key is scoped to the organisation — two different users can independently use the same key string without collision. The TTL prevents the table from growing unbounded.

The Race Condition

There’s a subtle bug in the naive implementation. What happens if two identical requests arrive simultaneously — before either has finished processing?

Request A: check key → not found → begin processing
Request B: check key → not found → begin processing  (race!)

Both requests pass the “key not found” check and both create the resource. You get duplicates despite having idempotency keys.

The fix is an atomic check-and-lock:

INSERT INTO idempotency_keys (key, organisation_id, response_code, response_body)
VALUES ($1, $2, 0, '{}')
ON CONFLICT (key, organisation_id) DO NOTHING
RETURNING *;

If the insert succeeds, this request “owns” the key and should process. If it conflicts, another request is already processing — wait and return that result.

In practice, we use SELECT ... FOR UPDATE inside a transaction:

async function withIdempotency(
  key: string,
  orgId: string,
  handler: () => Promise<ApiResponse>
): Promise<ApiResponse> {
  // Try to claim the key
  const existing = await db.query(
    `SELECT response_code, response_body FROM idempotency_keys
     WHERE key = $1 AND organisation_id = $2 AND expires_at > NOW()`,
    [key, orgId]
  );

  if (existing.rows.length > 0 && existing.rows[0].response_code > 0) {
    // Key exists and processing is complete — return cached response
    return existing.rows[0];
  }

  // Claim the key (atomic insert)
  await db.query(
    `INSERT INTO idempotency_keys (key, organisation_id, response_code, response_body)
     VALUES ($1, $2, 0, '{}')
     ON CONFLICT (key, organisation_id) DO NOTHING`,
    [key, orgId]
  );

  // Process the actual request
  const response = await handler();

  // Store the result
  await db.query(
    `UPDATE idempotency_keys
     SET response_code = $3, response_body = $4
     WHERE key = $1 AND organisation_id = $2`,
    [key, orgId, response.code, JSON.stringify(response.body)]
  );

  return response;
}

The response_code = 0 sentinel indicates “processing in progress.” A concurrent request that finds a key with response_code = 0 knows to wait and retry rather than process independently.

What the Client Needs to Do

Client-side idempotency is straightforward:

async function createInvoice(data: InvoiceInput): Promise<Invoice> {
  const idempotencyKey = `inv-${data.contactId}-${Date.now()}`;

  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      const response = await fetch('/v1/invoices', {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${apiKey}`,
          'Idempotency-Key': idempotencyKey,  // same key on every retry
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(data),
      });
      return await response.json();
    } catch (error) {
      if (attempt === 2) throw error;
      await sleep(1000 * Math.pow(2, attempt));  // exponential backoff
    }
  }
}

The key is generated once before the retry loop. Every retry sends the same key. If any attempt succeeds server-side, all subsequent retries return the cached result.

Key Design Decisions

What makes a good idempotency key?

A UUID works but is opaque. A structured key like inv-feb-acme-2026 tells you what it was for. We recommend structured keys for debuggability, but accept any string up to 255 characters.

Which endpoints need idempotency?

Every mutating endpoint that creates or modifies financial data. In SpeyBooks, that’s:

  • POST /v1/invoices — create invoice
  • POST /v1/invoices/:id/send — send to client
  • POST /v1/invoices/:id/void — void invoice
  • POST /v1/transactions — create journal entry
  • POST /v1/payments — record payment
  • POST /v1/bank-imports — import bank statement

GET endpoints are naturally idempotent. PUT and PATCH are idempotent by definition (applying the same update twice produces the same state). POST is the one that needs explicit handling.

What about request body changes?

If a client sends the same idempotency key with a different request body, that’s an error. The API returns 422:

{
  "success": false,
  "error": {
    "code": "idempotency_key_reused",
    "message": "This idempotency key was used with a different request body"
  }
}

We detect this by storing a hash of the original request body alongside the key and comparing on subsequent requests.

TTL and cleanup

Keys expire after 48 hours. A background job runs daily to clean up expired entries. The 48-hour window is generous enough to cover retry scenarios (most happen within seconds) while preventing unbounded table growth.

How Stripe Does It

This pattern isn’t new. Stripe’s idempotency implementation is the gold standard and worth studying. Their approach matches what we’ve described: client-supplied key, server-side storage, race condition handling via atomic operations, 24-hour TTL.

The one difference: Stripe uses the Idempotency-Key header (with a hyphen), which has become the de facto standard. Some APIs use X-Idempotency-Key but the X- prefix is deprecated by RFC 6648. Use Idempotency-Key.

The Broader Principle

Idempotency is one instance of a wider pattern: making invalid states unrepresentable at the API level. Double-entry constraints prevent unbalanced transactions. Minor units prevent rounding errors. Idempotency keys prevent duplicate mutations.

Each constraint removes a category of bugs before they reach your users’ code. Financial APIs can’t afford to be “mostly correct.” A ledger that occasionally creates duplicate entries is a ledger you can’t trust.

Key Takeaways
  • Idempotency means the same request twice produces the same result once — essential for financial APIs
  • The client generates a unique key and sends it with every retry of the same request
  • The server stores the key + response, returning the cached result on subsequent attempts
  • Race conditions require atomic check-and-lock (INSERT ON CONFLICT or SELECT FOR UPDATE)
  • Use the standard Idempotency-Key header, scoped per organisation, with a 24–48 hour TTL

Early access for developers.

SpeyBooks is in soft launch. We're inviting a small group of developers to help shape API-first accounting for the UK.

90-day free trial. Proper double-entry. No tracking.