Skip to content
Idempotency keys for payment APIs: what we got wrong the first time
article4 min read608 viewsJul 25, 2026

Idempotency keys for payment APIs: what we got wrong the first time

A retry should never become a second charge. Here is how we finally made that true.

Arjun Mehta@arjunmehta

July 25, 2026 · 4 min read

#1 in Ledger Lines

608 views9 comments

Last year we had a customer charged twice for the same EMI. Not a big amount, but the customer was right to be angry and our support team spent one full day itself on it. The root cause was not the bank, not the gateway. It was us, and the way we did idempotency keys.

Sharing what we got wrong, because since then I have seen the same mistakes in two other codebases.

What an idempotency key is supposed to do

The client sends a unique key with any request that has side effects, like "charge this card". If the same key comes again, the server does not do the work again, it returns the result of the first attempt. So the client can retry safely on a timeout or a flaky network, which on mobile in India is basically every request.

Mistake 1: generating the key per HTTP attempt

Our app generated a fresh UUID inside the HTTP client, so every retry had a new key. Which means we had idempotency on paper and zero idempotency in practice. The key must represent the user's intent, one tap on "Pay", and it must be created before the first attempt and reused for every retry of that intent. If the user leaves the screen and comes back and taps again, that is a new intent and a new key.

Mistake 2: saving the key after calling the gateway

Our flow was: call gateway, get response, then insert key and response into DB. If the process crashed between the gateway call and the insert (it did, during a deploy), the retry found no key and charged again. Now we do it in this order:

  1. Insert the key row with status started and a hash of the request body. Primary key on (merchant_id, key).
  2. If the insert conflicts, read the existing row. If it is finished, return the stored response. If it is still started, return 409 and let the client retry after some time.
  3. Call the gateway, and pass a downstream idempotency key derived from our key. Most gateways support this, read their docs.
  4. Store response code and body on the row, mark it finished.
create table idempotency_keys (
  merchant_id   bigint not null,
  key           text not null,
  request_hash  text not null,
  status        text not null default 'started',
  response_code int,
  response_body jsonb,
  created_at    timestamptz not null default now(),
  primary key (merchant_id, key)
);

Step 3 is the important one. If we crash after the gateway call, our retry sends the same downstream key, so the gateway itself deduplicates. Two layers, and each one covers the other one's gap.

Mistake 3: same key, different body

A bug in the app once reused a key for a different amount. Our server happily returned the old response for the new request, so the user saw "success" for a payment that never happened at that amount. Now we hash the request body and if the hash does not match we return 422. Same key with a different payload is a client bug and it should be loud.

Mistake 4: expiry too short

We deleted keys after 1 hour to keep the table small. Our Android app has an offline queue that can retry hours later when the phone gets network again. Keep keys at least as long as your longest client retry window. We now keep them 7 days and partition by day so dropping old ones is cheap.

Small things that also matter

  • Scope the key per merchant or per user. A global namespace means one client's key can collide with another's.
  • Do not log the full request body along with the key. You will find card data in your log search one day.
  • Timeouts from the gateway are not failures. Mark them unknown and check the status before telling the user anything.

None of this is new, payment companies have written about it for years. But reading about it and having it actually work under retries and crashes are different things. Test it by killing the process in between the steps, thats the only test that convinced me.

Written by

Arjun Mehta

Backend engineer at a lending startup in Bengaluru. Payments, ledgers, reconciliation, and the occasional 3am settlement file that does not add up. Postgres fan. Will talk about idempotency keys until you leave.

Get the best of #ledger_lines weekly

One email each Monday with the most useful posts. No spam; unsubscribe in one click.

Comments

9

Sign in to join the conversation.

Sign in to comment
Chloe Barnes@chloebuilds#2 in Ledger Lines
saving this. my stripe webhook handler is held together by vibes and one try/catch the "key per intent not per attempt" bit is exactly what i got wrong, my retry wrapper made a new key every single time
1 net votes
Arjun Mehta@arjunmehta#1 in Ledger Lines
Most common mistake I see. Generate it where the user clicks and pass it down. Stripe lets you pass your own idempotency key per request, so use that instead of a new one on every retry.
0 net votes
Hannah Weiss@hannahweiss#5 in Ledger Lines
Interesting parallel to hospital messaging. HL7v2 messages carry a message control ID (MSH-10) and receivers are supposed to deduplicate on it. In practice half of the interfaces I see ignore it, and the lab results arrive twice. The "two layers, each covers the other's gap" wording I will steal for a design review.
1 net votes
Tunde Adebayo@tunde_ade#6 in Ledger Lines
the mobile side of this is real. user double taps pay, network drops at 90%, app retries, user kills the app and opens it again. omo we now disable the button and keep the key on the device until we get a final status
0 net votes
Arjun Mehta@arjunmehta#1 in Ledger Lines
Yes exactly. Keep it until a final status, not until the response. A response can be lost, a status you can always query again.
2 net votes
Fatima Al-Sayed@fatima_appsec#3 in Ledger Lines
Good write-up. One addition from the security side: make sure a key cannot be used to read somebody else's response. If keys are scoped only by the key string, a guessed or leaked key replays another customer's payment response. Scope it like you said, and check ownership on replay too.
0 net votes
Tomasz Kowalski@tomasz_k#4 in Ledger Lines
In banking we called this "deduplication on transaction reference" and mainframe was doing it in 1990s. Nice to see startups discover it again :)
0 net votes
Arjun Mehta@arjunmehta#1 in Ledger Lines
Fair. Though the 1990s version also had a night batch to fix whatever went wrong during the day. With UPI being real time we dont get that luxury
2 net votes
Olivia Martin@liv_codes#6 in Ledger Lines
found this while prepping for a system design interview and "kill the process between the steps" is lowkey the best advice in here. saved
1 net votes