Designing Idempotent APIs

Technology

Designing Idempotent APIs

Make write retries safe with idempotency keys, request fingerprints, conflict semantics, storage TTLs, and side-effect boundaries that prevent duplicate charges.

6 min read·Updated September 18, 2026
AM

Principal Backend Engineer

Share this guide

Retries are inevitable. Networks flake, mobile clients disconnect, and load balancers time out after work has already started. If your write APIs are not idempotent, those retries create duplicate charges, duplicate records, and painful support tickets.

The problem in one diagram

text
Client                API                 Database
  |-- POST /orders -->|                     |
  |                   |-- insert order ---->|
  | X timeout         |                     |
  |-- POST /orders -->|                     |
  |                   |-- insert order ---->|  ← duplicate

Without an idempotency contract, the second request is a new business event. Clients must retry under uncertainty; servers must treat that retry as the same operation.

Idempotency key contract

Ask clients to send a unique key per logical operation:

text
POST /v1/orders HTTP/1.1
Idempotency-Key: 8f2c1a6e-9b44-4d21-a1c0-0d91f3e2a7b1
Content-Type: application/json
 
{"sku":"trail-pro","qty":1}

Server behavior:

  1. Hash the key scoped to the authenticated principal.
  2. Persist key + request fingerprint + response.
  3. On replay with the same key and body, return the original response.
  4. On replay with the same key and a different body, reject with 409 Conflict.

Info: Scope keys by user or API key. A global key namespace invites accidental collisions across tenants.

Document the header, key format (UUID v4 is fine), TTL, and conflict semantics. SDKs should generate a key once per user action—not once per HTTP attempt.

Minimal implementation sketch

text
type IdempotencyRecord = {
  key: string;
  actorId: string;
  requestHash: string;
  statusCode: number;
  responseBody: unknown;
  createdAt: string;
};
 
async function handleCreateOrder(req: Request) {
  const key = req.headers.get("Idempotency-Key");
  if (!key) throw new HttpError(400, "Missing Idempotency-Key");
 
  const actorId = requireUserId(req);
  const body = await req.json();
  const requestHash = sha256(JSON.stringify(body));
 
  const existing = await store.get({ actorId, key });
  if (existing) {
    if (existing.requestHash !== requestHash) {
      throw new HttpError(409, "Idempotency key reuse with different payload");
    }
    return Response.json(existing.responseBody, {
      status: existing.statusCode,
    });
  }
 
  const order = await createOrder(body);
  await store.put({
    key,
    actorId,
    requestHash,
    statusCode: 201,
    responseBody: order,
    createdAt: new Date().toISOString(),
  });
 
  return Response.json(order, { status: 201 });
}

The sketch is incomplete on concurrency. Same-key in-flight requests need a lock, unique constraint, or compare-and-set so only one writer creates the order; the loser waits for the stored result.

Handling in-flight duplicates

A practical pattern:

  1. Insert an idempotency row in processing under a unique (actor_id, key) constraint.
  2. If the insert loses the race, wait briefly and re-read; return the completed response when ready.
  3. If the first worker fails mid-flight, mark failed or hold until TTL—never silently accept a second create.

Canonicalize the body before hashing (stable key order) so equivalent JSON does not falsely trip 409. Exclude volatile fields (trace IDs, client timestamps) from the fingerprint.

Side effects and exactly-once illusions

Idempotency at the API edge does not give you exactly-once processing everywhere. Downstream systems still need:

  • dedupe tables or unique constraints
  • outbox + consumer checkpoints
  • careful webhook delivery semantics

Warning: Returning 201 twice with the same body is not enough if a payment capture happens outside the idempotent transaction boundary.

Put money movement, email, and third-party calls behind the same transactional boundary as the idempotency record—or an outbox consumers also dedupe.

Storage and retention

ConcernRecommendation
StoreRedis with DB fallback, or transactional SQL row
TTL24–72 hours for most product APIs
UniquenessUnique index on (actor_id, idempotency_key)
Payload sizeCap stored response bodies

Prefer SQL for money paths so key reservation and the business write share one transaction; Redis can cache in front. After TTL, keys may be reused—treat them as short-lived retry handles. Lasting identity belongs in fields like external_reference with their own unique constraints.

Response semantics clients should rely on

SituationStatusNotes
First success201 / 200Store for the key’s TTL
Replay, same bodySame as originalSame resource id; do not invent a new one
Replay, different body409Key/payload mismatch
Missing key (required APIs)400Enforce on money-adjacent routes
Concurrent processingWait or 409Prefer wait-and-return when latency allows

When GET is not enough

Safe methods are not a substitute for write idempotency. Clients still need a way to recover intent after uncertainty. Prefer:

  1. client-generated idempotency keys for POSTs
  2. natural unique constraints (external_reference)
  3. upsert semantics when the domain allows it

Common mistakes

  1. Generating a new key on every retry
  2. Storing the key without a request fingerprint
  3. Recording success after the side effect, not with it
  4. Global key namespaces across tenants
  5. No concurrent-request test under load
  6. Infinite retention of large response bodies

Rollout checklist

  • Docs cover header, TTL, and 409 behavior
  • Unique constraint on (actor, key) in durable storage
  • Stable request fingerprinting
  • In-flight lock (or equivalent) for concurrent duplicates
  • Side effects inside the same transaction/outbox boundary
  • Load test: parallel retries → one business effect
  • SDKs generate keys per action, not per attempt
  • Metrics for replays, conflicts, and lock timeouts
  • Runbook for stuck processing records

Optional-but-logged first is fine; then enforce missing keys on high-risk routes.

Testing checklist

  • Retry same key + body → identical response
  • Retry same key + different body → 409
  • Concurrent duplicate requests → single side effect
  • Expired key → treated as a new request
  • Unauthorized replay attempts → rejected

Automate concurrent retries with two workers; sequential Postman clicks miss the race.

FAQ

Every POST needs a key?
Require keys for creates that allocate money, inventory, or irreversible external side effects. Low-risk analytics posts can stay best-effort if you accept duplicates.

First request commits the row but dies before saving the key?
Order of operations is wrong. Reserve the key (or rely on a unique business constraint) in the same transaction as the write.

Can the order id be the key?
Only if the client already knows it (client-generated resource ids). Otherwise a timeout leaves nothing to retry under.

How long should keys live?
Long enough for mobile offline retries—often 24–72 hours—without keeping PII-laden bodies forever.

Continue learning

  • Pair this with queue outbox patterns before adding async fulfillment

Idempotency is a product reliability feature. Design it intentionally, document it publicly, and test it under concurrency—not only under happy-path Postman clicks.

Share this guide

Comments (…)

Share a thought or question about this guide.

Loading comments…