Srikanth Bandaru

Click once, charge twice

A checkout that tags every request for safe retries can still bill one mug twice, or ship the wrong order.

Contents6 sections
  1. A new key on every retry
  2. The same key on a new cart
  3. Which layers can send a payment again?
  4. When should the key be created?
  5. Can the server catch a reused key?
  6. Put the checkout on the server

An idempotency key is a unique ID sent with a request, so the server can spot a retry and return its first answer. It stops a double charge only if it lives exactly as long as the customer’s intent to pay. A new key on every retry charges twice. A key kept too long makes the next order replay the last one.

A new key on every retry

This checkout sends a key with every request, and still charges twice for one mug. Tap Pay, tap Try again when it fails, and count the charges.

One mug, one tap on Pay

Scripted simulation, no real payments

Every request from this page carries an Idempotency-Key. It starts with a new key on every attempt.

1 × Mug, $20

Network

  1. No requests yet

Bank statement

  • No charges

Then switch when the key is created, and pay again
The fetch wrapper gives up after 8 seconds (its timeout) and then retries twice. The response to request #1 is lost. The phone is offline for both retries, so each fails at once and they go out a tenth of a second apart. The clock on the phone and the times in the log are simulated, so the 8 seconds pass in about one.

At first, the fetch wrapper (the function that sends each request and retries it) makes a fresh key for every attempt. The server charges request 1, but the response is lost. Try again brings a key the server has never seen, so it charges again.

// api.ts
async function post(url: string, body: any) {
  for (let i = 0; ; i++) {
    // Bug: a new key on every attempt
    const key = crypto.randomUUID();
    try {
      return await fetch(url, {
        method: 'POST',
        headers: {
          'Idempotency-Key': key,
        },
        body: JSON.stringify(body),
        signal: AbortSignal.timeout(8000),
      });
    } catch (e) {
      if (i >= 2) throw e;
    }
  }
}

The fix is to move crypto.randomUUID() out of the loop. Create the key on the first tap, and reuse it until the server gives a final answer. The second setting in the figure does this, and the bank shows 1 charge.

The same key on a new cart

In the second setting, hit the error, tap Back, add the hat and pay $35. In a single-page app (SPA), Back only changes the screen, so the old key is still in memory. The server sees that key and replays its stored answer for the $20 mug. The phone shows “Paid” for $35, built from the cart it sent. The hat is never charged and never ships.

Which layers can send a payment again?

On its way from the phone to the bank, a payment request can be sent again by at least 7 different pieces of software.

Who sends the copy Which key it carries
The user, tapping Pay again A new one, unless the app kept the key
Your fetch wrapper, after a timeout A new one, if the key is made inside the loop
Chromium, when a reused connection drops Yours
Workbox (a library that stores failed requests in the browser), up to 7 days later Yours
stripe-node (Stripe’s Node.js client), on your server Its own
Go’s net/http, on a reused connection Yours
nginx (a reverse proxy), only with non_idempotent set Yours

A copy that carries your key is safe if the server locks the key before doing any work. A copy with a new key looks like a new payment, and stripe-node’s own key can’t fix that. The user and the fetch wrapper make those copies.

When should the key be created?

The figure tries 6 moments to create the key, against 8 scripted scenarios.

How long the key lives

Scripted model of 8 scenarios, not a real server
Key lifetime

in an SPA, until a full reloadA common tutorial pattern

Server keeps keys for

3 of 8 ok. Same as per submission on rows 1–7, and it also loses tomorrow’s purchase.

filled: key lived too briefly; hollow: key lived too long

Wrong order: the phone shows success for a cart the server never charged. Lost order: a deliberate second purchase swallowed as a duplicate. Stuck: a 422 the user can’t resolve.

Rules every row follows
  • The fetch wrapper has an 8 s timeout and 2 retries.
  • A 409 reply means “still in progress”. Clients that hold a key retry a 409 with the same key after 1 s. The per-attempt UI treats any failed reply as an error.
  • After the last scripted step, if the phone shows an error, the user taps Try again once. Row 7 is the exception: the user has closed the app.
  • The phone renders "Paid" from client state.
  • The server locks the key first, returns 409 while a request with that key is still running, and replays the response once it’s finished.
  • The app is a single-page app.
Outcomes come from running each row’s script against a model of the client and server, not a real payment service. Several results depend on stated client rules, such as how a client treats a 409 or a 422. Open a row to see its script.

It opens on “per page load”, a pattern common in tutorials. In a multi-page app, that key died with the page. In an SPA, it lives for the whole shopping session.

Per intent does better. The key is created when the customer commits to this purchase, saved, and reused for every retry of it. I wrote down the same rule in a 2026 note on offline design for a sales app on phones. Each write the user makes is queued with its own client mutation ID. The server can then treat a retry as the same write.

The last option gets all 8 rows right because it stops depending on how long a key lives. The checkout itself becomes a record on the server.

Can the server catch a reused key?

Partly. The IETF’s draft for the Idempotency-Key header says a key reused with a different body should get a 422 error. To tell, the server can store a fingerprint, a hash of the body. Stripe also rejects a reused key with different parameters.

But if the client treats the 422 as a fresh start, it makes a new key and charges $35 on top of the $20. In the figure, tick the 422 check in the server settings and pick per submission. Row 5 flips to double charge. Before it sends anything new, the client must ask what happened to the earlier payment.

Put the checkout on the server

When an intent outlives one request, give it a database row. POST /checkouts creates a draft, and PUT changes its cart. POST /checkouts/:id/confirm moves it from draft to paying to paid. A unique constraint, a database rule, allows one checkout per cart. A second tab gets the same checkout, and a late replay finds it paid.

Stripe’s PaymentIntent, its record of one payment, works this way. Creating one charges nothing, so it can be keyed on the cart. The charge happens at confirm, under a short-lived key.

The server has its own half. This handler has 4 bugs.

app.post('/pay', async (req, res) => {
  const key = req.get('Idempotency-Key');
  // (a) two copies can both miss
  const seen = await db.findKey(key);
  if (seen) return res.json(seen);
  // (b) not in the transaction
  await bank.charge(req.body.cents);
  const order = await db.save(req.body);
  // (c) crash before here: no record
  await db.saveKey(key, order);
  // (d) no fingerprint check
  res.json(order);
});

The worst is (b). The charge sits outside the database transaction, the writes that succeed or fail together, so a crash before (c) leaves a charge with no record.

Brandur Leach’s 2017 design still fixes all 4. Lock the key first, and reject a key whose parameters differ. Save a recovery point before each call to another system, so a background job can finish abandoned requests. Keep keys longer than any client retries. Workbox can retry for 7 days, but Stripe may drop a key after 24 hours.