A double charge is the payment bug customers actually notice. Two lines on a bank statement, an angry support ticket, and if it's handled badly, a dispute — which costs you a fee whether you win or not.
Most engineers reach for idempotency keys, and they should. But idempotency keys stop duplicate requests. They don't stop your own system making two legitimately different requests for the same order. That second category is where double charges that survive code review come from.
TL;DR
- Idempotency keys stop duplicate requests, not duplicate intents.
- Disabling the Pay button is a courtesy, not a control.
- Most remaining causes are races, and read-then-write checks lose races.
- The reliable fix is a partial unique index — and which states it covers decides whether it works at all.
- Never hold a database transaction open across the call to your payment provider.
Eight ways a customer gets charged twice
| # | Cause | Fix |
|---|---|---|
| 1 | Customer double-clicks Pay | UI guard + server idempotency |
| 2 | Retry sent with no idempotency key | Always send one |
| 3 | Retry sent with a new key | Generate before attempt 1, persist it |
| 4 | Two browser tabs / two devices | Server-side unique constraint |
| 5 | Mobile app retries on resume | Key survives app restart |
| 6 | Webhook handler charges again | Handlers record state, never charge |
| 7 | Two concurrent requests race a check | DB constraint, not if (exists)
|
| 8 | Dunning job races a manual retry | Guard scoped to the billing period |
Causes 1–3 and 5 are solved by doing idempotency properly. 4, 6, 7 and 8 are not — they're your system charging twice with two different, valid requests.
The race that careful-looking code loses
// WRONG: two concurrent requests can both pass this check
public Payment charge(String orderId, long amountMinor) {
if (payments.existsByOrderIdAndStateIn(orderId, SUCCESSFUL_STATES)) {
throw new AlreadyPaidException(orderId);
}
return doCharge(orderId, amountMinor); // both threads arrive here
}
Both requests run existsBy... before either commits. Both see nothing. Both charge. You'll never see it in local testing; you will see it during a retry storm or a double-tap on a slow mobile connection.
synchronized doesn't save you either — two instances behind a load balancer don't share a lock. Only the database can win this race.
The index that looks right and isn't
The obvious fix is a partial unique index allowing one successful payment per order:
-- Looks right. Doesn't prevent the double charge.
CREATE UNIQUE INDEX ux_payments_order_successful
ON payments (order_id)
WHERE state IN ('AUTHORIZED', 'CAPTURED', 'SETTLED');
Walk through the race with it in place:
- Request A inserts its payment as
INITIATED. The index ignores that state. - Request B inserts its payment as
INITIATED. Also ignored — both inserts succeed. - Both call the payment provider. Both charges go through.
- Both try to mark themselves
AUTHORIZED. Only now does the constraint fire — on the second one.
The index stopped you recording a double charge. It didn't stop you making one. The customer's card was already charged twice by step 3.
The index that actually works
Invert it. Cover every state except the ones where no money is held:
-- At most one payment per order that is in flight OR holds money.
CREATE UNIQUE INDEX ux_payments_order_active
ON payments (order_id)
WHERE state NOT IN ('DECLINED', 'FAILED', 'VOIDED', 'REFUNDED');
Now request B fails at the INSERT, before anything leaves your system. And you get two properties for free:
- A payment stuck in
UNKNOWN(a timeout — the money may or may not have moved) blocks a second attempt until it's resolved. That's exactly the situation behind most real-world double charges. - A genuinely declined payment doesn't block a retry, so the customer can try another card, and failed attempts stay in the table as history.
Both indexes were tested against PostgreSQL for this post: the first accepts both INITIATED rows, the second rejects the second one at insert, blocks on UNKNOWN, and still allows a retry after DECLINED.
Wiring it up in Spring
The losing request needs to fail inside your code, before the provider call, and the provider call must not run inside a transaction:
public Payment charge(String orderId, long amountMinor) {
Payment payment;
try {
// Short transaction: the INSERT is where the losing request fails.
// saveAndFlush makes the constraint fire here, not later at commit.
payment = tx.execute(status ->
payments.saveAndFlush(Payment.initiated(orderId, amountMinor)));
} catch (DataIntegrityViolationException alreadyInFlight) {
// Lost the race, and no provider call was made.
// Return the payment that won instead of a 500.
return payments.findActiveByOrderId(orderId)
.orElseThrow(() -> alreadyInFlight);
}
// External call runs with no database transaction held open.
return execute(payment);
}
Three details that matter:
-
saveAndFlush, notsave. With a plainsave, Hibernate may defer the insert until commit — so the violation surfaces somewhere yourcatchisn't. -
TransactionTemplate(tx), not@Transactionalon a private helper. Spring's transaction proxy doesn't intercept a class calling its own methods, so the annotation would silently do nothing. -
No transaction around
execute(). Holding one open across a multi-second HTTP call pins a connection and a row lock for the whole duration. Under load, that alone takes you down.
Never charge from a webhook handler
Webhooks are delivered at least once, so duplicates are guaranteed, not hypothetical. A handler that initiates a charge will eventually charge twice.
Handlers should only record what already happened. If an event genuinely needs to trigger a charge, enqueue a job that goes through the same guarded path as above.
Recurring billing needs a period-scoped guard
Cause 8 is subtle. A dunning job retries March's failed renewal. Meanwhile, support manually retries it. Both correctly use fresh idempotency keys — the provider's key retention expired days ago — so keys can't protect you. Two charges for one month.
The guard has to live in your data, scoped to the billing period, with the same index shape:
CREATE UNIQUE INDEX ux_sub_payments_period_active
ON subscription_payments (subscription_id, period_start)
WHERE state NOT IN ('DECLINED', 'FAILED', 'VOIDED', 'REFUNDED');
Idempotency keys protect a request. Your schema protects a business outcome.
When it happens anyway
Refund first, investigate second. A fast refund usually prevents a dispute, and a dispute costs you a fee regardless of outcome.
Then run this on a schedule — with the index in place it should always return zero rows, which makes it an excellent canary:
SELECT order_id, COUNT(*)
FROM payments
WHERE state IN ('AUTHORIZED', 'CAPTURED', 'SETTLED')
GROUP BY order_id
HAVING COUNT(*) > 1;
If it ever returns a row, either the index is missing in some environment or something is writing around it.
Checklist
- [ ] Partial unique index per order covering in-flight states, not just successful ones
- [ ]
saveAndFlushin its own short transaction, violation caught there - [ ] No transaction held open across the provider call
- [ ] Webhook handlers record state and never charge
- [ ] Recurring payments guarded per billing period
- [ ] Duplicate-charge canary query running on a schedule
This is part 1 of a series on building payment systems as a backend engineer. The full version on my site adds the attempt-log schema for diagnosing the double charges that do slip through. I also build free, in-browser payment engineering tools — including a card decline code lookup and a settlement reconciliation diff.
Top comments (0)