Idempotency
A customer's phone retries one payment twenty times at once. With no protection they are charged twenty times. With a half-protection, still several. With the right one, once.
Try it
Phones lose signal and try again. People double-tap. Every one of those is the same payment sent twice, or twenty times. Press the button and watch what each version of the shop does with it.
"Charge order order_… $49." Same order, same amount, 20 times. Only the shop can tell they are one payment.
- charged once
- charged again
- got the first answer again
- waiting
- not tapped
One $49 payment, tapped 20 times at once, sent to three versions of the same shop. The customer should pay once.
Limit: 60 runs per hour per visitor. Each run writes one row to idem.runs and up to 41 rows to idem.charges. Rows older than 24 hours are deleted.
Three places to stop it
1 · A receipt number from the app
The app sends a unique number with every payment attempt, and reuses it when it retries. If the shop has already seen that number, it returns the old answer instead of charging again.
2 · One payment per order
Apps forget to send numbers. So the shop also refuses to hold two paid receipts for the same order. That rule lives in the database, where nobody can forget it.
3 · Tell the card company too
The card company charged the card, then our server crashed before writing it down. The retry would charge again, unless the card company also got our receipt number. Every serious provider accepts one.
The code
What runs behind the button, in one sentence. The code itself is one click away.
The bad version asks the database 'have I seen this receipt number?' and then writes. The good version writes the number first, and the database refuses everyone who comes second.
Show the code anyway
const seen = await db.query( "SELECT 1 FROM idempotency_keys WHERE key = $1", [idemKey]);if (seen.rowCount) return storedResponse(idemKey);const charge = await db.query( "INSERT INTO charges (order_id, amount_cents) VALUES ($1, $2) RETURNING id", [orderId, amount]);await db.query( "INSERT INTO idempotency_keys (key, charge_id) VALUES ($1, $2)", [idemKey, charge.rows[0].id]);return created(charge.rows[0]);await db.query("BEGIN");const claim = await db.query( `INSERT INTO idempotency_keys (key) VALUES ($1) ON CONFLICT DO NOTHING RETURNING key`, [idemKey]);if (claim.rowCount === 0) { await db.query("ROLLBACK"); return storedResponse(idemKey); // may be "in progress": return 409}const charge = await db.query( "INSERT INTO charges (order_id, amount_cents) VALUES ($1, $2) RETURNING id", [orderId, amount]);await db.query( "UPDATE idempotency_keys SET charge_id = $2, response = $3 WHERE key = $1", [idemKey, charge.rows[0].id, created(charge.rows[0])]);await db.query("COMMIT");const intent = await stripe.paymentIntents.create( { amount, currency: "eur", metadata: { order_id: orderId } }, { idempotencyKey: `charge:${orderId}` });// Same key, same request body → Stripe returns the same intent.// Different body with the same key → Stripe refuses. That is the point.Still curious?
A few more questions people ask. Open what you need.
Why does checking a list first still fail?
Twenty requests arrive together. Each one looks at the list, sees it empty, and goes ahead. Looking and writing are two separate steps, and everyone finishes step one before anyone finishes step two.
What should the second attempt see?
The same answer the first attempt got. If the first attempt is still in progress, a polite 'still working, try again in a second'.
How long does the shop remember a receipt number?
About a day. Longer than any phone or queue will keep retrying.
Is this the same thing as the double-spend article?
No. This stops one payment from being charged twice. Double-spend is about two different payments racing for the same money. You need both.
Ticketing: idempotency on three levels
This article is the toy version of a problem from a real project. The case page tells what happened and what it cost.