
Prevent Double Payments (Node.js)
Every payment integration eventually gets the same support ticket: "I was charged twice." And it's almost never the provider's fault — it's a missing idempotency guard in your own flow. Here's why double payments happen when you use a third-party provider, and how to make them impossible.
How do you prevent double payments with a third-party payment provider?
Give every payment attempt one stable idempotency key, and enforce "one charge per key" at three layers: disable the pay button on submit, dedupe on the server using the key before you call the provider, and back it with a database unique constraint. Then handle the provider's webhooks idempotently too, and reconcile to catch anything that slips through.
The whole idea rests on one principle: a user's intent to pay should map to exactly one charge, identified by a key that stays the same across clicks, refreshes, and retries.
Why users get charged twice
Double payments look like one bug but have many triggers:
- The user double-clicks the pay button, firing two requests.
- Nothing happens fast enough, so they click again — or refresh the page and resubmit.
- The network times out and the client auto-retries a request that actually succeeded.
- They've got the checkout open in two tabs, and both submit.
- The provider retries its webhook/callback, and you credit the order twice.
Every one of these is the same root cause wearing a different hat: there's no stable identity for "this one payment," so your system can't tell a retry from a brand-new charge.
Give each payment intent one idempotency key
The fix is a single key that identifies the intent, not the request. Generate it once, when the checkout screen loads — not when the button is clicked — so every retry carries the same key:
// Generated once on mount, reused across every click and retry
const [idempotencyKey] = useState(() => crypto.randomUUID());
<button disabled={submitting} onClick={pay}>
{submitting ? "Processing…" : "Pay"}
</button>;Some providers accept this natively — Stripe takes an Idempotency-Key header
and dedupes on their side. M-Pesa and Pesapal don't, so you implement the same
guarantee yourself. The principle is identical either way.
Layer 1: the UI (necessary, never sufficient)
Disabling the button and showing progress stops the most common double-click, but the client is the weakest link — it can be bypassed, and it can't help with network retries or a second tab. Treat the UI as a courtesy, and put the real guarantee on the server.
Layer 2 + 3: the server dedupes, the database guarantees
On the server, "one charge per key" is enforced by a database unique constraint on the idempotency key. Your application check is the fast path; the constraint is what actually holds under concurrency:
app.post("/api/payments", async (req, res) => {
const { orderId, idempotencyKey } = req.body;
// Never start a payment for an order that's already paid
const order = await Order.findByPk(orderId);
if (!order || order.status === "paid") {
return res.status(409).json({ error: "Order is not payable" });
}
// One payment per key. findOrCreate turns a duplicate into a lookup,
// and the unique constraint backs it up when two requests race.
let payment, created;
try {
[payment, created] = await Payment.findOrCreate({
where: { idempotencyKey },
defaults: { orderId, status: "pending" },
});
} catch (e) {
if (e.name === "SequelizeUniqueConstraintError") {
payment = await Payment.findOne({ where: { idempotencyKey } });
created = false;
} else {
throw e;
}
}
// Duplicate request → return the ORIGINAL result, don't charge again
if (!created) {
return res.json({
status: payment.status,
reference: payment.providerReference,
});
}
// First time only → call the provider, keyed on our own payment id
const result = await provider.initiate({ orderId, reference: payment.id });
payment.providerReference = result.reference;
await payment.save();
return res.json({ status: "pending", reference: result.reference });
});The important move is what happens on a duplicate: you return the same response as the original request, not an error and definitely not a second charge. That's what "idempotent" actually means — repeat the call, get the same result, change nothing.
Don't fix the front door and leave the back door open
Providers retry their notifications too. M-Pesa resends callbacks; Pesapal fires an IPN you then have to verify. If your webhook handler isn't idempotent, a retried notification marks the order paid — or credits it — twice. So dedupe the webhook on the provider's reference and process it under a row lock, so a duplicate notification becomes a no-op. (This is the atomic, locked callback handling worth building into your payments table from the start.)
Reconciliation: the backstop for real money
Even with every guard in place, verify the final state against the provider's status endpoint rather than trusting the notification, and run a reconciliation job that flags anomalies — the same order with two successful provider references, say. If a genuine duplicate charge ever slips through, you want to detect and refund it automatically, not hear about it from an angry customer. With money, belt and suspenders is the correct amount of caution.
The gotchas that undo all of it
- Generate the key on screen load, not per click. A new key per click is no key at all — every retry looks new.
- The database constraint is the guarantee. An application-level
ifraces; the unique index doesn't. - Return the original result for duplicates. Same response, no new side effects.
- Check order state first. Never initiate a payment for an order that's already paid.
- Make webhooks idempotent too. Guarding initiation but not notifications only moves the bug.
Building payments and want them bulletproof?
Double charges erode trust faster than almost any other bug, because they cost your users real money. I build payment flows in Node.js that are idempotent end to end — UI to database to webhook — with the reconciliation to back them up. If that's what your product needs, reach out, or find me on Fiverr and Upwork.

