Blog
5 min read

Idempotency Keys: Making POST Requests Safe to Retry

A timeout on "create payment" — did it go through or not? Idempotency keys let clients retry safely without double-charging. How the Idempotency-Key header works, a Postgres-backed implementation, handling concurrent duplicates, fingerprint mismatches, expiry, and what to store.

A client sends POST /payments. The connection drops before the response arrives. Did the payment happen? The client can't know. If it retries, the customer might be charged twice. If it doesn't, the order might never be paid.

Idempotency keys solve this. The client attaches a unique key to the request; if it retries with the same key, the server returns the original result instead of doing the work again. Stripe popularised the pattern, and an IETF draft standardises the Idempotency-Key header.

Idempotent, briefly

An operation is idempotent if doing it twice has the same effect as doing it once. GET, PUT (replace a resource) and DELETE are idempotent by design in HTTP. POST ("create a new thing") is not — every call creates another thing. (REST API design.)

Idempotency keys make specific POST endpoints behave idempotently.

The protocol

  1. The client generates a unique key — a UUID — once per logical operation, and sends it:

    POST /api/payments
    Idempotency-Key: 0192f3a7-5c1e-7b2a-9d4f-3e8a1c6b7d20
    
  2. The server checks whether it has seen that key (for that client):

    • New key: do the work, store the response under the key, return it.
    • Seen and completed: return the stored response — same status code and body — without doing anything.
    • Seen and still in progress: return 409 Conflict (the original request is still running); the client retries later.
    • Seen but with a different request body: return 422 — the key is being misused.
  3. The client retries with the same key after timeouts or 5xx errors, and generates a new key for a genuinely new operation.

A Postgres-backed implementation

CREATE TABLE idempotency_keys (
  account_id    bigint      NOT NULL,
  key           text        NOT NULL,
  request_hash  text        NOT NULL,
  status        text        NOT NULL DEFAULT 'in_progress',  -- in_progress | completed
  response_code int,
  response_body jsonb,
  created_at    timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (account_id, key)
);

Scoping the key to the account means one customer's keys can never collide with — or reveal — another's.

The request flow:

async function handleIdempotent(req, accountId: number, work: () => Promise<{ code: number; body: unknown }>) {
  const key = req.header("Idempotency-Key");
  if (!key) return work(); // or require it for this endpoint

  const hash = sha256(JSON.stringify(req.body));

  // 1. Claim the key atomically. The primary key makes concurrent duplicates lose this race.
  const inserted = await db.query(
    `INSERT INTO idempotency_keys (account_id, key, request_hash)
     VALUES ($1, $2, $3)
     ON CONFLICT DO NOTHING
     RETURNING key`,
    [accountId, key, hash]
  );

  if (inserted.rowCount === 0) {
    const { rows } = await db.query(
      "SELECT * FROM idempotency_keys WHERE account_id = $1 AND key = $2",
      [accountId, key]
    );
    const existing = rows[0];
    if (existing.request_hash !== hash) return { code: 422, body: { error: "Key reused with different request" } };
    if (existing.status === "in_progress") return { code: 409, body: { error: "Request in progress" } };
    return { code: existing.response_code, body: existing.response_body };
  }

  // 2. We own the key: do the work.
  try {
    const result = await work();
    await db.query(
      `UPDATE idempotency_keys SET status = 'completed', response_code = $3, response_body = $4
       WHERE account_id = $1 AND key = $2`,
      [accountId, key, result.code, JSON.stringify(result.body)]
    );
    return result;
  } catch (err) {
    // Release the key so the client can retry a failed attempt.
    await db.query("DELETE FROM idempotency_keys WHERE account_id = $1 AND key = $2", [accountId, key]);
    throw err;
  }
}

Key points:

  • The INSERT ... ON CONFLICT DO NOTHING claim is the concurrency control. Two identical requests arriving simultaneously can't both win. (Database transactions explained.)
  • Store the response, not just "done". A retry must get the same answer — including the ID of the payment that was created.
  • Decide what to cache. Successful results and deterministic client errors (400, 422) are usually stored; transient failures (5xx, timeouts) usually release the key so the client can retry.

Getting atomicity right

The hard case: the work succeeded, but the server crashed before marking the key completed. On retry, the key is stuck in_progress (or was released and the work runs again).

Approaches, strongest first:

  1. Same transaction: if the work is purely database writes, do the work and mark the key completed in one transaction. Either both happen or neither does.
  2. Recovery points: for multi-step operations involving external calls, record progress after each step, and make each step resumable — Stripe's engineering writing describes this approach in detail.
  3. Downstream idempotency: when your work calls another API (like a payment provider), pass an idempotency key to that API too, derived from yours. Then even a re-run doesn't double-charge.
  4. Stale in-progress keys: treat in_progress older than a timeout as abandoned, and let a retry take over — carefully, only if the work is safe to resume.

Side effects like emails belong in an outbox, committed with the rest of the transaction. (The transactional outbox pattern.)

Expiry

Keys don't need to live forever. Stripe keeps them for at least 24 hours. Delete old rows with a scheduled job, and document the window so clients know how long retries are safe.

Client responsibilities

  • Generate a new key per operation, not per attempt. A key generated inside the retry loop defeats the purpose.
  • Persist the key with the pending operation (for example in local storage or a job record), so retries after an app restart reuse it.
  • Retry only on network errors, timeouts, 409 and 5xx — with backoff.

Idempotency on the receiving side

The mirror image: when you receive webhooks, the sender retries too, and you must deduplicate by event ID. Same idea, different direction. (Handling webhooks reliably.)

The summary

  • Idempotency keys let clients safely retry non-idempotent requests like POST /payments.
  • Claim the key atomically, store the full response, return it on retries; 409 while in progress, 422 on mismatched bodies.
  • Scope keys per account, expire them after a set window.
  • For true safety, make the work and its completion atomic, and pass keys downstream.

EasySpawn runs your API and PostgreSQL together on one server, so Claude Code can fire concurrent duplicate requests at your real endpoint and prove the second one doesn't charge twice. See how it works or join the waitlist.

Related: How to Add Stripe Payments to an AI-Built App · Stripe vs Paddle vs Lemon Squeezy · Postgres Advisory Locks · Implementing Rate Limiting

Keep reading