Dev.to WebDev 🛠 Dev 👁 0 📖 6 min read

Idempotency keys: stop charging customers twice when they double-click Pay

A customer taps Pay. The spinner hangs for four seconds, so they tap it again. Or their phone drops from Wi-Fi to mobile data halfway through the request, and the app retries. Your server sees two identical POST requests

A customer taps Pay. The spinner hangs for four seconds, so they tap it again. Or their phone drops from Wi-Fi to mobile data halfway through the request, and the app retries. Your server sees two identical POST requests, and unless something stops it, the customer is charged twice.

Nothing is broken in that story. The network is doing what networks do, and the client is doing what good clients do: retrying. The fix is to make the request idempotent, so that sending it twice has the same effect as sending it once.

The standard way to do that for payments and other create-something requests is an idempotency key. Stripe's API has used one for years, and the IETF has a draft standard for an Idempotency-Key HTTP header. Here's a small, tested implementation for Express and Postgres, and the edge cases that matter.

How it works

The client generates a unique key for each intended action, a UUID created when the user opens the checkout, and sends it with every attempt at that action:

POST /payments
Idempotency-Key: 6f1c2d0e-8b7a-4c3e-9d21-5a0b7e4f9c11
Content-Type: application/json

{ "amount": 4900 }

The server remembers each key it has seen. On the first request it does the work and stores the response. On any repeat it skips the work and returns the stored response. Five things have to be true for that to be safe:

  1. Claiming a key is atomic. Two requests arriving at the same moment must not both think they're first.
  2. A repeat while the first is still running doesn't run again. It waits, or gets told to retry.
  3. A key reused with a different body is rejected. Otherwise a bug that reuses keys silently returns the wrong payment.
  4. Keys are scoped per user, so two users can't collide.
  5. Failures that didn't change anything can be retried.

The table

CREATE TABLE idempotency_keys (
  user_id       text        NOT NULL,
  key           text        NOT NULL,
  request_hash  text        NOT NULL,
  status        text        NOT NULL DEFAULT 'in_progress', -- or 'done'
  response_code int,
  response_body jsonb,
  created_at    timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (user_id, key)
);

The primary key on (user_id, key) does the heavy lifting. Postgres guarantees only one row can exist for that pair, which makes "claim this key" a single atomic statement.

The middleware

// idempotency.js — Express middleware. `db.query(sql, params)` returns { rows }, as node-postgres does.
import { createHash } from "node:crypto";

export function idempotent(db) {
  return async (req, res, next) => {
    const key = req.get("Idempotency-Key");
    if (!key) return res.status(400).json({ error: "Idempotency-Key header is required" });

    const userId = req.user.id; // scope keys per user, so two users can't collide
    const requestHash = createHash("sha256").update(JSON.stringify(req.body)).digest("hex");

    // 1. Try to claim the key. The primary key makes this atomic: one request wins.
    const claimed = await db.query(
      `INSERT INTO idempotency_keys (user_id, key, request_hash)
       VALUES ($1, $2, $3)
       ON CONFLICT (user_id, key) DO NOTHING
       RETURNING key`,
      [userId, key, requestHash],
    );

    // 2. Someone already used this key. Decide what to tell them.
    if (claimed.rows.length === 0) {
      const { rows: [row] } = await db.query(
        `SELECT request_hash, status, response_code, response_body
         FROM idempotency_keys WHERE user_id = $1 AND key = $2`,
        [userId, key],
      );
      if (row.request_hash !== requestHash) {
        return res.status(422).json({ error: "Idempotency-Key was already used for a different request" });
      }
      if (row.status === "in_progress") {
        return res.status(409).json({ error: "A request with this Idempotency-Key is still being processed" });
      }
      res.set("Idempotent-Replayed", "true");
      return res.status(row.response_code).json(row.response_body);
    }

    // 3. We own the key. Save the response before sending it, so a retry gets the same answer.
    const send = res.json.bind(res);
    res.json = (body) => {
      const save = res.statusCode >= 500
        ? db.query(`DELETE FROM idempotency_keys WHERE user_id = $1 AND key = $2`, [userId, key]) // let them retry
        : db.query(
            `UPDATE idempotency_keys SET status = 'done', response_code = $3, response_body = $4
             WHERE user_id = $1 AND key = $2`,
            [userId, key, res.statusCode, body],
          );
      save.then(() => send(body), next);
      return res;
    };
    next();
  };
}

