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

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'));
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());
})();

Steps

  1. 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 memory
    
    HTTP 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.

  2. 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 200

    The 200 returns the reference the 201 created rather than a new one.

  3. 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.

  4. Step 4.

    Send the same pair in one tick against the same in-process store.

    node pay-twice.js memory idem_ord_5512
    
    HTTP 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.

  5. 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_5512
    
    HTTP 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.

  6. Step 6.

    Read the target's own log for the statement that decided it. The two ExperimentalWarning lines Node prints for node:sqlite are cut here.

    cat charge.log
    
    …
    charge-api on 8479
    UNIQUE constraint failed: idem_keys.idem_key

    The 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.

  7. 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 200

    Same key, same body, and the stored reference comes back.

  8. 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 422

    Without 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

Sign: The idempotency test passes and support still sees double charges.Cause: The test sends the two requests one after the other. A handler that checks its key store, awaits the gateway and writes the key afterwards only fails when both requests sit inside that await together. Steps 3 and 4 are the same code with the same key.
Sign: The client generates the idempotency key inside its HTTP helper.Cause: A key created per request is new on every retry, so nothing matches and the mechanism is inert. The key belongs to the user action: created once when the button is pressed, reused by every attempt to complete that action.
Sign: The losing request gets a 409 and the client shows the customer an error.Cause: The charge succeeded. The 409 means the first attempt is still running, so the correct client behaviour is to wait and send the same key again, which step 7 shows returning the reference. Treating 409 as final loses a completed payment.
Sign: A unique index rejects the second insert, so the endpoint is called idempotent.Cause: Refusing a duplicate is not replaying one. If the error escapes as a 500 the retrying client sees a failure for work that succeeded. The constraint has to be caught and turned into the stored answer, which is what the catch block above does.

What to check next

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.

intermediate15 minpublished updated Maks Verny