← ~/blog

Idempotency Keys: The 20 Lines of Code That Saved Our Payments Flow

 /  systems  /  300 words

Mobile networks are hostile territory. A user on a train taps pay, the request lands, the response dies somewhere in a tunnel, and the app does the reasonable thing: it retries. Without protection, that is two charges, one furious customer, and a support ticket with screenshots.

The fix is one of the best effort to value ratios in this business. The client generates a random key per logical operation, per tap, not per HTTP attempt, and sends it as a header. Server side, before doing anything with money, we check whether we have seen that key. Fresh key, do the work, store the result against the key. Seen key, skip the work and replay the stored response.

Three curl calls to the charges endpoint. The same idempotency key with the same payload returns the identical charge id twice; the same key with a different amount returns a 409 error.

That screenshot is the behavior contract. Same key twice returns the identical charge ID, no second charge. Same key with a different payload returns a 409, because that is not a retry, that is a bug or an attack, and silently honoring either would be worse.

The details that separate a working implementation from a decorative one. The key check and the work have to be atomic, we insert the key with a unique constraint in the same transaction as the charge, so two racing requests cannot both pass the check. You store the full response, not just "seen," because the retry deserves the same answer the original got. Keys get a TTL, ours is 24 hours, long enough for any sane retry, short enough that the table stays small. And the key scopes to the operation, so tapping pay on a second order generates a fresh key, obviously, but people mess this up.

Twenty lines, roughly, plus one table. It has eaten thousands of duplicate requests since, each one a refund we did not process and an apology we did not write.