DEV Community

Sohail Khan
Sohail Khan

Posted on

Your Pay Button Is Not the Problem. Your Server Is.

User pressed "Pay" twice. Got charged twice.

Last month a client called me in a panic: "Stripe is double charging our customers."

What actually happened

The user taps Pay Now in the Flutter app. Request 1 leaves the phone, then the network hangs. No response. The user taps again. Request 2 goes through.

Both requests hit POST /charge 200ms apart. Both succeeded. The customer got charged twice and the support inbox filled up.

The wrong fix

Disable the Pay button after the first tap.

That only fixes taps. It does nothing about retries, timeouts or flaky networks. The server never learns that the two requests were the same intent.

The real fix: idempotency keys

The client generates one UUID per payment intent and sends it as a header:

Idempotency-Key: 7f3a9c…
Enter fullscreen mode Exit fullscreen mode

Same intent, same key. Every time. The server stores the key together with the result on the first charge. When the same key arrives again, it returns the stored result instead of charging again.

Client side (Flutter)

final idempotencyKey = const Uuid().v4(); // one key per payment intent

final response = await http.post(
  Uri.parse('https://api.example.com/charge'),
  headers: {
    'Content-Type': 'application/json',
    'Idempotency-Key': idempotencyKey,
  },
  body: jsonEncode({'amount': 4999, 'currency': 'usd'}),
);
Enter fullscreen mode Exit fullscreen mode

On retry, reuse the same key. A new key means a new intent.

Server side (Express)

app.post("/charge", async (req, res) => {
  const key = req.headers["idempotency-key"];
  const seen = await db.charges.findOne({ key });
  if (seen) return res.json(seen.result); // same key, no second charge

  const result = await stripe.charges.create(req.body);
  await db.charges.insertOne({ key, result });
  res.json(result);
});
Enter fullscreen mode Exit fullscreen mode

How long to keep the key

Keep the key stored at least as long as your longest retry window. I keep mine 24 hours. After that the key can expire, because no client should still be retrying the same intent.

Why this matters beyond payments

Any endpoint that creates something can receive the same request twice: orders, bookings, signups. If the frontend can retry it, the backend must be able to recognize the retry. Stripe does this natively. Any payment API you build should do it too.


How do you handle double submits on your backend: frontend only, idempotency keys, or something else? Drop it in the comments.


I'm Sohail Khan, a Senior Full Stack Developer building AI agents and automation. Tech Lead at LoopxCorp. I write about backend engineering, AI integration and practical system design.

Top comments (0)