Mesrai
Back to blog
// essayTechnical Deep Dive

Missing Idempotency-Key on POST: The Stripe-Inspired Catch

Real PR catch: orders-create POST with no idempotency key — retries create duplicates. Header-required + upsert fix.

Mesrai TeamAugust 17, 20268 min read

An orders-create endpoint. POST. No idempotency key. Identical to the payment-create bug in cluster 3 — different surface, same issue. Mesrai flagged it.

The vulnerable diff

tsapi/orders.ts
// api/orders.ts
ordersRouter.post("/orders", requireAuth, async (req, res) => {
  // BUG: no idempotency. Network retry creates two orders.
  const order = await db.orders.create({
    data: { userId: req.user.id, items: req.body.items, total: req.body.total },
  });
  return res.json({ id: order.id });
});

What is wrong

Idempotency keys are the standard pattern for any state-changing API that can be retried. The client generates a unique key per logical attempt; the server dedupes by it. Stripe, AWS, Razorpay all expose this and document it as required for mutating calls. Without it, every retry creates a new resource — mobile networks are noisy, double-clicks are real, the bug shows up under load.

The attack

Symptom:

textexploit
# User on slow mobile network taps Place Order. 7s pass.
# Frustrated, taps again. Both requests reach the server.
# Two orders. Two fulfillment workflows. Two charges.

Common cause of e-commerce support tickets.

Mesrai's review comment

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

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

POST that creates resources without idempotency key. Network retries
and double-clicks create duplicates.

Require an Idempotency-Key header; dedupe by upsert:

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

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

Frontend: generate UUID per submit attempt, persist in
sessionStorage so retries reuse it.

The fix

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

  const order = await db.orders.upsert({
    where: { idempotencyKey: key },
    create: {
      idempotencyKey: key, userId: req.user.id,
      items: req.body.items, total: req.body.total,
    },
    update: {},
  });
  return res.json({ id: order.id });
});

Upsert by idempotency key. Retries return the same order. Frontend reuses the key on retry. Class closed.

Why human review missed it

Idempotency is missing on most pre-production POST endpoints because the happy path doesn't need it. The bug surfaces under retry, flakiness, double-clicks — all common in production. Mesrai catches every state-changing POST/PUT/PATCH/DELETE without an idempotency key check.

Related rules + further reading

Mesrai rule pack: logic/post-idempotency-required — flags mutating endpoints without Idempotency-Key validation.

Stripe docs: Idempotent Requests.

Same shape as cluster 3 post 27 — different surface, same principle.

Takeaway

Every mutating POST needs an idempotency key. Generated by client, deduped by server. Mesrai catches the gap.

// try

See it on your next PR.

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