Founder & Lead Engineer, RAITHub
RAITHub ships and tests production software. See QA as a Service or talk to us.
A customer charged twice is almost always one intent sent to the gateway more than once: a retried request, a double-clicked button or a replayed webhook. Refund the duplicate first and tell the customer. Then make it impossible: attach an idempotency key to every payment request, and back it with a unique constraint in your database so the second write is rejected, not charged.
If you would rather have this fixed and prevented for you, see how RAITHub would fix it at the end of this guide.
Why do customers get charged twice?
Because the same payment intent reaches the gateway twice, and nothing downstream recognises the repeat. The fix differs by cause, so identify yours before you change code.
| Cause | What happens | Fix |
|---|---|---|
| Client retry | A slow response times out; the app resends the charge, but the first one already succeeded | Idempotency key reused on the retry |
| Double click | The customer taps "Pay" twice before the button disables | Disable on submit; idempotency key per checkout attempt |
| Webhook replay | The gateway redelivers a payment_succeeded event and your handler charges or books again | Deduplicate on the event ID; make the handler idempotent |
| Two devices or tabs | The same cart is paid from two sessions | Unique constraint on the order's payment |
| Job retried after a crash | A background charge runs, the worker dies before marking it done, and it runs again | Record intent before charging; unique key on the attempt |
Every row has the same root shape: an operation that is not safe to repeat was repeated. The cure is to make the charge operation idempotent, so repeating it has no additional effect.
What should I do the moment I find a double charge?
Contain it, refund it, and tell the customer before they tell you.
- Confirm it is a true duplicate, not a legitimate second purchase. Two charges, same amount, same card, seconds apart, one order: that is a duplicate.
- Refund the extra charge, not the whole order. Refund the later of the two so the original stays matched to the order.
- Record the refund against the duplicate in your ledger, so reconciliation does not later flag it as missing money.
- Message the customer with the amount, the date and when the refund lands. A proactive note prevents a chargeback.
- Find the blast radius. If one request path double-charged, others on the same path probably did too. Query for same-card, same-amount pairs inside a short window.
A refunded duplicate is a reconciliation event, not a loss, as long as you record it. The matching side of this is in finding and fixing a reconciliation mismatch.
How does an idempotency key stop duplicate charges?
An idempotency key is a unique token your client generates once per payment attempt and sends with the request. The gateway uses it to recognise a repeat: if it sees the same key again, it returns the original result instead of charging a second time. Stripe documents this in its idempotent requests API: send an Idempotency-Key header, and a retry with the same key returns the first response.
The key must be generated when the user starts the payment, not on each request, or a retry would carry a fresh key and defeat the whole mechanism. Generate it on the checkout page, store it with the pending order, and reuse it on every retry of that attempt.
// Generate ONCE per checkout attempt, persist it, reuse on every retry.
const idempotencyKey = order.paymentIdempotencyKey ?? crypto.randomUUID()
await stripe.paymentIntents.create(
{ amount: order.amountMinor, currency: order.currency, customer: order.customerId },
{ idempotencyKey }, // a retry with this same key returns the first result
)
Why do I also need a unique constraint in the database?
Because a gateway idempotency key protects the call to the gateway, not your own records. A webhook replayed to your server, or two of your workers racing, can still write two "paid" rows even when the gateway charged once. The database is the one place that can reject the second write under concurrency, so put the guarantee there.
-- One successful payment per order, enforced by the database itself.
CREATE TABLE payment (
id bigserial PRIMARY KEY,
order_id bigint NOT NULL REFERENCES orders(id),
provider_txn_id text NOT NULL,
amount_minor bigint NOT NULL,
status text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
-- The gateway's own event/charge ID: a replay can never insert twice.
UNIQUE (provider_txn_id)
);
-- At most one captured payment per order, whatever races upstream.
CREATE UNIQUE INDEX one_capture_per_order
ON payment (order_id)
WHERE status = 'captured';
Now a replayed webhook that tries to insert the same provider_txn_id fails the unique constraint, and your handler catches the conflict and treats it as "already processed". A second capture for the same order hits the partial unique index and is rejected. Make the webhook handler itself idempotent by deduplicating on the event ID, as described in idempotency in API design and tested in testing payments and webhooks.
How do I prove it will not happen again?
With tests that replay the exact failures, run in CI on every change. If these never run, the bug comes back the first time someone edits the payment path.
- Replay the same webhook twice and assert one payment row and one ledger entry.
- Fire two capture requests concurrently for one order and assert the second is rejected.
- Retry a timed-out charge with the stored idempotency key and assert the gateway returns the first result, not a second charge.
- Crash a charge worker mid-run and re-run it; assert no second charge.
Buy, build or hire?
| Option | Example | Choose this when | Where it stops |
|---|---|---|---|
| Gateway idempotency only | Stripe idempotency keys | A single, simple charge path | Does not protect your own records from replays |
| Managed checkout | Hosted checkout that disables the button and dedupes for you | You can use the provider's hosted flow | Less control over the flow and local rails |
| Custom idempotency + DB constraints | Your own keys plus unique indexes | Several rails, background charges, or custom checkout | You own the tests and the edge cases |
| Hire an engineering team | Audit the path, add keys, constraints and tests | Duplicates are happening now and you cannot find every path | Not a substitute for an accountant on refunds |
How long does it take to fix yourself?
Our estimate, for a developer who knows the codebase: two to four hours to add idempotency keys and the unique constraints on a single charge path; one to two days to also cover webhooks, background charges and the regression tests. The main risk of doing it alone is fixing one path and missing another, so the double charge returns from a different route. Audit every place that captures money, not just the one that surfaced.
How RAITHub would fix this
- Stop the bleeding: find and refund outstanding duplicates, recorded against the ledger so reconciliation stays clean.
- Add keys: an idempotency key generated once per checkout attempt and reused on every retry, across all gateways.
- Enforce in the database: unique constraints so a replay or a race is rejected, not charged.
- Prove it: replay and concurrency tests in CI that fail if anyone reintroduces the bug.
Timeline: a focused fix is days; hardening the whole money path with tests is 2–4 weeks as a code-rescue or SaaS engagement. What you receive: automated tests and CI, handover docs, and full IP under NDA. Production and financial data stay in your own cloud account; development uses synthetic data.
RAITHub is an engineering studio and has not shipped a regulated or licensed fintech product; how you handle refunds is a question for your accountant, and this is general information, so confirm with your adviser. Proof we can point to: TheSkinProof, the founder's own venture, runs multi-gateway payments with 750+ automated tests, and PadhAI, built by RAITHub, was designed for 9 gateways behind one abstraction. Next step: a free 15-minute technical audit, then a written fixed quote. Book the audit. More is on the SaaS development page and the FinTech page.
Frequently asked questions
Why was my customer charged twice?
Almost always the same payment reached the gateway twice: a retried request after a timeout, a double-clicked button, or a replayed webhook that charged or booked again. The money is recoverable; refund the duplicate and record it against the ledger.
What is an idempotency key?
A unique token generated once per payment attempt and sent with the request. The gateway recognises a repeat with the same key and returns the original result instead of charging again. Generate it when checkout starts and reuse it on every retry of that attempt.
Isn't the gateway's idempotency enough?
No. It protects the call to the gateway, not your own database. A replayed webhook or two racing workers can still write two paid rows. Add a unique constraint in your database so the second write is rejected under concurrency.
How do I refund a duplicate charge correctly?
Refund the later of the two charges so the original stays matched to the order, record the refund against the duplicate in your ledger, and message the customer with the amount and refund date to prevent a chargeback.
How do I make sure duplicates do not come back?
Write tests that replay the same webhook, fire two captures at once, and retry a timed-out charge, each asserting exactly one payment. Run them in CI so any future edit to the payment path that reintroduces the bug fails the build.
Related posts
Ready to discuss your project?
Book a free 15-minute technical audit with our engineering team.