What an idempotency key is
An idempotency key is a unique value a client sends with a request so the server can recognize a retry of that same request and return the first result instead of doing the work again.
HTTP already treats GET, PUT and DELETE as idempotent: sending them twice has the same intended effect as sending them once. POST, the method most “create something” endpoints use, isn’t. An idempotency key makes a POST safe to repeat. Stripe accepts one in an Idempotency-Key header on every POST request, and an IETF Internet-Draft proposes the same header for any HTTP API. It is a draft, not a standard: its latest revision, from October 2025, expired in April 2026.
Who runs into this
Anyone whose code does something that must not happen twice, in response to a request that can arrive twice:
- a checkout or “Pay” button that gets double-clicked;
- a mobile app or SDK that retries when the connection drops;
- a webhook handler or job queue that delivers the same event more than once;
- an AI agent that calls a “create charge” tool again after the first call timed out.
The problem
A request times out. The client can’t tell whether the server never got it, or got it and the response was lost on the way back. The natural move is to send it again, and if the first request did go through, the customer pays twice.
Retrying is the right reaction to a network error. Stripe’s docs tell clients to retry those requests with the same key and the same parameters until they get a definite result. So the fix is to make the retry safe, not to stop retrying. You also don’t control every retry: the HTTP specification notes that some clients resend a POST on their own when the connection closes before any response arrives.
How it works
- The client makes one key per operation and sends the same key on every retry of it. Stripe suggests a random V4 UUID, or a value derived from the thing being paid for, such as a cart ID. Keep personal data like email addresses out of keys.
- The server claims the key by inserting it into a table where the key is unique. A unique index allows only one row per value, so exactly one request wins, even when two arrive at the same moment. In Postgres,
INSERT … ON CONFLICT DO NOTHING RETURNINGreturns a row only for the insert that happened. Scope the key to the caller, for example by prefixing the account ID, so one client can never be served another client’s stored response. The IETF draft recommends the same. - The winner does the work and stores the response, status and body, next to the key.
- Every later request with that key gets the stored response. If the first request is still running, it gets 409 Conflict. If the key comes back with a different body, it gets 422. Those are the status codes the IETF draft suggests.
/*
* Run a request at most once per idempotency key.
*
* In Postgres the store is one table keyed by the idempotency key:
* CREATE TABLE idempotency_keys (
* key text PRIMARY KEY, fingerprint text NOT NULL, response jsonb,
* claimed_at timestamptz NOT NULL DEFAULT now()
* );
* and claim() is:
* INSERT INTO idempotency_keys (key, fingerprint) VALUES ($1, $2)
* ON CONFLICT (key) DO NOTHING RETURNING key;
* which returns a row only for the request that inserted the key.
*
* Scope the key to the caller (for example `${accountId}:${key}`) so one
* client can never be served another client's stored response.
*/
export type StoredResponse = { status: number; body: unknown }
export type KeyRow = { fingerprint: string; response?: StoredResponse }
export interface IdempotencyStore {
/** Inserts the key. Resolves false if the key already exists. */
claim(key: string, fingerprint: string): Promise<boolean>
get(key: string): Promise<KeyRow | undefined>
saveResponse(key: string, response: StoredResponse): Promise<void>
release(key: string): Promise<void>
}
export async function runOnce<T>(
store: IdempotencyStore,
key: string,
request: T,
// Pass `key` on to your payment provider as its idempotency key too.
handler: (request: T, key: string) => Promise<StoredResponse>,
): Promise<StoredResponse> {
const fingerprint = JSON.stringify(request)
if (await store.claim(key, fingerprint)) {
let response: StoredResponse
try {
response = await handler(request, key)
} catch (error) {
await store.release(key) // nothing stored, so a retry may run it
throw error
}
// If the save fails, the key stays claimed: retries get 409, not a rerun.
await store.saveResponse(key, response)
return response
}
const row = await store.get(key)
if (row && row.fingerprint !== fingerprint) {
return { status: 422, body: { error: 'Key reused for another request' } }
}
if (!row?.response) {
return { status: 409, body: { error: 'In progress. Retry later.' } }
}
return row.response // the first result, replayed
}Three details decide whether this holds up in production:
- Pass the key on to your payment provider. If the handler fails after the card was charged but before the response was saved, a retry runs the handler again. Stripe stores the result of the first request for each key and returns it to later requests with the same key, so the second call gets the first charge back instead of making a new one.
- Decide how long keys live. Stripe may remove keys once they’re at least 24 hours old, and a key reused after that is treated as a new request. Keep yours for at least as long as your longest retry window.
- Plan for a crash between the claim and the save. The row stays “in progress” and every retry gets 409. Record when each key was claimed, and let a retry take over a claim older than the longest time your handler can run, with one conditional
UPDATEso only one retry wins. The takeover runs the handler again, so write your own rows in the same transaction that saves the response.
The example compares requests by their JSON text. The IETF draft calls this a fingerprint and leaves the method open; in production, hash a canonical form of the body so the order of keys doesn’t matter.
Why it matters for AI-written code
A “charge the customer” handler written by a coding agent will normally pass the test you run by hand: one click, one request, one charge. The double charge needs a timeout, a slow network or a redelivered event, which a quick manual check doesn’t create. So unless you ask for idempotency, it tends to surface in production rather than in review.
Agents retry too. In a tool-use loop the model decides when to call a tool, so if a “create charge” tool times out, nothing in the loop itself stops the model from calling it again. Give every tool that moves money or changes data an idempotency key, as you would an HTTP endpoint, derived from the task and the operation rather than from the attempt.
Alternatives and how they compare
- Check, then insert. Look the order up and create it if it’s missing. Two requests that arrive together can both find nothing and both insert. A unique constraint in the database closes that gap.
- A natural key instead of a random one. Use the cart or order ID as the key. Stripe’s docs list this as a simple guard against double submissions; it only works when there is exactly one valid operation per object.
- Disable the button after the first click. Good for the person clicking, but it does nothing about network retries, queue redeliveries or agent retries.
- Rely on the payment provider’s idempotency alone. It protects the charge, but not your own side effects around it: the order row, the confirmation email, the stock count.
- A workflow engine. Temporal, for example, retries steps and records their results, and its docs still ask you to make each step idempotent, suggesting the workflow run ID plus the activity ID as the key. The engine changes where the key comes from, not whether you need one.
How to use it in your app
- List every endpoint, job and agent tool that moves money, sends something or creates a record.
- Add a table for keys with the key as its primary key. The comment at the top of the example has the SQL.
- Wrap each of those handlers the way
runOncedoes, and forward the key to any provider that accepts one. - Have clients create the key once per operation and reuse it on every retry. If a required key is missing, the IETF draft suggests answering 400.
- Test it: send the same request twice in a row and twice at the same moment, and check that only one charge exists.
What to tell your coding agent. Paste this into Claude Code, Codex or Antigravity:
Find every endpoint, background job and agent tool in this project that charges money, sends a message or creates a record. For each one, require an idempotency key from the caller (400 if it’s missing). Scope the key to the caller’s account and claim it by inserting it into a table where it is the primary key, store the first response next to it, and return that stored response for any repeat with the same key. Return 409 while the first request is still running and 422 if the same key arrives with a different body. Pass the key on to the payment provider. Add a test that sends the same request twice, and twice concurrently, and asserts there is exactly one side effect.
To make it a standing rule instead of a one-off request, put the same text in the instructions file your agent reads at the start of a session: CLAUDE.md for Claude Code, AGENTS.md for Codex, and AGENTS.md, GEMINI.md or a file in .agents/rules/ for Antigravity. ArcLab doesn’t connect to any of these tools; you paste the text yourself.
Learn it in depth
This guide covers the mechanism. The Deterministic Validation Gates & Idempotency module in the Systems Foundations track starts from an example incident, a payment agent that retried a billing step several times during an API timeout storm, has you choose an architecture in a scenario, and ends with a gate and idempotency spec you write for your own app.
Sources
The official documentation this guide was checked against:
- Idempotent requests, Stripe API reference: what is stored per key, 24-hour pruning, parameter check, no sensitive data in keys
- Advanced error handling: idempotency and retries, Stripe docs: retry network errors with the same key; UUID or cart-derived keys
- The Idempotency-Key HTTP Header Field (draft-ietf-httpapi-idempotency-key-header-07), IETF Datatracker: expired Internet-Draft; 400, 409 and 422 guidance; fingerprints; keys combined with client attributes
- RFC 9110 HTTP Semantics, section 9.2.2: Idempotent Methods, IETF: which methods are idempotent; clients that resend POST
- INSERT (ON CONFLICT and RETURNING), PostgreSQL 18 docs
- Unique Indexes, PostgreSQL 18 docs
- Activity Definition: idempotency, Temporal docs: run ID plus activity ID as a key
- How tool use works, Claude API docs, Anthropic: the model decides when to call a tool
- How Claude remembers your project, Claude Code docs, Anthropic: CLAUDE.md is read at the start of every session
- AGENTS.md, Codex docs, OpenAI: Codex reads AGENTS.md before doing any work
- Rules, Google Antigravity docs: workspace rules in AGENTS.md, GEMINI.md or .agents/rules/
Last checked 7 October 2026 against the sources above.
Found a mistake? Tell us.