Three paths:

  • We claimed the key. INSERT ... ON CONFLICT DO NOTHING RETURNING returns a row only for the request that inserted it, so exactly one request gets here. It runs the handler, and res.json is wrapped so the response is saved before it's sent.
  • The key exists and is done. Return the saved status and body, with an Idempotent-Replayed: true header so the client and your logs can tell.
  • The key exists but is still in progress. Return 409 Conflict. The client should wait briefly and retry with the same key, and it will then get the replayed response.

A key reused with a different request body gets 422, because that's always a bug on the client side.

Using it

app.post("/payments", idempotent(db), async (req, res) => {
  try {
    const charge = await createCharge(req.user.id, req.body.amount);
    res.status(201).json(charge);
  } catch (e) {
    res.status(502).json({ error: e.message });
  }
});

Does it hold up?

We ran it against an in-process Postgres (PGlite) with a fake payment provider that takes 100 ms per charge, and fired the scenarios that matter. Actual output:

no key:                            400  {"error":"Idempotency-Key header is required"}
double click, request A:           201  {"id":"ch_1","userId":"u1","amount":4900}
double click, request B:           409  {"error":"A request with this Idempotency-Key is still being processed"}
retry after success:               201 (replayed) {"id":"ch_1","amount":4900,"userId":"u1"}
same key, different amount:        422  {"error":"Idempotency-Key was already used for a different request"}
same key, another user:            201  {"id":"ch_2","userId":"u2","amount":4900}
provider fails:                    502  {"error":"provider timeout"}
retry after failure:               201  {"id":"ch_3","userId":"u1","amount":4900}
charges created: 3

The double click produced one charge and one 409. The retry after success replayed the original charge instead of making a new one. Reusing the key with a different amount was rejected. A different user with the same key got their own charge. And when the provider failed, the key was released so the retry could succeed. Three real charges, for three real intentions.

The edge case that still charges twice

Look again at the failure path. If the provider times out, we return 502 and release the key so the customer can retry. But a timeout doesn't mean the charge failed. It means we don't know. If the provider did take the money and the customer retries, we've charged them twice, and our idempotency key didn't help, because we deleted it.

The fix is to pass the same idempotency key through to the payment provider. Stripe, for example, accepts an Idempotency-Key header on its API, so a retried request with the same key returns the original charge instead of creating a second one:

const charge = await stripe.paymentIntents.create(
  { amount: req.body.amount, currency: "usd", customer: customerId },
  { idempotencyKey: req.get("Idempotency-Key") },
);

Idempotency has to hold at every layer that can have side effects. Your key protects your database; the provider's key protects the money.

The rest of the checklist

  • Generate the key on the client when the intent starts, not on each click. A new key per click defeats the whole thing.
  • Hash a canonical body. JSON.stringify is fine when the same client code sends the same object. If key order can vary, sort keys before hashing.
  • Expire old keys. Keep them long enough to cover realistic retries, at least a day, then delete them with a scheduled job. Also clear rows stuck in_progress for longer than your request timeout, which happens if the server crashes mid-request.
  • Don't store secrets in response_body. It's a copy of what you sent the client, so the same rules apply.
  • Use it beyond payments. Anything that creates something or sends something: orders, invoices, emails, webhooks you forward on.

Why it's worth an afternoon

Double charges are rare, which is exactly why they're expensive. Each one is a support ticket, a refund, a chargeback risk and a customer who now double-checks every payment. An afternoon spent on the middleware above removes the whole class of bug.

It belongs in the first version of any fintech product, and of any SaaS product that takes money, because it's much easier to add before the first duplicate charge than after.

Have you been bitten by a double charge or a duplicate order? We'd like to hear how it got through, especially the ones that weren't a double click.

From the team at Redlio Labs, where we build web applications, SaaS products and the payment flows inside them.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.