Skip to content
Engineering

Duplicate Payments: Why Customers Get Charged Twice and How to Prevent It

A customer pays once and is charged twice. It's rarely a provider fault: it's a double tap, a timeout followed by a blind retry, or two providers that both said yes. Where duplicates come from, the four layers that stop them (including idempotency keys done properly), and what to do when one slips through.

LILPE Editorial Team9 October 2026 12 min read
Cover for an engineering guide to preventing duplicate payments and double charges

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.

Kinds of duplicate in a payment system
KindWhat happenedWho notices
Duplicate chargeMoney was taken twice for one purchaseThe customer, on their statement
Duplicate fulfilmentOne payment, but the order shipped or the service was credited twiceYour finance or stock team, later
Duplicate recordOne payment recorded twice in your database or reportsReconciliation, 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:

Common causes of duplicate charges and where to fix them
CauseHow it plays outWhere to fix it
Double tap or double clickTwo create-payment requests leave the browser or app within a secondCheckout and idempotency keys
Customer pays again after "pending"A UPI or bank payment sits pending; the customer gives up and pays again; both succeedCheckout messaging and one success per order
Blind retry after a timeoutYour server times out calling the provider, assumes failure, and sends a new paymentTimeout handling and idempotency keys
Retry on a second providerProvider A was slow, the retry on provider B succeeded, then A also succeeded lateStatus checks before retrying, and reconciliation
A billing job runs twiceA scheduler fires twice or a deploy restarts a job mid-run; a subscription is charged twiceOne charge per billing period, enforced in the database
A new order for the same basketThe customer refreshes, the app creates a fresh order and a fresh paymentReuse 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.

SQLPostgreSQL: many attempts per order, at most one succeeded
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.

  1. 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.
  2. The server stores the key, scoped to the merchant or account, together with a fingerprint of the request: the method, path and body.
  3. 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.
  4. 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.
  5. If the original request is still in progress, the server returns a conflict, HTTP 409 in the draft, and the client retries later.
  6. Keys expire after a published period, long enough to cover realistic retries.
TypeScriptA create-payment handler that honours idempotency keys (helpers omitted)
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:

  1. Confirm both charges succeeded and settled, not that one is a pending authorisation or a failed payment awaiting automatic reversal.
  2. Refund the later charge in full, to the original payment method, through your provider's refund flow, and record it against the order.
  3. 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.
  4. 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.

Official sources

Last reviewed . Examples, amounts and screens marked illustrative are not LILPE figures.

Route every payment to where it succeeds.

Tell us which providers you use today and where payments get stuck. We'll show you what the setup would look like on LILPE.