Idempotency Keys: How Stripe-Style APIs Prevent Double Charges

Idempotency Keys: How Stripe-Style APIs Prevent Double Charges

#api#fintech#nodejs#postgresql#reliability

A customer taps “Pay.” The request reaches your server, the charge succeeds, and then the response times out on the way back. The client sees a failure and does the obvious thing: it retries. Now you have charged them twice.

This is not an edge case. Networks drop responses, mobile clients retry aggressively, load balancers time out, and users double-click. Any payment API that survives contact with the real world has to answer one question: how do you make “do this once” hold even when the request arrives twice? The answer the industry settled on is the idempotency key.

The core idea

The client generates a unique key for each logical operation (a UUID is fine) and sends it with the request, conventionally in an Idempotency-Key header. The server remembers what it did for that key. The first time it sees the key, it does the work and stores the result against the key. If the same key shows up again, the server skips the work and replays the stored response.

sequenceDiagram
  participant C as Client
  participant S as Server
  participant DB as Store
  C->>S: POST /charges (Idempotency-Key: idem_9f2a)
  S->>DB: claim key idem_9f2a
  DB-->>S: new (first time)
  S->>S: charge the card
  S->>DB: save response for idem_9f2a
  S-->>C: 200 { charge }
  Note over C,S: response lost, client retries
  C->>S: POST /charges (Idempotency-Key: idem_9f2a)
  S->>DB: claim key idem_9f2a
  DB-->>S: exists + completed
  S-->>C: 200 { same charge } (no second charge)

That is the whole promise: the operation runs at most once, no matter how many times the request is delivered. It is also why a bare retry loop is dangerous on its own. I wrote separately about why retries are not a fix by themselves; idempotency is the missing half that makes retries safe.

The three things that make it actually hard

Storing “key to response” sounds trivial. The difficulty is in the races.

1. Concurrency. The retry often arrives before the first request has finished (that is exactly why the client gave up and retried). So two requests with the same key can execute at the same time. If both pass the “have I seen this key?” check before either writes, both charge the card. You need the claim to be atomic.

2. Key reuse with a different body. A client bug (or an attacker) might reuse a key for a different request. If you blindly replay the stored response, you confirm an operation that never happened. So bind the key to a fingerprint of the request and reject a mismatch.

3. Exactly-once, not just at-most-once. Marking the key “done” and performing the business write have to succeed or fail together. If the charge commits but the process crashes before recording the response, the retry must not charge again.

Implementation: Postgres + Node

Postgres gives us the atomic primitive we need for concurrency: a unique constraint plus INSERT ... ON CONFLICT. The key is claimed and checked in a single statement.

CREATE TABLE idempotency_keys (
  key           text PRIMARY KEY,
  request_hash  text NOT NULL,
  status_code   integer,
  response_body jsonb,
  completed     boolean NOT NULL DEFAULT false,
  created_at    timestamptz NOT NULL DEFAULT now()
);

Middleware that claims the key, replays completed responses, and rejects the bad cases:

import { createHash } from "node:crypto";

const sha256 = (s) => createHash("sha256").update(s).digest("hex");

async function idempotency(req, res, next) {
  const key = req.header("Idempotency-Key");
  if (!key) {
    return res.status(400).json({ error: "Idempotency-Key header is required" });
  }

  const requestHash = sha256(
    JSON.stringify({ method: req.method, path: req.path, body: req.body }),
  );

  // Claim the key atomically. If it already exists, no row comes back.
  const claim = await db.query(
    `INSERT INTO idempotency_keys (key, request_hash)
     VALUES ($1, $2)
     ON CONFLICT (key) DO NOTHING
     RETURNING key`,
    [key, requestHash],
  );

  if (claim.rowCount === 0) {
    const { rows: [row] } = await db.query(
      `SELECT request_hash, status_code, response_body, completed
         FROM idempotency_keys WHERE key = $1`,
      [key],
    );

    if (row.request_hash !== requestHash) {
      return res.status(422).json({
        error: "This Idempotency-Key was already used with a different request",
      });
    }
    if (!row.completed) {
      // The original request is still in flight. Tell the client to back off.
      return res.status(409).json({
        error: "A request with this Idempotency-Key is still being processed",
      });
    }
    // Replay the stored result. No second charge.
    return res.status(row.status_code).json(row.response_body);
  }

  // We own the key. Capture whatever the handler responds and persist it.
  const sendJson = res.json.bind(res);
  res.json = (body) => {
    db.query(
      `UPDATE idempotency_keys
          SET status_code = $2, response_body = $3, completed = true
        WHERE key = $1`,
      [key, res.statusCode, body],
    ).catch(() => {});
    return sendJson(body);
  };

  next();
}

One refinement worth copying from Stripe: cache a result only once the endpoint has actually begun executing. A request that fails input validation, or that is rejected because another request with the same key is still in flight, never started the real work, so it should stay retryable with the same key. The capture above stores every response; to mirror Stripe, persist only when the handler produced a real outcome and skip pure validation (400) failures.

Getting exactly-once right

The middleware above is at-most-once for the common cases, but note the gap: if the handler charges the card and the process dies before the UPDATE marks the key completed, a retry sees an in-flight key (409) and, once that expires, could run again. The fix is to make the business write and the key completion one transaction:

await db.tx(async (t) => {
  const charge = await t.query("INSERT INTO charges ... RETURNING *");
  await t.query(
    "UPDATE idempotency_keys SET status_code=$2, response_body=$3, completed=true WHERE key=$1",
    [key, 200, charge.rows[0]],
  );
});

Now the charge and the record of it commit together or not at all. That is the difference between “we probably won’t double-charge” and a guarantee. It is the same discipline that underpins resilient fintech systems in general: money operations are transactional or they are bugs.

Expiry

Keys should not live forever. Store created_at and purge old rows on a schedule (a daily job deleting anything past your retention window). Stripe, for reference, keeps idempotency keys for 24 hours, which is a sane default: long enough to cover client retries, short enough that keys do not accumulate.

The client’s job

A key identifies an operation, not an HTTP attempt. Generate one UUID when the user initiates the action, then reuse that same key across every retry of that action. A new key per retry defeats the entire mechanism, because each looks like a brand-new charge.

Two constraints from Stripe are worth honoring: keep the key under 255 characters, and never use sensitive data (an email, a customer ID) as the key itself.

const key = crypto.randomUUID(); // once per checkout
await retry(() =>
  fetch("/v1/charges", {
    method: "POST",
    headers: { "Content-Type": "application/json", "Idempotency-Key": key },
    body: JSON.stringify({ amount: 4200, currency: "usd", source: "tok_visa" }),
  }),
);

Where it applies

Idempotency keys are for the methods that are not already idempotent by HTTP semantics. GET, PUT, and DELETE are defined to be repeatable; it is POST that carries “create a new thing / move money,” and that is where a duplicate does damage. So the keys guard your POST endpoints: charges, orders, transfers, signups.

Stripe formalized this pattern into the Idempotency-Key header years ago: reuse a key and you get the original response back; reuse it with different parameters and you get an error; send it while the first is still running and you get a conflict. There is now an IETF draft standardizing the header field, so expect it to become a first-class part of HTTP API design rather than a per-vendor convention.

The takeaway is small and durable: retries are inevitable, so make them safe. An idempotency key turns “retry and pray” into “retry and get the same answer,” which is exactly what you want standing between a flaky network and someone’s bank balance.

References

Building auth for a regulated app?

Join the waitlist for the Compliance-Ready Backend Kit. Early access and launch pricing go out to this list first.