Skip to main content

Command Palette

Search for a command to run...

Idempotency-Key in practice: retrying POSTs safely, deduping concurrent requests, and documenting it in OpenAPI

A network timeout never tells the client whether the charge happened. Retrying a plain POST double-creates; an Idempotency-Key header turns the retry into a replay. Here is the header contract, the in-flight 409 versus the stored response, key lifetime and scope, and the OpenAPI

Updated
•6 min read•View as Markdown

A payment call times out after the server already committed the charge. The client, doing exactly what every reliability guide tells it to do, retries the POST. Now there are two charges. The problem is not the retry; retries are mandatory on an unreliable network. The problem is that plain POST is defined as "create a new resource," so every retry is a new operation. The Idempotency-Key header turns a retry into a replay of one operation.

The core contract

The client generates a unique key for each logical operation and sends it on the first request and every retry of that same operation:

POST /api/transfers HTTP/1.1
Idempotency-Key: 7e3b1f9a-4c2d-4f6a-9b81-2d7c0e6a5f31
Content-Type: application/json

{ "amount_minor": 5000, "currency": "USD", "to": "acct_123" }

The server stores, per key, the request fingerprint and the outcome. The behavior is then:

Server state for the key Response Meaning
Never seen Process once, store the result Normal first request
Seen, request still in progress 409 Conflict (or 425 Too Early) Another attempt is running; retry shortly
Seen, completed Replay the stored response, same status and body Safe retry, no second side effect
Seen, but a different body arrives with the same key 422 Unprocessable Entity Key reuse with a different request is a client bug

This is the pattern codified by the IETF Idempotency-Key draft and implemented by Stripe, Square, and most payment APIs. The two subtle rows are the in-flight case (do not block forever, do not run it twice) and the fingerprint mismatch (a reused key with a different payload must never silently run a second operation).

Scope, lifetime, and key generation

Document these explicitly, because every one of them is a place implementations disagree:

  • Scope. A key is unique within an account (or organization), not globally. Two tenants reusing the same UUID must not collide. Include the authenticated principal in the lookup.
  • Lifetime. Keys must survive at least as long as the retry window, typically 24 hours; payment systems often keep them far longer. State the retention so clients know when a replay is no longer guaranteed.
  • Generation. The client mints the key, ideally a UUID v4, one per logical operation. A new user intent (a different transfer, a second form submission after editing) gets a new key; an automatic retry of the same intent keeps the key.
  • Methods. The header matters for non-idempotent methods (POST, sometimes PATCH). GET, PUT, and DELETE are already idempotent by HTTP semantics and do not need it, though accepting the key there is harmless.
  • Idempotency across endpoints. The key is bound to the route it was first used on; the same key on a different path is a mismatch or a new scope.

Store it before you do the work

Correctness depends on ordering. The server must persist the key (in a "started" state) inside the same transaction that prevents a duplicate, before performing the side effect:

  1. Insert the key row with a unique constraint scoped to the account; a duplicate insert is how you detect a concurrent retry.
  2. If the insert wins, perform the work, then atomically store the final status and response body, marking the key complete.
  3. If the insert loses to an in-flight request, return 409 and let the client retry; when it retries after completion, it gets the stored response.
  4. If the process crashes mid-work, the key stays in "started"; a later retry either resumes or, after a recovery timeout, is reconciled. Never leave a key that permanently returns 409.

The unique constraint, not an application-level check-then-act, is what makes concurrent first requests safe. Two retries arriving in the same millisecond must both be prevented from charging.

Documenting it in OpenAPI

Make the header a reusable parameter and add the three responses clients must handle:

paths:
  /transfers:
    post:
      summary: Create a transfer (safe to retry with the same Idempotency-Key)
      operationId: createTransfer
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateTransfer'
      responses:
        '201':
          description: Transfer created (or replayed for a repeated key)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Transfer'
        '409':
          description: A request with this key is still being processed; retry after the delay.
          headers:
            Retry-After:
              schema: { type: integer }
              description: Seconds to wait before retrying.
        '422':
          description: The key was already used with a different request body.

components:
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: >-
        Client-generated UUID, unique per logical operation within the account.
        Send the same key on retries; a new intent requires a new key. Stored for
        at least 24 hours.
      schema:
        type: string
        format: uuid

Requiring the key on money-moving and other "exactly-once effect" endpoints is reasonable; on low-stakes creates you can accept it optionally and document that retries without it may duplicate. State which.

How clients and AI agents should use it

A correct client wraps the call in a retry loop that keeps the key stable:

async function createTransfer(input: CreateTransfer, key = crypto.randomUUID()) {
  for (let attempt = 0; attempt < 5; attempt++) {
    const res = await api.post("/transfers", input, {
      headers: { "Idempotency-Key": key },
    });
    if (res.status === 409) {
      await sleep(Number(res.headers["retry-after"] ?? 2) * 1000);
      continue; // same key: poll for the stored result
    }
    return res; // 201 on first call or stored replay; 422 is a caller bug, do not retry
  }
  throw new Error("transfer did not settle; query by key before retrying with a new one");
}

This is exactly the behavior an AI agent calling tools should follow, and putting it in the spec, with the 409 and 422 rows spelled out, is what lets the agent do the right thing without being told each time. A spec that only documents 201 leaves every SDK and every agent to invent its own retry policy, which is how double charges happen.

Checklist

  1. Require (or strongly recommend) Idempotency-Key on every endpoint with a non-repeatable side effect.
  2. Define scope (per account), lifetime, and that retries reuse the key while new intents mint a new one.
  3. Persist the key under a unique constraint before performing the work; store status and body for replay.
  4. Return 409/425 while in flight with Retry-After, the stored response once complete, and 422 on body mismatch.
  5. Never run the side effect twice for one key, and never let a crashed "started" key return 409 forever.
  6. Model the header as a reusable parameter and document all three non-201 outcomes in OpenAPI.
  7. Verify with a test that fires two concurrent identical requests and asserts exactly one effect plus identical responses.

Get these right and "the request timed out, did it work?" stops being a support ticket; the client just retries and gets the same answer.

You can attach the idempotency header, generate a client whose retry loop replays correctly, and run concurrent duplicate-request tests against a mock in one local-first workspace, right in your browser. For the backoff contract that pairs with the in-flight 409, see documenting 429 and Retry-After in OpenAPI.

More from this blog

P

Powerduck Blogs

117 posts

Essays on local-first API tooling, OpenAPI contracts, MCP, and agentic coding failures. We build Powerduck, a local-first OpenAPI studio where the spec stays the source of truth. Topics: AI code review trust, testing AI-generated code, context engineering, and what actually breaks when AI writes your code.