How to test duplicate payment prevention
Fire two identical charge requests in the same tick, both carrying one idempotency key, then count the ledger rows: node pay-twice.js memory idem_ord_5512. One charge means the key held. Two means the endpoint read its key store, awaited the gateway, and wrote afterwards, which is what a double click produces.
Why check this
A customer double clicks. A mobile client retries on a dropped connection. A queue worker re-runs a job after a timeout. None of them knows whether the first attempt reached the gateway. Run this before any release that touches checkout, and again after a change to the retry policy on either side. The failure it prevents is one basket charged twice, which arrives as a chargeback rather than a bug report.
Prerequisites
- curl 7.0 or later. See the curl manual.
- Node 22 for the target. Save it as
charge-api.jsand start it withnode charge-api.js > charge.log 2>&1 &, so step 6 can read its log. Node prints an ExperimentalWarning fornode:sqliteon startup, which is expected. Thechargestable is the money that moved;idem_keysis the reservation, and its primary key is what the second half of the run tests:
const http = require('node:http');
const { DatabaseSync } = require('node:sqlite');
const db = new DatabaseSync(':memory:');
db.exec('CREATE TABLE charges (id INTEGER PRIMARY KEY, idem_key TEXT, amount INTEGER, gateway_ref TEXT)');
db.exec('CREATE TABLE idem_keys (idem_key TEXT PRIMARY KEY, amount INTEGER, gateway_ref TEXT)');
let mode = 'memory';
let seq = 0;
const keys = new Map();
const callGateway = () => new Promise((r) => setTimeout(() => r('ch_' + ++seq), 20));
http.createServer((req, res) => {
const url = new URL(req.url, 'http://127.0.0.1:8479');
const json = (code, body, extra = {}) => {
res.writeHead(code, { 'content-type': 'application/json', ...extra });
res.end(JSON.stringify(body));
};
if (req.method === 'GET') {
const rows = db.prepare('SELECT amount, gateway_ref FROM charges ORDER BY id').all();
return json(200, { mode, charges: rows.length, total: rows.reduce((s, r) => s + r.amount, 0), rows });
}
if (req.method === 'DELETE') {
mode = url.searchParams.get('mode') || 'memory';
keys.clear(); seq = 0;
db.exec('DELETE FROM charges; DELETE FROM idem_keys');
return json(200, { mode, charges: 0 });
}
let raw = '';
req.on('data', (c) => { raw += c; });
req.on('end', async () => {
const b = JSON.parse(raw || '{}');
const k = req.headers['idempotency-key'];
const record = (ref) =>
db.prepare('INSERT INTO charges (idem_key, amount, gateway_ref) VALUES (?,?,?)').run(k ?? null, b.amount, ref);
if (!k) {
const ref = await callGateway();
record(ref);
return json(201, { charged: b.amount, gateway_ref: ref, key: null });
}
if (mode === 'memory') {
const prev = keys.get(k);
if (prev) {
if (prev.amount !== b.amount) return json(422, { title: 'Key reused with a different amount', stored: prev.amount, sent: b.amount });
return json(200, { replay: true, charged: prev.amount, gateway_ref: prev.gateway_ref }, { 'idempotent-replay': 'true' });
}
const ref = await callGateway();
keys.set(k, { amount: b.amount, gateway_ref: ref });
record(ref);
return json(201, { charged: b.amount, gateway_ref: ref, key: k });
}
try {
db.prepare('INSERT INTO idem_keys (idem_key, amount, gateway_ref) VALUES (?,?,NULL)').run(k, b.amount);
} catch (e) {
console.log(e.message);
const prev = db.prepare('SELECT amount, gateway_ref FROM idem_keys WHERE idem_key = ?').get(k);
if (prev.amount !== b.amount) return json(422, { title: 'Key reused with a different amount', stored: prev.amount, sent: b.amount });
if (prev.gateway_ref === null) return json(409, { title: 'Idempotency key in use, the first attempt has not finished', status: 409 });
return json(200, { replay: true, charged: prev.amount, gateway_ref: prev.gateway_ref }, { 'idempotent-replay': 'true' });
}
const ref = await callGateway();
db.prepare('UPDATE idem_keys SET gateway_ref = ? WHERE idem_key = ?').run(ref, k);
record(ref);
json(201, { charged: b.amount, gateway_ref: ref, key: k });
});
}).listen(8479, '127.0.0.1', () => console.log('charge-api on 8479'));
- A second file,
pay-twice.js, which resets the ledger and then sends both charges in one tick. Twocurlprocesses started from a shell do not overlap:
const [mode = 'memory', key] = process.argv.slice(2);
const url = 'http://127.0.0.1:8479/v1/charges';
const body = JSON.stringify({ order: 'ord_5512', amount: 4999, currency: 'usd' });
const headers = { 'content-type': 'application/json', ...(key ? { 'idempotency-key': key } : {}) };
const send = () =>
fetch(url, { method: 'POST', headers, body }).then(async (r) => `HTTP ${r.status} ${await r.text()}`);
(async () => {
await fetch(`${url}?mode=${mode}`, { method: 'DELETE' });
for (const line of await Promise.all([send(), send()])) console.log(line);
console.log(await (await fetch(url)).text());
})();
- Pick a port nothing else is using and change it in both files. Stop the target with Ctrl+C afterwards. The 20 ms in
callGatewaystands for the call your service makes to the provider, which in production is slower and holds the window open longer.
Steps
- Step 1.
Send two identical charges at the same instant with no idempotency key at all. This is the baseline the rest is measured against.
node pay-twice.js memoryHTTP 201 {"charged":4999,"gateway_ref":"ch_1","key":null} HTTP 201 {"charged":4999,"gateway_ref":"ch_2","key":null} {"mode":"memory","charges":2,"total":9998,"rows":[{"amount":4999,"gateway_ref":"ch_1"},{"amount":4999,"gateway_ref":"ch_2"}]}Two gateway references and a total of 9998 minor units for a basket of 4999.
- Step 2.
Send the same two charges in sequence, both carrying one key, against an in-process key store.
curl -s -X DELETE 'http://127.0.0.1:8479/v1/charges?mode=memory' > /dev/null && for i in 1 2; do curl -s -w ' HTTP %{http_code}\n' -X POST http://127.0.0.1:8479/v1/charges -H 'content-type: application/json' -H 'Idempotency-Key: idem_ord_5512' -d '{"order":"ord_5512","amount":4999,"currency":"usd"}'; done{"charged":4999,"gateway_ref":"ch_1","key":"idem_ord_5512"} HTTP 201 {"replay":true,"charged":4999,"gateway_ref":"ch_1"} HTTP 200The 200 returns the reference the 201 created rather than a new one.
- Step 3.
Count the ledger.
curl -s http://127.0.0.1:8479/v1/charges{"mode":"memory","charges":1,"total":4999,"rows":[{"amount":4999,"gateway_ref":"ch_1"}]}One charge from two requests. A suite that stops here reports the key as working.
- Step 4.
Send the same pair in one tick against the same in-process store.
node pay-twice.js memory idem_ord_5512HTTP 201 {"charged":4999,"gateway_ref":"ch_1","key":"idem_ord_5512"} HTTP 201 {"charged":4999,"gateway_ref":"ch_2","key":"idem_ord_5512"} {"mode":"memory","charges":2,"total":9998,"rows":[{"amount":4999,"gateway_ref":"ch_1"},{"amount":4999,"gateway_ref":"ch_2"}]}The same key, the same handler that passed step 3, two charges. Both requests read the store before either wrote to it.
- Step 5.
Repeat with the key held by a primary key in the database, so the reservation is a single statement with no gap in front of it.
node pay-twice.js unique idem_ord_5512HTTP 201 {"charged":4999,"gateway_ref":"ch_1","key":"idem_ord_5512"} HTTP 409 {"title":"Idempotency key in use, the first attempt has not finished","status":409} {"mode":"unique","charges":1,"total":4999,"rows":[{"amount":4999,"gateway_ref":"ch_1"}]}One charge. The loser is answered 409 and not 200, because at that moment the winner is still inside the gateway call and there is no reference to replay.
- Step 6.
Read the target's own log for the statement that decided it. The two ExperimentalWarning lines Node prints for
node:sqliteare cut here.cat charge.log… charge-api on 8479 UNIQUE constraint failed: idem_keys.idem_keyThe database refused the second reservation. Nothing in the handler had to notice the race, and the same statement would refuse it across two service instances.
- Step 7.
Retry the losing request now that the first attempt has finished.
curl -s -w ' HTTP %{http_code}\n' -X POST http://127.0.0.1:8479/v1/charges -H 'content-type: application/json' -H 'Idempotency-Key: idem_ord_5512' -d '{"order":"ord_5512","amount":4999,"currency":"usd"}'{"replay":true,"charged":4999,"gateway_ref":"ch_1"} HTTP 200Same key, same body, and the stored reference comes back.
- Step 8.
Reuse the key with a different amount.
curl -s -w ' HTTP %{http_code}\n' -X POST http://127.0.0.1:8479/v1/charges -H 'content-type: application/json' -H 'Idempotency-Key: idem_ord_5512' -d '{"order":"ord_5512","amount":5999,"currency":"usd"}'{"title":"Key reused with a different amount","stored":4999,"sent":5999} HTTP 422Without this check the endpoint would replay a 4999 charge to a client that asked for 5999 and report success.
How to read the result
| What you see | What it means | What to do | | --- | --- | --- | | Sequential pair, one charge | The key works when nothing overlaps | Keep going. Step 4 is the case that decides. | | Concurrent pair, two charges | Read, await, write, with the key stored last | Reserve the key in one statement before the gateway call. | | Concurrent pair, one charge and a 409 | The reservation is atomic | Check the client retries the 409 rather than surfacing it. | | Two different gateway references | Two charges left your service | Count money, not HTTP statuses. Both calls returned 201. | | Second call 500 | A constraint fired and nothing caught it | The client cannot tell this from a real failure and retries into it. | | Same key, different amount, 200 | The stored response is replayed for a request that is not the same request | Compare the stored amount before replaying, as step 8 does. |
Common mistakes
What to check next
- Webhook idempotency: the same race on the inbound side, keyed by the provider's event id instead of your own header.
- How to test API idempotency: the general property and the replay contract, without the money.
- Price tampering: the other reason to compare the stored request against the one that arrived.
- Zero decimal currency: the amount compared in step 8 is only meaningful once the unit is settled.
- Payment gateway testing checklist: where this check sits in a release pass.
FAQ
How do idempotency keys work in payment systems?
The client sends one key per user action. The service reserves that key before it calls the gateway and stores the answer against it. Every later request with the same key gets the stored answer instead of a second charge.
What does an idempotency key implementation have to store?
The key, a fingerprint of the request it was first used with, and the response once there is one. The fingerprint is what lets step 8 answer 422 rather than replaying a 4999 charge to a client that asked for 5999.
Should the key be a header or a body field?
A header. The key describes the attempt rather than the resource, and a header survives a retry helper that re-serializes the body into different bytes. Idempotency-Key is the name in common use.
Can this be tested against a live payment provider?
No. Every attempt that gets through moves money. Run the target above, or a sandbox account you own, and keep the concurrency half of the test entirely local.
Why does the concurrent run need a script instead of two curl commands?
Two curl processes started from one shell do not overlap. The second request arrives after the first has finished, so the race never happens. The pair has to be issued from one process.
Verified
Verified by Maks Vernycurl 8.21.0node 22.23.2node:sqlite 3.51.3
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate15 minpublished updated Maks Verny