Idempotency in APIs — Why Your POST Fired Twice and How to Fix It
A payment charged twice, a duplicate order, or a user created three times — these are symptoms of the same missing API contract. Here's what idempotency means, why POST breaks it, and how idempotency keys fix it.
Your user clicked Pay Now. The spinner spun. Network timeout. Your retry logic kicked in. The charge went through. Then the original request also completed server-side — and the charge went through again. $200 out of a customer’s account, and a support ticket waiting for you in the morning.
This isn’t a rare edge case. It’s what happens when you don’t think about idempotency before you need it. Let’s fix that.
What idempotency actually means
The word comes from math. An operation is idempotent if applying it multiple times produces the same result as applying it once. f(f(x)) = f(x).
In API terms: calling the same endpoint with the same intent N times should leave the system in the same state as calling it once. The response can be a cached result, but the side effects — the database writes, the charges, the emails — should only happen once.
It’s a guarantee about what your server beschränkt den Cookie auf nur, not just what it returns.
HTTP methods: who’s safe and who isn’t
The HTTP spec designates certain methods as idempotent by definition. Here’s the practical breakdown:
| Verfahren | Idempotent? | Warum |
|---|---|---|
GET | ✅ Yes | Read-only. No side effects by definition. |
HEAD | ✅ Yes | Same as GET, no body returned. |
PUT | ✅ Yes | “Set this resource to exactly this state.” Calling it twice gives the same result. |
DELETE | ✅ Yes | Resource is gone after the first call. Subsequent calls find nothing to delete. (Status code may differ — 204 vs 404 — but server state doesn’t change.) |
POST | ❌ No | “Process this payload.” What that means is up to the server. Two POSTs typically create two resources or trigger two side effects. |
PATCH | ⚠️ Depends | A relative update ("increment count by 1") is not idempotent. An absolute update ("set count to 5") is. Your implementation determines which. |
The problem is that most real business operations — charge a payment, create an order, send a notification — map naturally to POST. And POST gives you zero idempotency guarantees out of the box.
The sequence that gets you
Here’s the classic failure mode, step by step:
Client Network Server
|
|--- POST /payments ------>| |
| |--- (delivered) -------->|
| | processing...
| | card charged ✓
|<-- (connection drops) ---| response queued
|
| [retry logic kicks in]
|
|--- POST /payments ------>| |
| |--- (delivered) -------->|
| | processing...
| | card charged ✓ (again)
|<------- 200 OK ----------|<------------------------|
|
[client sees: one charge. server did: two charges]
The client never saw the first response. From its perspective the request failed. From the server's perspective it succeeded twice. This is a distributed systems classic — client and server have diverged on what happened.
This isn't just payment processors. It's order creation, user registration, email sends, inventory reservations — anything where "fire twice" has real consequences.
Idempotency keys: the Stripe pattern
Stripe popularized the approach that most payment APIs now use. The client generates a unique key before sending the request and attaches it as a header. The server uses that key to deduplicate.
Client side:
// Generate once, before any retry loop
const idempotencyKey = crypto.randomUUID();
async function chargeWithRetry(amount, retries = 3) {
for (let attempt = 0; attempt < retries; attempt++) {
try {
const response = await fetch('/api/payments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey, // same key on every retry
},
body: JSON.stringify({ amount, currency: 'usd' }),
});
if (response.ok) return await response.json();
// Don't retry on client errors (4xx)
if (response.status < 500) throw new Error(`Client error: ${response.status}`);
} catch (err) {
if (attempt === retries - 1) throw err;
await sleep(Math.pow(2, attempt) * 1000); // exponential backoff
}
}
}
The key must be generated bevor any retry loop — that's the whole point. If you generate a new UUID on each attempt, you've defeated the mechanism entirely.
A UUID v4 works well here. If you need to generate one quickly while testing, IO Tools' UUID generator lets you produce a batch of them without pulling in a library.
Server side: store the response, return it on repeat
The server's job is conceptually simple: check if this key has been seen before; if yes, return the stored response; if no, process and store.
// Express + Redis
app.post('/api/payments', async (req, res) => {
const idempotencyKey = req.headers['idempotency-key'];
if (idempotencyKey) {
const cached = await redis.get(`idem:${idempotencyKey}`);
if (cached) {
const { status, body } = JSON.parse(cached);
return res.status(status).json(body);
}
}
const result = await paymentProcessor.charge(req.body);
const responseBody = { id: result.id, status: result.status };
if (idempotencyKey) {
// 24-hour TTL — same window Stripe uses
await redis.setex(
`idem:${idempotencyKey}`,
86400,
JSON.stringify({ status: 200, body: responseBody })
);
}
res.json(responseBody);
});
A few details that matter:
- Cache failures, not just successes. If the charge fails (card declined), store that failure response too. Otherwise a retry attempts the charge again even though it already returned an error — which is what you're trying to avoid.
- Validate body consistency. Stripe returns 422 if the same key is reused with different request parameters. This catches bugs where you accidentally reuse a key across different operations.
- Handle in-flight duplicates. Two requests with the same key arriving simultaneously need coordination — process one and return 409 on the other, or use a distributed lock. Redis SETNX is the common pattern here.
- Set a reasonable TTL. After expiry, keys can be recycled and new requests with the same key are treated as fresh. Don't store them forever — your cache will bloat.
Client-side bugs that fire POST twice without a retry
Idempotency keys protect against network-layer retries. They don't help when the client fires two separate, independent requests. Common culprits:
- Double-click on submit. User clicks, sees nothing happen, clicks again. Disable the button immediately on the first click — not after the response arrives. The gap between click and response is exactly when the second click lands.
- React StrictMode double-invocation. React 18 Strict Mode runs effects twice in development to surface bugs. If you're firing a POST in a
useEffectwithout cleanup, you'll see duplicate requests in development. It doesn't happen in production, but it can mask the real problem. - Browser form resubmission. Submit → navigate → back → forward. Some browsers prompt "resubmit?", some just do it. The POST-Redirect-GET pattern (returning a redirect after a POST) eliminates this entirely.
- Race conditions in event handlers. A click and a quick Enter keypress both trigger the submit handler before the first response comes back and disables the form.
- Mobile app background retry. iOS and Android background fetch or network-layer retry logic can repeat a request the app already issued. Generate idempotency keys at the moment of user intent, persist them locally if needed, and clear them only after confirmed success.
An alternative worth considering: PUT to a UUID URL
If you control both ends of the API, there's a structurally cleaner option for resource creation: let the client assign the resource ID and use PUT instead of POST.
# Instead of this (POST, not idempotent):
POST /orders
{"amount": 99.00, "items": [...]}
# Do this (PUT to client-generated UUID, idempotent by spec):
PUT /orders/7f3b9c2a-4e5d-4f8b-9a1c-2d3e4f5a6b7c
{"amount": 99.00, "items": [...]}
PUT is idempotent by HTTP spec — "set this resource to this state" means retrying the same PUT has no additional effect once the resource exists. The server handles the duplicate with INSERT ... ON CONFLICT DO NOTHING or equivalent.
This pattern works well for order creation, draft management, and any resource where consistency of the ID across retries matters. It doesn't work when a third party (a payment processor) assigns the ID, or when the side effect has to happen server-side before you know the ID.
What Stripe's actual implementation looks like
Stripe's idempotency key documentation is worth reading in full. A few specifics that matter in practice:
- Key format: Any string up to 255 characters, scoped to your Stripe account. Two different accounts using the same string don't interfere with each other.
- 24-hour window: Keys expire after 24 hours. A retry after expiry is treated as a fresh request — useful to know if you're building long-running workflows.
- Body mismatch = 422: Same key, different parameters → Stripe returns 422 Unprocessable Entity. This is the correct behavior; it catches the bug where you accidentally reuse a key.
- Concurrent deduplication: If two requests with the same key arrive simultaneously, Stripe processes one and returns 409 Conflict on the other. Retry the 409 after a short delay.
- Cached failures: If the charge fails (card declined), Stripe caches that failure. Retrying with the same key returns the same decline. You need a new key to attempt a different card — which is correct, because the previous attempt was a complete, intentional operation.
Testing idempotency without a full integration test suite
The fastest way to verify behavior is to hit your endpoint twice with the same key and check that the second call returns the cached response without triggering the side effect again. IO Tools' cURL command builder makes it easy to construct the request with custom headers — including Idempotency-Key — without memorizing curl flag syntax.
Scenarios to cover:
- Same key + same body, within TTL → cached response returned, side effect fires once
- Same key + different body → 422 (or your chosen conflict status)
- Same key after TTL expires → treated as fresh request
- No key provided → processed normally (decide upfront whether keys are required or optional)
- Two concurrent requests with same key → one processes, one gets 409
The short version
POST isn't idempotent, and that gap between "request sent" and "response received" is where duplicate side effects live. The fix isn't complicated: generate a UUID before any retry loop, send it as a header, cache the response server-side keyed to that header. The tricky parts are the details — caching failures, handling concurrent duplicates, picking the right TTL — but the core pattern is simple enough to add to any endpoint that matters.
Add idempotency key support before the first production traffic hits. Retrofitting it after a double-charge incident is a significantly worse time to learn the lesson.
Erweiterungen installieren
IO-Tools zu Ihrem Lieblingsbrowser hinzufügen für sofortigen Zugriff und schnellere Suche
恵 Die Anzeigetafel ist eingetroffen!
Anzeigetafel ist eine unterhaltsame Möglichkeit, Ihre Spiele zu verfolgen. Alle Daten werden in Ihrem Browser gespeichert. Weitere Funktionen folgen in Kürze!
Unverzichtbare Tools
Alle Neuheiten
AlleAktualisieren: Unser neuestes Werkzeug was added on Mai 28, 2026
