Mesrai
Back to blog
// essayTechnical Deep Dive

Double-Submit Form Race: Mesrai's Idempotency-Key Suggestion

Real PR catch: payment endpoint without idempotency — double-clicks cause double charges. Stripe idempotency-key + DB upsert fix.

Mesrai TeamJuly 29, 20268 min read

A checkout endpoint that creates a Stripe charge. The author left out idempotency entirely. Mesrai flagged it — every payment endpoint without idempotency is a double-charge incident waiting to happen.

The vulnerable diff

tsapi/checkout.ts
// api/checkout.ts
checkoutRouter.post("/charge", requireAuth, async (req, res) => {
  const { amount, source } = req.body;
  // BUG: no idempotency. Network retry or double-click → double charge.
  const charge = await stripe.charges.create({ amount, source });
  await db.orders.create({ data: { userId: req.user.id, chargeId: charge.id }});
  res.json({ ok: true, chargeId: charge.id });
});

What is wrong

Payment systems are noisy networks — clients retry, users double-click, mobile apps re-submit on a slow response. Every retry that reaches the server is a potential duplicate operation. Idempotency keys solve the class: the client generates a unique key per logical attempt, the server (and provider) deduplicate by that key. Stripe's API supports `Idempotency-Key` as a header on every mutating call — providing one means retries are safe by construction. The application layer also needs idempotency: a duplicate `db.orders.create` writes a duplicate order even if the Stripe charge was deduplicated.

The attack

Production symptoms:

textexploit
1. Customer on slow mobile network taps Pay. Request takes
   8 seconds. Customer assumes it failed, taps again.

2. Both requests reach the server. Stripe processes both as
   independent charges (no idempotency key supplied). Two charges.

3. Application writes two orders. Two fulfillment workflows kick off.

4. Customer support ticket. Refund cycle. Operations time on root
   cause.

Single-digit percent of payment transactions in apps without idempotency suffer some variation of this.

Mesrai's review comment

textreview-comment.txt
mesraipilot · Bot · reviewed 2 min ago

[mesrai] [code-review] [Logic] [Idempotency] [high]

Payment endpoint without idempotency. Network retries or
double-clicks cause duplicate charges and duplicate orders.

Two-layer fix:

  1. Require a client-supplied Idempotency-Key header. Pass it to
     Stripe so the provider deduplicates the charge.

  2. Use the same key as a uniqueness constraint on the orders
     table so a duplicate request produces the same order id.

  const key = req.header("Idempotency-Key");
  if (!key) return res.status(400).json({ error: "Idempotency-Key required" });

  const charge = await stripe.charges.create(
    { amount, source },
    { idempotencyKey: key }
  );

  await db.orders.upsert({
    where: { idempotencyKey: key },
    create: { idempotencyKey: key, userId: req.user.id, chargeId: charge.id },
    update: {},
  });

Frontend: generate key per submit attempt (uuid v4), persist in
sessionStorage so retries reuse it.

The fix

tsapi/checkout.ts (fixed)
// api/checkout.ts — fixed
checkoutRouter.post("/charge", requireAuth, async (req, res) => {
  const key = req.header("Idempotency-Key");
  if (!key) return res.status(400).json({ error: "Idempotency-Key required" });

  const { amount, source } = req.body;

  const charge = await stripe.charges.create(
    { amount, source },
    { idempotencyKey: key }
  );

  const order = await db.orders.upsert({
    where: { idempotencyKey: key },
    create: { idempotencyKey: key, userId: req.user.id, chargeId: charge.id, amount },
    update: {}, // existing order returned as-is
  });

  res.json({ ok: true, chargeId: charge.id, orderId: order.id });
});

Two layers of idempotency. Stripe deduplicates the charge based on the `idempotencyKey` option. The application deduplicates the order via a unique constraint on `idempotencyKey` + upsert. A retry produces the same chargeId, the same orderId, no duplicate side effects.

Why human review missed it

Idempotency keys are missing from most pre-production payment integrations because the tutorials skip them — the happy path works without them and the bug only surfaces in production under network flakiness or in customer-support tickets weeks later. Mesrai catches every `stripe.*.create`, `paypal.*.create`, `razorpay.*.create` call without an idempotency-key parameter.

Related rules + further reading

Mesrai rule pack: logic/payment-idempotency-required — flags payment provider creates without idempotency keys.

Stripe docs: Idempotent Requests.

Common in fintech post-mortems; Stripe published guidance on it for a reason.

Takeaway

Every payment endpoint needs an idempotency key — supplied by the client, passed to the provider, used as a uniqueness constraint on the order row. Three changes, double-charge class closed.

// try

See it on your next PR.

Free for individuals. Install in two minutes. Mesrai reviews every commit.