You buy something online. You click "Pay $50." The page freezes.
Nothing happens for five seconds. You click again. And a third time, because you're impatient.
Did the merchant charge you $50, $100, or $150?
The answer, almost always, is $50.
Three clicks. Each one fired off a network request. Each one tried to charge your card. And yet only one charge lands. That's a little bit remarkable when you think about it.
The thing that makes this work is called an idempotency key. It's one of the most quietly important patterns in modern payment systems.
And it works because both sides cooperate. The client — the app or website you're clicking in. The server — the payment API behind the scenes. Both have a job to do. Let's look at how.
Why retries even happen
First, why did those three clicks even reach the server?
Networks aren't reliable. When you click a button, the request travels over the internet. Through your ISP. Through the merchant's cloud provider. Through whatever routers happen to be in between.
Any of them can drop the request. Or delay the response. Or lose the reply coming back.
When something goes wrong, the app can't tell what happened. Maybe the request never reached the server. Maybe it reached the server, the payment succeeded, and the "success" reply got lost coming back. Maybe the server is still working on it and just hasn't answered yet.
Three possibilities. The app can't tell them apart. It only knows: no response.
So it retries.
That's the safe default. If the payment didn't go through, the customer needs another chance. And when you click three times because the page is frozen, you're doing the same thing manually.
The problem: if the server already processed the first request, the retry causes a duplicate. You're not retrying a lost request. You're duplicating a completed one.
Charge twice. Or three times. Or however many times you clicked.
The idempotency key
The fix is surprisingly simple. Every time the client wants to charge the customer, it generates a unique ID for that specific charge.
Not for each network request. For each charge.
That distinction is the hard part. It's where most people get lost.
A network request is the individual attempt you send over the wire. A charge is what the user wants to do. "I want to pay $50 for this order."
One "want" can produce many attempts. The network drops. The SDK retries. The user impatiently clicks again. But they're all attempts at the same charge. And they should all carry the same ID.
Think of it like a tracking number on a package.
If the courier tries to deliver three times, you don't get three tracking numbers. The tracking number belongs to the package. Not to any single delivery attempt.
Same idea here. The ID belongs to the charge. Not to any single request.
Concretely: the moment the user clicks Pay, the client generates an ID and holds onto it. That ID sticks with the charge for its entire life.
Network drops the first attempt? Retry with the same ID.
User closes the app and reopens it and tries again? Still the same ID.
User starts a new subscription renewal next month? Fresh ID — that's a genuinely new charge.
The rule: the ID is generated when the user's intent forms. Not when the network request fires.
Once the ID exists, the client sends it to the server in an HTTP header:
POST /payments
Idempotency-Key: 8f4e2c1a-9d6b-4b8f-a5c3-7e1d2f8a9b3c
If the request fails and the client retries, it sends the same ID on the retry. Not a new one. Not a random one. The exact same string.
The server's job is to recognize duplicates by this ID.
First time seeing an ID? Process the payment normally.
Seen this ID before? Return the previous result without processing again.
That's the whole concept.
The client says: "This is charge X, please process it."
The server says: "OK, I processed charge X. Here's the result."
Client retries: "This is charge X, please process it."
Server says: "I already processed charge X. Here's the same result again."
Two clicks. One charge. Because both clicks sent the same ID.
What the client does
The client — the app or website, the code running on your phone or in your browser — has two responsibilities.
Generate the ID at the right moment. The ID should exist for one logical charge, not for one network request. If you're paying $50 for a subscription renewal, the ID gets generated once when you click Pay. It stays the same across every retry the app does. It stays the same if the app got backgrounded and reopened.
Use that ID for every retry. The Stripe SDK, for example, generates the key inside your charge.create() call and reuses it if the underlying HTTP request needs retrying. You as the developer don't have to manage this yourself. But you have to know it's happening. Because if you accidentally generate a new key per attempt, the protection breaks.
The generation is usually a UUID — a random-looking string like 8f4e2c1a-9d6b-4b8f-a5c3-7e1d2f8a9b3c.
UUIDs are practically guaranteed to be unique. Two clients generating random UUIDs will almost never generate the same one. That's what lets the client generate the ID on its own, without asking the server for one first.
What the server does
The server side is where the deduplication happens.
When a payment request arrives with an idempotency key, the server does three things.
Check if it's seen this key before. The server keeps a record of every idempotency key it's processed, along with the response it sent back. This record lives in a database or in a fast cache like Redis. The lookup is quick.
If the key is new, process the payment. The server records that it's seen this key, marks it as "in progress," calls the payment processor (the card network, the bank, wherever the money actually moves), and stores the result.
If the key is already known, return the stored result. No new payment happens. The card doesn't get charged again. The server replays the response it sent originally — same status, same details. From the client's point of view, it's as if the original request had come through cleanly. But the payment only happened once, at the original attempt.
There's one subtle piece worth calling out.
If two requests with the same key hit the server at the exact same moment, the "have I seen this before?" check could pass for both. Both would look up, see nothing, and both would process the payment.
To prevent this, the server relies on a database rule that says "this key can only exist once in the table." Whichever request writes first wins. The other gets rejected by the database and knows to look up the previously-stored result instead.
You don't have to know the specifics of that mechanism to use idempotency keys. You do have to know that if the API you're using is a serious one, that mechanism is what's quietly protecting the customer.
Both sides working together
Here's the crux.
The client generates a stable ID. The server dedupes on that ID.
Both sides doing their job is what makes the pattern work. If either side misbehaves, the protection falls apart.
If the client generates a new ID every time — say, because a developer thought they were being helpful — the server has no way to recognize the retry. Each request looks new. The customer gets charged multiple times.
If the server doesn't check for duplicates — say, because the API doesn't implement idempotency — the ID becomes decorative. The client sends a header the server ignores. Every retry results in a fresh payment attempt.
Both sides matter.
That's why every serious payment API — Stripe, Square, Adyen, GoCardless — implements idempotency on the server side. And every SDK that talks to those APIs generates keys on the client side. It's not a feature you turn on. It's part of the contract.
For a developer integrating a payment API: check the docs to see if idempotency is supported. It almost certainly is for any modern payment API. Understand how the SDK generates keys. Don't override or bypass that generation. And if you're building your own retry logic on top of the SDK, make sure your retries reuse the original key, not a fresh one.
For a product manager or non-engineer: the takeaway is simpler. The reason double-clicking Pay is (usually) safe is a specific pattern that requires cooperation between the app you're using and the payment API behind it. Both sides have to have implemented it correctly. When they do, the retry protection is invisible. You'd never notice it's there.
Which is exactly the point.
What this quietly protects
Idempotency keys are the reason a lot of things in the modern web quietly work correctly.
They're why refreshing a checkout page doesn't repost your order. Why the mobile Uber app doesn't book two rides when the network hiccups mid-request. Why your subscription renewal charges you once, instead of once per failed retry.
Next time you click Pay twice because a page froze, know that there's a specific string of characters — a UUID, most likely — quietly making sure only one charge lands.
Top comments (0)