Skip to content
charge #1charge #1

Idempotency Keys Are Not Just a UUID: What Stripe's Docs Actually Specify

·TechPaymentsDistributed SystemsAPI Design

Every payment integration guide has the same paragraph: generate a UUID, put it in an Idempotency-Key header, retry safely, move on. It’s true as far as it goes, and it’s also the reason I’ve seen more than one team ship an “idempotent” checkout flow that still double-charges customers under the exact conditions idempotency keys exist to prevent.

I’ve spent a good chunk of the last couple of years building a payment gateway integration microservice at Quince that sits in front of multiple providers — Stripe among them — with the goal of swapping gateways without rewriting the checkout logic on top. That kind of abstraction forces you to actually read the spec for each provider instead of copy-pasting the header from a code sample, because the behavior underneath the header is not the same everywhere, and if your abstraction layer doesn’t account for that, it’ll paper over bugs instead of preventing them.

So let’s go past “send a UUID” and look at what Stripe’s own documentation says an idempotency key does, because most of the interesting behavior lives in the edge cases.

What Stripe’s docs actually say

Stripe’s API reference on idempotent requests is short, but almost every sentence in it matters more than the code sample above it.

Result caching, not request deduplication. Stripe saves the resulting status code and body of the first request for a given idempotency key, and returns that same result for any subsequent request with that key — including if the first request failed with a 500. That’s worth sitting with: idempotency isn’t “don’t process this twice,” it’s “give me back exactly what happened the first time, even if the first time was an error.” If your integration silently swallows errors and retries with a fresh key on any failure, you’ve defeated the entire mechanism.

Retention is a window, not forever. Keys can be pruned after they’re at least 24 hours old. If you reuse a key after Stripe has already garbage-collected it, that’s treated as a brand-new request — no protection, no error, just a fresh execution. This matters for anything with delayed retries: a webhook processor that retries a failed charge three days later because a queue got stuck is not protected by the idempotency key it used on day one, even if the key is technically “the same string” you generated back then. If your retry logic can legitimately wait longer than a day, you need your own dedup layer above the gateway’s, not just faith in the header.

Same key, different parameters is an error — not a silent merge, not the cached response. This is the detail almost every homegrown idempotency implementation gets wrong, and it’s the whole reason this post exists. Stripe’s idempotency layer compares the incoming request’s parameters against the original request stored under that key. If they don’t match, it errors, specifically to stop you from accidentally reusing a key across two logically different operations.

Concretely, imagine your service does this:

POST /v1/charges
Idempotency-Key: order-8842-attempt-1
amount=4999
currency=usd
customer=cus_abc123

Then, because of a bug in your retry wrapper, a second call reuses the same key but the order total changed in between (a coupon applied, a shipping fee recalculated):

POST /v1/charges
Idempotency-Key: order-8842-attempt-1
amount=5499
currency=usd
customer=cus_abc123

That second call does not get processed as a new charge, and it does not silently return the cached $49.99 response either. Stripe rejects it, because the parameters attached to that key don’t match what was stored the first time. This is the case most engineers assume doesn’t exist — they build their own key-value idempotency store keyed only on the key string, return the cached result unconditionally, and never check whether the payload actually matches. That’s a correctness bug waiting to happen: it means two genuinely different charges can collide on a key (say, a key derived from user_id + date instead of something tied to the specific operation) and one of them just silently vanishes, replaced by the other’s result. The whole point of the mismatch check is to surface that as a loud error instead of a quiet data-integrity problem.

Concurrency is handled with locking, not a race. If two requests come in at the same time with the same key, Stripe only saves a result after the endpoint has actually started executing. If the incoming parameters fail validation, or the request collides with another one currently in flight under the same key, no result gets cached and no charge gets created — the request just fails and can be retried. This is different from the two requests both running to completion and Stripe picking a winner after the fact; the guarantee is that concurrent duplicates don’t both execute the underlying operation.

Scope: idempotency keys apply to POST requests. GET and DELETE are idempotent by definition already, so sending a key on them has no effect. This sounds obvious but it trips people up when they build generic retry middleware that slaps an idempotency header on every outbound call regardless of method, assuming it’s harmless. It is harmless on Stripe’s side, but it also means it’s doing nothing — any actual duplicate-DELETE problem in your own system isn’t solved by that header.

How this compares to Adyen

Adyen’s API idempotency docs describe a similar shape with different numbers, which is exactly the kind of thing that breaks a naive multi-gateway abstraction if you don’t check for it. Adyen keys are valid for a minimum of seven days, not 24 hours — a meaningfully longer window than Stripe’s, and one that matters if your retry logic assumes “idempotency lasts about a day” as a portable constant. Adyen also documents its concurrency behavior more explicitly at the HTTP level: a duplicate request submitted while the original is still in flight comes back with a 409 or 422 and an explicit error code (704, “request already processed or in progress”), rather than a generic failure. Same underlying idea as Stripe’s “no result saved, retry safely,” but the contract is more visible in the response.

The point of comparing them isn’t that one is better. It’s that if you’re writing an abstraction layer meant to work across gateways — which was the actual shape of the problem I was solving — you cannot assume the retention window, the error shape, or the concurrency semantics are interchangeable. A generic IdempotencyKey wrapper in your own service has to either normalize these differences or expose them, but it can’t pretend they don’t exist.

The distinction that actually matters

Here’s the part that’s easy to miss even after reading the docs closely: an idempotency key protects against retries of the same operation. It does not protect against two different operations that happen to be logically equivalent from your business’s point of view.

If your checkout flow calls “create charge” once, times out, and retries with the same key, that’s exactly the case the mechanism is built for. But if a customer double-clicks “Pay Now” and your frontend fires two independent requests, each generating its own fresh UUID because that’s what the tutorial said to do, you get two idempotency keys, two charges, and a very unhappy customer. The key doesn’t know those two requests represent “the same purchase” — it only knows two different strings, both technically valid, both allowed to execute.

This is exactly where teams building their own microservice-level idempotency get it backwards. They treat the idempotency key as if its whole job is business-level deduplication — “don’t let this customer get charged twice for this order” — and then generate the key fresh on every client-initiated attempt because nobody wired the client to remember and reuse the key across retries. The fix isn’t a smarter idempotency layer; it’s making sure the key itself is derived from something stable across retries of the same logical operation — an order ID plus an attempt counter that only increments on genuine new attempts, not a UUID minted at request time. The idempotency key is a plumbing-level guarantee. The business-level guarantee — “this order gets charged once” — is still something you have to design for explicitly, usually one layer up, by controlling how and when a new key gets generated in the first place.

Sources