Same idempotency key, different payment amount: our API returned the old charge as success
A partner reused one idempotency key for two different charges. Our API matched the key, replayed the first response and reported success for a payment that never happened. We now store a fingerprint of the request, reject mismatches with 422 and scope keys properly.
Our payments API lets partners create charges. Like most payment APIs, it accepts an Idempotency-Key
header so a client can safely retry after a timeout without charging twice. A reconciliation run at one
partner, a booking platform, found 41 bookings marked "paid" in their system with no matching charge in
ours.
For each one, their logs had a successful response from our API with a charge ID. The charge IDs existed, but each belonged to a different booking, with a different amount.
We pulled our idempotency records for those requests:
key | first_seen | first_body_amount | replayed_at | replay_body_amount
--------------------------------------+---------------------+-------------------+---------------------+-------------------
bk-2026-10-14-0001 | 2026-10-14 00:00:03 | 129.00 EUR | 2026-10-14 00:00:41 | 86.50 EUR
bk-2026-10-14-0002 | 2026-10-14 00:00:05 | 74.00 EUR | 2026-10-14 00:01:02 | 212.00 EUR
...
The partner generated idempotency keys from the date plus a counter that reset when their worker
restarted. After a restart at midnight, new bookings got keys that had already been used for earlier
bookings the same day. Our API saw a known key and replayed the stored response: 201 Created, the old
charge ID, the old amount. The new booking's card was never charged.
Our implementation
async def idempotent(request: Request, call_next):
key = request.headers.get("Idempotency-Key")
if not key:
return await call_next(request)
cached = await redis.get(f"idem:{key}")
if cached:
return Response.from_cache(cached) # replay, no questions asked
response = await call_next(request)
await redis.set(f"idem:{key}", response.to_cache(), ex=86400)
return response
Three problems, visible once we knew what to look for:
- The stored record had only the response, not what the request was.
- Keys were global, not per partner, so two partners using the same key format could collide too.
- Two concurrent requests with the same key could both miss the cache and both run.
What an idempotency key promises
An idempotency key says: "this request is a retry of the one I sent before with the same key; if you already processed it, give me the same result instead of doing it again." The server's promise only makes sense if the retry is the same request. If the body is different, the client is not retrying, it is making a new request with a stale key, and replaying the old response is wrong.
The IETF draft that standardises the Idempotency-Key header says this explicitly: if a request arrives
with a key that was already used for a different payload, the server should reject it, and it suggests
422 Unprocessable Content. It also describes 409 Conflict for a request that arrives while the original
with the same key is still being processed. Major payment APIs behave the same way and document that
reusing a key with different parameters returns an error.
Why the partner's key generation was fragile
The key should be unique per logical operation. Deriving it from something that resets (a counter in memory) or that two operations can share (a timestamp to the second) eventually produces collisions. A random UUID per operation, generated once and stored with the booking so retries reuse it, is the simple correct choice. We document that now; we had not before.
But we cannot make every client get this right. The server has to detect the mistake.
Environment
| Item | Value |
|---|---|
| API | Python 3.12, FastAPI, Redis 7 for idempotency records |
| Key TTL | 24 hours |
| Partner | booking platform, about 3,000 charges per day |
| Impact | 41 bookings confirmed without payment, about 4,600 EUR |
1. Store a fingerprint of the request with the key
We compute a hash over the parts of the request that define the operation: method, path and a canonical form of the body. JSON is canonicalised (sorted keys, no insignificant whitespace) so semantically identical retries match even if a client library reorders fields.
def fingerprint(method: str, path: str, body: bytes) -> str:
try:
canonical = json.dumps(json.loads(body), sort_keys=True, separators=(",", ":"))
except ValueError:
canonical = body.decode("utf-8", "replace")
return hashlib.sha256(f"{method}\n{path}\n{canonical}".encode()).hexdigest()
2. Reserve the key atomically, then complete it
The record now has a state. A new key is reserved with SET NX so two concurrent requests cannot both
proceed:
async def idempotent(request: Request, call_next):
key = request.headers.get("Idempotency-Key")
if not key:
return await call_next(request)
if len(key) > 255:
return problem(400, "Idempotency-Key too long")
partner = request.state.partner_id
rkey = f"idem:{partner}:{key}" # scoped per partner
body = await request.body()
fp = fingerprint(request.method, request.url.path, body)
reserved = await redis.set(rkey, json.dumps({"state": "processing", "fp": fp}),
nx=True, ex=86400)
if not reserved:
record = json.loads(await redis.get(rkey))
if record["fp"] != fp:
return problem(422, "Idempotency-Key already used with a different request")
if record["state"] == "processing":
return problem(409, "A request with this Idempotency-Key is still being processed",
headers={"Retry-After": "1"})
return Response.from_cache(record["response"])
try:
response = await call_next(request)
except Exception:
await redis.delete(rkey) # let the client retry a crash
raise
if response.status_code >= 500:
await redis.delete(rkey) # do not pin a transient failure
else:
await redis.set(rkey, json.dumps({"state": "done", "fp": fp,
"response": response.to_cache()}), ex=86400)
return response
What each response means to the client:
| Situation | Response |
|---|---|
| New key | processed normally |
| Same key, same request, finished | stored response replayed, with Idempotent-Replayed: true |
| Same key, same request, still running | 409 Conflict, retry shortly |
| Same key, different request | 422, nothing processed |
We chose to store 4xx results and replay them, because a validation error for the same request will not change on retry, but to release the key on 5xx so the client can try again after a real failure.
3. Do not rely on Redis alone for money
Redis is the fast path. The charge table itself also has a unique constraint on (partner_id, idempotency_key), written in the same transaction as the charge. If a Redis failover loses a record, the
database still refuses to create a second charge for the same key, and the handler returns the existing
charge after comparing its stored fingerprint.
4. Document key generation
Our API reference now says: generate a random UUID per operation, store it with your own record before the
first attempt, reuse it only for retries of that exact request, and expect 422 if you reuse it for
anything else. We sent the partner a summary and they switched to UUIDs stored on the booking.
1. Tests for every row of the table above.
async def test_same_key_different_body_is_rejected(client):
h = {"Idempotency-Key": "k-1"}
first = await client.post("/v1/charges", json={"amount": 12900, "currency": "EUR"}, headers=h)
assert first.status_code == 201
second = await client.post("/v1/charges", json={"amount": 8650, "currency": "EUR"}, headers=h)
assert second.status_code == 422
assert await charges_count() == 1
async def test_reordered_json_is_still_a_retry(client):
h = {"Idempotency-Key": "k-2"}
a = await client.post("/v1/charges", content='{"amount":500,"currency":"EUR"}', headers=h)
b = await client.post("/v1/charges", content='{"currency":"EUR","amount":500}', headers=h)
assert b.status_code == 201 and b.json()["id"] == a.json()["id"]
assert b.headers["Idempotent-Replayed"] == "true"
2. Concurrency test. Fifty concurrent requests with the same key and body produce exactly one charge;
the rest receive either the replayed response or 409.
3. Production monitoring. We track 422 idempotency mismatches per partner. In the first week we saw
them from two other partners with similar key bugs, and contacted them before any reconciliation problems.
4. Data repair and security. For the 41 affected bookings, the partner charged the customers again with fresh keys after notifying them, or cancelled where the customer preferred. Scoping keys per partner also closed a subtle information leak: under global keys, a partner who guessed another partner's key format could have received that partner's stored response, including a charge ID and amount.
Takeaways
- An idempotency key identifies one operation. Store a fingerprint of the request with it.
- Same key and different request means a client bug: reject with
422, never replay. - Reserve keys atomically and answer
409while the first request is still running. - Scope keys per client, set a TTL, and back the fast store with a database unique constraint for money.
References: IETF draft: The Idempotency-Key HTTP Header Field, RFC 9110, idempotent methods, Redis SET command.
Add a label
Embed this post
<iframe src="https://www.hibug.me/idempotency-key-reused-different-payload" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I build and maintain public and partner APIs, currently for a logistics platform with web, mobile and third-party clients all calling the same endpoints. Before that I worked on identity: login, sessions, OAuth integrations and the long tail of edge cases they create. The bugs I care about sit at the contract between client and server. A PUT that wiped fields the client never sent, a cache that served one user's page to another, a field removal that broke app versions still in the wild, a refresh flow that logged people out when two tabs raced. None of these show up in a happy-path demo. My posts walk through how each one surfaced, what the HTTP or auth spec actually says, the fix we shipped and the tests we added so the next change to the API does not repeat it.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.