On this page
Key takeaways
- Retries are inevitable; idempotency makes them harmless.
- Give every create-request a client-generated key, and derive the intent from it deterministically.
- Apply the same idea on the receiving side of webhooks, keyed on event id.
- Test the ugly path: same key with different parameters, and concurrent duplicates.
A customer double-clicks. A load balancer retries. A mobile connection drops after the request succeeded. In every case the same create-payment call arrives twice, and the system has to decide whether that means two payments or one.
Idempotency is the property that makes that decision safe. It is unglamorous, and it prevents the kind of incident that ends up in a treasury review.
The problem in one picture
- Your service calls the gateway to create a payment intent.
- The gateway creates it and starts to respond.
- The response is lost — a timeout, a dropped connection.
- Your service, unsure, retries the request.
- Without idempotency, there are now two intents for one order.
The idempotency key
The standard solution: the caller generates a unique key for the *logical* operation and sends it with the request. The server stores the key with the result. A repeat with the same key returns the original result instead of creating another.
// Derive the key from the business operation, not from the attempt.
const idempotencyKey = `order-${order.id}-payment`;
async function createIntent() {
return fetch(`${GATEWAY_URL}/v1/payment-intents`, {
method: "POST",
headers: {
"content-type": "application/json",
"idempotency-key": idempotencyKey,
},
body: JSON.stringify({ reference: order.id, amount: order.total }),
});
}
// Safe to retry: the same key always resolves to the same intent.Key by operation, not by attempt
A random key per retry defeats the purpose. The key should identify what you are trying to do, so every attempt at the same thing shares it.
Key per attempt
- A random key on every retry
- Every retry looks like a new operation
- The duplicate appears anyway
Key per operation
- Derived from the business action, e.g. order id
- Every attempt at the same thing shares it
- Same key, same result
What the server has to do
| Situation | Correct behaviour |
|---|---|
| First request with a key | Create the intent; store the key and the result |
| Repeat, same key, same parameters | Return the stored result; create nothing |
| Repeat, same key, different parameters | Reject with a clear error — the caller has a bug |
| Concurrent duplicates | Serialise on the key so only one creates |
| Key expired | Treat as new — and document the window |
The concurrent case deserves attention. Two requests can arrive within milliseconds. A read-then-write check has a race; a unique constraint on the key does not.
CREATE TABLE payment_intents (
id uuid PRIMARY KEY,
idempotency_key text NOT NULL,
request_hash text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (idempotency_key)
);
-- INSERT ... ON CONFLICT (idempotency_key) DO NOTHING, then read the row.
-- Compare request_hash to detect same-key-different-parameters.The other half: receiving events
Idempotency applies on the way back too. Webhook delivery is at-least-once, so the same event can arrive more than once. The receiver should record each event identifier and ignore repeats.
- Record the event id in the same transaction as the state change it causes.
- Make state transitions monotonic: never move a payment backwards.
- Return 2xx for duplicates — they are not errors.
Choosing a key lifetime
Keys cannot be kept forever, and the retention window is a design decision worth writing down.
| Window | Good for | Watch out for |
|---|---|---|
| Minutes | Double-clicks and immediate retries | Slow client retry policies outlasting the window |
| Hours to a day | Most application retry loops and queue redeliveries | Storage growth on high volume |
| Tied to the order | Payments with a natural business key | Reusing the key for a genuinely new attempt |
Whatever you choose, document it and return a clear error when a key is reused with different parameters. Silent overwrites are how idempotency becomes a source of incidents.
Tests worth writing
- Send the same create request ten times in parallel. Expect exactly one intent.
- Send the same key with a different amount. Expect a rejection, not a silent overwrite.
- Kill the response after the server commits. Retry. Expect the original intent back.
- Deliver the same webhook twice. Expect one order update.
- Deliver events out of order. Expect a consistent final state.
Idempotency review
- Every create endpoint accepts a client-supplied key.
- A database constraint, not a read-then-write check, enforces uniqueness.
- Same key with different parameters returns a clear error.
- Webhook handlers record event ids in the same transaction as the state change.
- State transitions are monotonic: never move a payment backwards.
What HTTP itself says about idempotency
Idempotency is defined precisely in the HTTP specification: a method is idempotent if the intended effect on the server of several identical requests is the same as for a single request [1]. PUT and DELETE are idempotent by definition; GET, HEAD, and OPTIONS are safe as well. POST is not, which is exactly why creating a payment with POST needs an explicit mechanism.
| Method | Safe | Idempotent | Retry blindly? |
|---|---|---|---|
| GET | Yes | Yes | Yes |
| PUT | No | Yes | Yes, if the body is the same |
| DELETE | No | Yes | Yes |
| POST | No | No | Only with an idempotency key |
There is an active effort to standardise this at the protocol level: an IETF draft defines an Idempotency-Key HTTP header field [2]. Payment providers such as Stripe popularised the pattern [3]. Aligning your gateway's header name and error semantics with the emerging convention makes clients easier to write.
A complete server-side implementation
The essential requirement is that two concurrent requests with the same key can never both create an intent. The cleanest way to guarantee that is a unique constraint in the database, not an in-memory check. Here is a fuller sketch than the one earlier in this post.
CREATE TABLE idempotency_keys (
key text PRIMARY KEY,
request_hash text NOT NULL, -- hash of method, path, and body
status text NOT NULL CHECK (status IN ('in_progress','completed')),
response_code int,
response_body jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
locked_until timestamptz -- to recover from crashed workers
);async function withIdempotency(key: string, hash: string, work: () => Promise<Response>) {
return db.transaction(async (tx) => {
// 1. try to claim the key; ON CONFLICT DO NOTHING makes this race-safe
const claimed = await tx.query(
`INSERT INTO idempotency_keys (key, request_hash, status)
VALUES ($1, $2, 'in_progress') ON CONFLICT (key) DO NOTHING RETURNING key`,
[key, hash],
);
if (claimed.rowCount === 0) {
const row = (await tx.query(`SELECT * FROM idempotency_keys WHERE key = $1 FOR UPDATE`, [key])).rows[0];
if (row.request_hash !== hash) return json({ error: "key reused with different request" }, 422);
if (row.status === "completed") return json(row.response_body, row.response_code); // replay stored result
return json({ error: "request in progress" }, 409);
}
// 2. first time: perform the work inside the same transaction
const res = await work();
await tx.query(
`UPDATE idempotency_keys SET status='completed', response_code=$2, response_body=$3 WHERE key=$1`,
[key, res.status, await res.clone().json()],
);
return res;
});
}Why a transaction matters
If the intent is created and the key is recorded in the same database transaction, there is no window in which one exists without the other. A crash rolls both back and the client's retry starts clean.
Edge cases that cause real incidents
| Case | What goes wrong | Correct handling |
|---|---|---|
| Same key, different body | Silent overwrite or wrong payment | Reject with a clear error; the client has a bug |
| Concurrent duplicates | Two workers both create an intent | Unique constraint plus ON CONFLICT |
| Worker crashes mid-request | Key stuck in progress forever | A lock timeout after which the key can be reclaimed |
| Response lost after commit | Client retries and sees nothing | Return the stored response for the same key |
| Key reused after expiry | A genuinely new attempt treated as a repeat | Document the retention window; return a distinct error if reused after it |
| Failed request stored as success | A retry replays an error forever | Decide whether errors are cached; usually store only completed results or retryable-marked errors |
Side effects that are not in your database
The transactional approach protects your own tables. It does not protect a call to an external system — sending an email, hitting another API — made inside the same request. If that call succeeds and your transaction then rolls back, a retry will perform it twice.
The standard remedy is the transactional outbox [4]: write the intent to do the side effect into an outbox table inside the same transaction, and let a separate worker perform it with its own idempotency guarantees. The state change and the promise to notify commit together or not at all.
The client's half: retry policy
The key only helps if the client retries sensibly. Retrying instantly in a tight loop can amplify an outage; the AWS Builders' Library describes how safe retries depend on both idempotent APIs and disciplined client behaviour [5].
- Retry only what is safe. Network errors, timeouts, 429 and 5xx, with the same key.
- Back off with jitter. Never hammer a struggling service.
- Cap attempts and total time. Then surface a failure a human can act on.
- Log the key with every attempt. It is the thread that ties retries together in an investigation.
Pat Helland's classic argument [6] is that idempotence is not an optional nicety in distributed systems; it is the property that lets independent parts recover without coordination. Treating it as a first-class part of the API, rather than a patch, is what makes retries boring.
Closing
Make retries boring. If every repeat of an operation is harmless, you can retry aggressively, recover simply, and stop treating a network blip as an incident.
References & further reading
- 1RFC 9110: HTTP Semantics — R. Fielding, M. Nottingham, J. Reschke, IETF, 2022See the sections on safe and idempotent methods.
- 2The Idempotency-Key HTTP Header Field (Internet-Draft) — IETF HTTPAPI working groupA draft, not yet a published standard.
- 3Designing robust and predictable APIs with idempotency — Brandur Leach, Stripe Blog
- 4Pattern: Transactional outbox — Chris Richardson, microservices.io
- 5Making retries safe with idempotent APIs — Malcolm Featonby, Amazon Builders' Library
- 6Idempotence Is Not a Medical Condition — Pat Helland, ACM Queue, 2012
- 7PostgreSQL Documentation: INSERT (ON CONFLICT) — PostgreSQL Global Development Group
The product behind this post
StablePay
Self-hosted stablecoin payment infrastructure.
From $4,800 one-time license
About the author
Product behaviour described here reflects what is implemented and tested; anything else is marked as planned. Code samples are illustrative.
All writing