What Is Idempotency? Stop Charging Customers Twice
For: A CTO or lead backend engineer at a seed-to-Series A fintech or SaaS startup who just got a Slack message saying a customer was charged twice after a mobile network timeout — and is now realising their retry logic silently re-executes the same payment request
A double-charge after a network timeout is almost never a bug in Stripe or Razorpay. It's a bug in your retry logic. Idempotency is the contract that fixes it: the client generates a unique key for each logical operation (say, checkout-order-8842), sends it with every retry of that same request, and the server uses that key to guarantee the side effect — charging the card — happens exactly once, no matter how many times the request arrives.
The non-obvious part: idempotency is not something your server can add unilaterally. It's a claim the client has to make. Without an idempotency key on the wire, even a perfectly reliable server has no way to distinguish a retry from a genuinely new purchase. Two identical HTTP requests, one second apart, from the same user, for the same amount — that could be a duplicate, or it could be someone buying two coffees. Only the client knows.
The problem: retries and side effects don't mix
Here's the failure mode that just paged you.
- User taps "Pay" in your mobile app.
- Your app calls
POST /charges. - The server receives it, charges the card via Stripe, writes the order to Postgres, and sends back
200 OK. - The user's train enters a tunnel. The
200never reaches the phone. - Your app's HTTP client sees a timeout and retries.
- The server, having no memory that this specific request already succeeded, charges the card again.
The customer is now charged twice. Your server logs show two successful 2xx responses. Stripe's dashboard shows two charges. Nothing looks wrong except the angry Slack message from support.
The core issue: HTTP methods like POST are, by definition, not safe to retry. GET is idempotent by convention — calling it ten times returns the same resource and changes nothing. POST creates things. Charges. Orders. Payouts. Emails. Retrying a POST without a contract in place means retrying the side effect.
Most HTTP clients — axios, okhttp, iOS URLSession, retry middlewares in Go and Python — will happily retry failed requests by default. If you haven't explicitly turned that off or made your endpoints idempotent, you already have this bug. You just haven't been paged for it yet.
The intuition: a claim check at a coat room
Think of an idempotency key like the paper ticket you get at a coat check.
You hand over your coat. The attendant gives you ticket #47. You wander off, then come back and ask, "Did I already drop off a coat?" The attendant checks ticket #47: yes, coat is on hook 12. You get the same coat back — not a second hook, not a second coat.
Now imagine there's no ticket. You come back and say "I want to drop off a coat." The attendant, being polite and having no memory, takes another one. You now have two coats hanging up and one very confused wardrobe.
The ticket is the client's claim: this is the same coat I mentioned before. The server's job is just to look up the ticket and respond accordingly. The intelligence lives in the ticket, not in the attendant.
A minimal worked example
Here's how a well-designed idempotent API endpoint behaves. Using Stripe's model, because it's the reference implementation most engineers know.
Client:
POST /v1/charges
Idempotency-Key: checkout-order-8842-attempt-1
Content-Type: application/json
{
"amount": 4999,
"currency": "usd",
"customer": "cus_abc123"
}The key checkout-order-8842-attempt-1 is generated on the client — typically a UUID, or a deterministic hash derived from the user action (order ID + user ID + a nonce). It's stored locally before the request goes out.
Server logic:
- Received request with
Idempotency-Key: checkout-order-8842-attempt-1. - Look up that key in the idempotency store (Redis, Postgres, DynamoDB — whatever's fast and durable).
- If not found: acquire a lock on the key, execute the charge, store
(key → response body, status code, request fingerprint), release the lock, return the response. - If found and completed: return the stored response verbatim. Do not re-execute.
- If found and still in-flight (another request is processing it right now): return
409 Conflictor wait.
Now when the phone retries with the same key, the server sees it, returns the cached 200 and the original charge ID, and no second charge happens. The client gets a response that looks identical to the original — because it is the original.
Three details that matter:
- The key must persist on the client across retries. If your retry logic generates a fresh UUID each attempt, you've built nothing. Store the key with the pending operation (SQLite, Core Data, IndexedDB) and reuse it until the operation is confirmed done.
- Fingerprint the request body. If someone reuses the key with a different amount, that's a bug — return an error, don't silently succeed. Stripe returns
400in this case. - Set a TTL. Keys don't need to live forever. 24 hours is typical. Anything longer bloats the store; anything shorter risks losing a legitimate retry from a phone that was offline overnight.
Gotchas that will bite you
1. The lock window. Between "start processing" and "store response," a concurrent retry can arrive. If you don't lock on the key, you'll double-charge inside a single request's lifetime. Use SELECT FOR UPDATE, a Redis SETNX, or a database unique constraint on the key column.
2. Partial failures. You charged the card, then your DB write failed. Now you have a Stripe charge with no corresponding order, and the idempotency record was never written. The retry will charge again. Solution: write the idempotency record and the order in the same transaction, and only after the external side effect is confirmed. Or use an outbox pattern.
3. Idempotency ≠ deduplication. Idempotency keys deduplicate identical retries of one logical operation. They do not stop a user from tapping "Pay" twice and generating two different keys. That's a UI problem (disable the button) and a business-logic problem (check for a recent identical charge).
4. Cross-service retries. If your /charges endpoint internally calls Stripe, you need to pass an idempotency key to Stripe too. Otherwise your server's retry to Stripe on a transient error creates the exact bug you're trying to prevent, one layer down.
5. Non-deterministic responses. If your response includes a server_timestamp or a fresh JWT, and you return the cached response on retry, the client gets a stale timestamp. Usually fine. Occasionally not. Know your fields.
When to use idempotency keys — and when not to
Use them for: any POST, PATCH, or DELETE that triggers a side effect you can't afford to repeat. Payments, payouts, order creation, sending SMS/email, external API calls that cost money or affect users.
Don't bother for: GET requests (already idempotent). Internal analytics events where duplicates are cheap and dedup happens downstream. Operations that are naturally idempotent by design — PUT /users/42/email with the same email is safe to repeat because you're setting state, not incrementing it.
The tradeoff: idempotency adds a storage dependency (Redis or a DB table) on the hot path of every mutating request, plus a lock. It's a real latency cost — usually a few milliseconds, sometimes more under contention. For a low-volume internal tool, it's overkill. For anything that touches a customer's money, it's non-negotiable.
How CodeNicely can help
Most double-charge incidents we've seen at fintech startups aren't gateway issues — they're retry-logic issues sitting quietly in the mobile client or an API gateway. When we worked with GimBooks, a YC-backed accounting SaaS handling invoicing and payments for small businesses, getting the write path right — idempotency, transactional consistency between payment state and ledger state, safe retries across flaky mobile networks — was a foundational part of the platform. Similar concerns applied on Cashpo, where loan disbursement flows can't tolerate a duplicate transfer under any network condition.
If you're a fintech founder or CTO who just discovered your retry logic is unsafe, or you're auditing a payment flow before scaling volume, we can review your architecture and put the right primitives in place. See our work with startups or the full offerings.
Frequently Asked Questions
What's the difference between an idempotent API and a Stripe idempotency key?
An idempotent API is any endpoint that produces the same result no matter how many times it's called with the same input. A Stripe idempotency key is the specific implementation Stripe uses: a client-supplied header (Idempotency-Key) that lets Stripe recognize retries and return the cached response instead of re-executing. Stripe's model is the de facto standard — most fintech APIs (Adyen, Square, Razorpay) copy it.
How should I generate idempotency keys on the client?
Use a UUID v4 generated once per logical operation, stored locally before the first request goes out, and reused for every retry of that operation. Do not derive it purely from request contents — two legitimate identical purchases would collide. Do include enough context (order ID, attempt scope) that debugging is possible.
Do idempotency keys prevent all duplicate payments?
No. They prevent duplicates caused by network retries of the same logical request. They don't prevent a user from double-tapping a button that generates two different keys, or from placing two genuinely separate orders. Those need UI-level guards (button disabling) and business-logic checks (recent-duplicate detection).
How long should idempotency keys be stored on the server?
24 hours is a common default and matches Stripe's behavior. Long enough to cover phone-offline scenarios and delayed retries, short enough that your store doesn't grow unbounded. If you need longer windows for reconciliation, store the key alongside the transaction record itself, not in the hot idempotency cache.
We think we have this bug in production. What now?
First, disable client-side retries on any mutating endpoint until you've added idempotency support end-to-end. Second, audit your gateway logs for identical requests from the same client within a few seconds — that's your duplicate rate. Third, add idempotency keys on the client, a keyed dedup store on the server, and pass keys through to any downstream payment provider. Contact CodeNicely for a personalized assessment if you'd like a second pair of eyes on the design.
Building something in Fintech?
CodeNicely partners with founders and tech teams to ship AI-native products that move metrics. Tell us about the problem you're solving.
Talk to our team_1751731246795-BygAaJJK.png)