Few things damage a customer's trust faster than seeing the same payment twice on their statement. They meant to pay once. Now they have to notice the second charge, contact you, wait for a refund and wonder whether to buy from you again. For the business, every duplicate means a refund to process, a support conversation, possibly fees that don't come back, and sometimes a dispute raised with the customer's bank.
The good news is that duplicates are almost always preventable. They come from a short list of causes, nearly all of them in the merchant's own checkout and integration rather than in the payment network. This guide is for the engineers and product owners who build those flows: where duplicates come from, the four layers of defence that stop them, and how to clean up the ones that get through anyway.
Three different problems that all look like "duplicates"
It helps to separate three things that people call duplicates, because each has a different fix.
| Kind | What happened | Who notices |
|---|---|---|
| Duplicate charge | Money was taken twice for one purchase | The customer, on their statement |
| Duplicate fulfilment | One payment, but the order shipped or the service was credited twice | Your finance or stock team, later |
| Duplicate record | One payment recorded twice in your database or reports | Reconciliation, as a mismatch |
This guide is mostly about the first, the one customers feel. Duplicate fulfilment usually comes from processing the same webhook twice, which our guide to payment gateway webhooks covers. Duplicate records are the internal version of the same problem and are covered in exactly-once payment processing.
Where duplicate charges come from
In practice, nearly every double charge traces back to one of these causes:
| Cause | How it plays out | Where to fix it |
|---|---|---|
| Double tap or double click | Two create-payment requests leave the browser or app within a second | Checkout and idempotency keys |
| Customer pays again after "pending" | A UPI or bank payment sits pending; the customer gives up and pays again; both succeed | Checkout messaging and one success per order |
| Blind retry after a timeout | Your server times out calling the provider, assumes failure, and sends a new payment | Timeout handling and idempotency keys |
| Retry on a second provider | Provider A was slow, the retry on provider B succeeded, then A also succeeded late | Status checks before retrying, and reconciliation |
| A billing job runs twice | A scheduler fires twice or a deploy restarts a job mid-run; a subscription is charged twice | One charge per billing period, enforced in the database |
| A new order for the same basket | The customer refreshes, the app creates a fresh order and a fresh payment | Reuse the open order instead of creating another |
Notice what's missing from the list: the payment network inventing a second charge. That can happen, and banks do occasionally post a debit twice, but it's rare compared with the causes above. If you can't find a cause on your side, check before assuming one: a debit that appears twice can also be an authorisation hold next to the final charge, or a failed payment that hasn't been reversed yet. Our guide to money debited but the payment failed explains those from the customer's side.
Layer 1: design checkout so customers don't pay twice
The cheapest duplicate to fix is the one that never starts. A few interface decisions remove most customer-triggered duplicates:
- Disable the pay button after the first press and show progress. Re-enable it only when you know the outcome, not after a fixed delay.
- Keep one open order per basket. If the customer comes back to checkout, reuse that order and its payment rather than creating new ones.
- When a payment is pending, say so plainly: "We're waiting for your bank to confirm. Please don't pay again; we'll update this page." Then poll your own server for the result.
- If the customer does choose another method while one is pending, cancel or expire the pending one where the provider allows it, and keep the order able to accept only one success.
- For UPI collect or QR payments, expire the request after a short, clearly shown window, so an old request can't be approved after the customer has already paid another way.
These steps reduce duplicates. They can't eliminate them: networks drop responses, browsers resend, and customers are faster than your JavaScript. That's why the next three layers live on the server.
Layer 2: one order, many attempts, at most one success
The most useful modelling decision in a payment system is to separate the order (what the customer is buying) from payment attempts (each try to pay for it). An order can have several attempts: a declined card, then a UPI payment that worked. But only one attempt per order should ever end up succeeded, and your database can enforce that.
CREATE TABLE payment_attempts (
id uuid PRIMARY KEY,
order_id uuid NOT NULL REFERENCES orders (id),
provider text NOT NULL,
provider_ref text,
amount_minor bigint NOT NULL CHECK (amount_minor > 0),
currency char(3) NOT NULL,
status text NOT NULL
CHECK (status IN ('created', 'pending', 'succeeded', 'failed')),
created_at timestamptz NOT NULL DEFAULT now()
);
-- A partial unique index: only rows with status 'succeeded' are indexed,
-- so a second success for the same order is rejected by the database.
CREATE UNIQUE INDEX one_success_per_order
ON payment_attempts (order_id)
WHERE status = 'succeeded';Be clear about what this does and doesn't do. The index can't stop a provider from taking money twice; it stops your system from silently recording two successes. When the second success arrives, the update fails, and that failure is your signal to start a refund for the extra payment and alert someone. Without it, the duplicate sits quietly in your data until a customer complains.
The same idea applies to recurring billing. Give each scheduled charge a natural key, such as the subscription ID plus the billing period, and make it unique. A job that runs twice then fails on its second insert instead of charging twice.
Layer 3: idempotency keys on every money-moving request
An operation is idempotent when doing it twice has the same effect as doing it once. HTTP defines GET, PUT and DELETE as idempotent, but POST, the method nearly every payment API uses to create a payment, is not. An idempotency key makes a POST safe to retry: the client attaches a unique key to the request, and the server promises that any repeat carrying the same key returns the original result instead of creating a second payment.
Many payment providers already accept a key like this, usually in an Idempotency-Key header, and an IETF working group has drafted a common specification for it. If you run your own payments service in front of providers, or expose a payments API to other teams, you should implement the same contract yourself.
- The client generates a key once per intent to pay, such as a random UUID created when the customer presses pay, and stores it with the order. Every retry of that request reuses the same key.
- The server stores the key, scoped to the merchant or account, together with a fingerprint of the request: the method, path and body.
- If a request arrives with a key it has already completed, the server returns the stored response. No second call to the provider is made.
- If the key is known but the fingerprint differs, the server rejects the request. The IETF draft suggests HTTP 422: the client is misusing the key.
- If the original request is still in progress, the server returns a conflict, HTTP 409 in the draft, and the client retries later.
- Keys expire after a published period, long enough to cover realistic retries.
async function createPayment(req: Request): Promise<Response> {
const key = req.headers.get("Idempotency-Key");
if (!key) return json(400, { error: "Idempotency-Key header is required" });
const merchantId = await authenticate(req);
const body = await req.text();
const fingerprint = sha256(req.method + " " + new URL(req.url).pathname + "\n" + body);
// Claim the key atomically: only one concurrent request can insert it.
const claimed = await db.query(
"INSERT INTO idempotency_keys (merchant_id, key, request_hash, status) " +
"VALUES ($1, $2, $3, 'in_progress') " +
"ON CONFLICT (merchant_id, key) DO NOTHING RETURNING key",
[merchantId, key, fingerprint],
);
if (claimed.rowCount === 0) {
const prior = await db.one(
"SELECT request_hash, status, response_code, response_body " +
"FROM idempotency_keys WHERE merchant_id = $1 AND key = $2",
[merchantId, key],
);
if (prior.request_hash !== fingerprint) return json(422, { error: "Key reused with a different request" });
if (prior.status === "in_progress") return json(409, { error: "Original request still in progress" });
return json(prior.response_code, prior.response_body); // replay: no second charge
}
const result = await createWithProvider(JSON.parse(body), { idempotencyKey: key });
await db.query(
"UPDATE idempotency_keys SET status = 'completed', response_code = $3, response_body = $4 " +
"WHERE merchant_id = $1 AND key = $2",
[merchantId, key, result.status, result.body],
);
return json(result.status, result.body);
}Three details separate a working implementation from one that only works in tests. First, the claim must be atomic: the insert with a primary key on merchant and key means two simultaneous requests can't both win. Second, pass the key, or one derived from it, on to the provider if it accepts one, so a retry from your server to the provider is protected too. Third, decide what happens to a key stuck at in_progress because the process crashed mid-request. Don't simply delete it and let the client try again: the provider may have taken the payment. Check the payment's status with the provider first, then complete the key with the real outcome.
Layer 4: never answer a timeout with a new payment
The most expensive duplicates come from a reasonable-looking piece of code: call the provider, and if it doesn't answer in time, try again. A timeout doesn't mean the payment failed. It means you don't know. The provider may have charged the customer a moment after your client gave up.
- Treat a timeout as "unknown", not "failed". Keep the attempt in a pending state.
- Resolve unknowns by asking the provider for the payment's status, using your own reference or the provider's ID, before doing anything else.
- Retry only with the same idempotency key, so a repeat to the same provider can't create a second payment.
- Move to another provider only when you know the first attempt failed, or when the first provider confirms it has nothing for your reference.
Our guide to payment API timeouts works through the timeout budget and status-check logic in detail.
When you use more than one provider
Routing payments across several providers adds one more way to charge twice: an attempt on provider A looks failed or stuck, the retry on provider B succeeds, and then A succeeds after all. Two providers, two successful payments, one order.
The defences are the ones above, applied across providers: check A's status before moving to B, never retry a pending payment elsewhere, and let reconciliation catch the rare late success so it can be refunded quickly. An orchestration layer should make this visible as one payment with a list of attempts rather than two unrelated charges. That's how LILPE's smart retry page describes its retries, and the reconciliation page describes how a late success is surfaced for follow-up.
Catching the duplicates that get through
No design is perfect, so look for duplicates deliberately rather than waiting for complaints:
- Alert when an order has more than one successful attempt. If you added the unique index above, this is every time it rejects an update.
- Run a daily check for the same customer, same amount and same merchant within a short window, across all orders. Not every match is a duplicate (people do buy the same thing twice), but every duplicate is a match.
- Match provider settlement reports against your orders. A settled payment with no order, or two settled payments for one order, shows up as an exception. Our payment reconciliation guide explains the routine.
- Track duplicates as a metric, broken down by cause. The cause tells you which layer to strengthen.
What to do when a customer has been charged twice
When a duplicate is confirmed, speed and clarity matter more than anything else:
- Confirm both charges succeeded and settled, not that one is a pending authorisation or a failed payment awaiting automatic reversal.
- Refund the later charge in full, to the original payment method, through your provider's refund flow, and record it against the order.
- Tell the customer what happened, the refund reference and when to expect the money. Card refunds and UPI refunds take different routes back, so give the reference that their bank can trace.
- Record the cause, so it feeds the metric above.
Refunding promptly is also cheaper. A customer who can't get an answer may raise a dispute with their bank instead, and a dispute costs more time than a refund. Our guide to chargebacks explains that process.
A checklist for your integration
Before you go live, or the next time you review your payment flow, check each of these:
- The pay button can't send two requests, and a pending payment is shown as pending.
- One open order per basket, reused on return.
- Orders and payment attempts are separate, with at most one success per order enforced by the database.
- Every create-payment, refund and payout request carries an idempotency key, generated once per intent and reused on retry.
- The server stores the key, a request fingerprint and the full response, and handles in-progress and mismatched keys.
- Timeouts are treated as unknown and resolved with a status check, never with a new payment.
- Scheduled charges have a unique key per billing period.
- An alert fires on a second success for one order, and reconciliation flags duplicate settlements.
Frequently asked questions
Why was I charged twice for one online payment?
Usually because two payments were created: a double tap, a retry after the page seemed stuck, or the merchant's system retrying in the background. Sometimes one of the two is only a pending authorisation or a failed payment that will be reversed automatically. Check whether both show as completed, then contact the merchant with both references. If one was a failed debit, our guide to money debited but the payment failed explains reversal timelines.
What is an idempotency key in payments?
A unique value the client sends with a request, such as a create-payment call, so the server can recognise a retry of the same request. If the same key arrives again, the server returns the original result instead of creating a second payment. It makes retries after timeouts and network errors safe.
How long should idempotency keys be kept?
Long enough to cover every realistic retry of the request, and the period should be published so clients know it. Many APIs keep keys for at least a day. Keys for scheduled charges are better replaced by a permanent natural key, such as the subscription and billing period.
Can a database constraint stop duplicate payments?
It can stop your system from recording two successful payments for one order, which is what lets you detect and refund a duplicate immediately. It can't stop a provider from taking the money twice; idempotency keys and careful timeout handling do that.
Is a duplicate payment the same as a chargeback?
No. A duplicate is two charges where one was intended; the fix is a refund. A chargeback is the customer's bank reversing a payment through the card network's dispute process. A duplicate that isn't refunded quickly can become a chargeback if the customer goes to their bank.
Go deeper
Official sources
- IETF — The Idempotency-Key HTTP Header Field (Internet-Draft, draft-ietf-httpapi-idempotency-key-header)Working-group draft, not yet an RFC. Unique keys such as UUIDs (2.2); expiry policy (2.3); request fingerprint (2.4); 409 for a request still in progress, 422 for a key reused with a different payload, 400 for a missing key (2.6–2.7).
- IETF — RFC 9110, HTTP SemanticsIdempotent methods (9.2.2): PUT, DELETE and safe methods are idempotent; POST is not.
- PostgreSQL documentation — Partial indexesA unique partial index enforces uniqueness only among rows that satisfy its WHERE clause.
Last reviewed . Examples, amounts and screens marked illustrative are not LILPE figures.




