Writing

The User Clicked 'Pay' 3 Times! Designing Idempotency Keys in Payment Systems

A user on a flaky mobile connection taps 'Pay Now' three times—and gets charged three times for the same order. Why disabling the button on the frontend isn't enough, and how Stripe-grade backends guarantee exactly-once execution using Idempotency Keys.

Furkan ÖzayOctober 23, 2026 · 3 min read
czCZAY.DEV // EDITORIALEN
SECURITY & AUTH

#Sistem Mimarisi · 3 MIN READ

FURKAN ÖZAYVOL. 2026 // #10EC

Picture this classic e-commerce nightmare: A customer is riding the subway with a spotty 4G connection. They tap "Pay 2,500 TL". The request reaches your backend and charges their card, but the response drops before reaching their phone. Frustrated, they tap the button two more times—or their HTTP client automatically retries.

Ten seconds later, they receive three SMS notifications from their bank: their credit card was charged three times for the exact same order.

· · ·

1. Why "Just Disable the Button in React" Fails in Production

Junior developers often respond to this scenario by saying: "Easy! Just set disabled={isLoading} on the submit button in the frontend."

While disabling the button is good UX, it never guarantees backend safety:

  1. Network-Level Retries: Mobile apps, API gateways, and webhook senders automatically retry requests when a TCP connection times out.
  2. Multi-Tab / Multi-Device Requests: A user can open checkout in two tabs or bypass frontend UI constraints altogether.
  3. The Two Generals' Problem: When a network connection drops mid-flight, the client has no way of knowing whether the server failed before charging the card or after charging the card.

To solve this safely, the payment operation must be Idempotent.

· · ·

2. What is Idempotency (and an Idempotency Key)?

In mathematics and computer science, an operation is idempotent if performing it once produces the exact same system state as performing it ten times.

  • GET /orders/123 and DELETE /orders/123 are naturally idempotent.
  • POST /payments/charge is NOT naturally idempotent—every call creates a new charge.

To make POST /payments/charge idempotent, the client generates a unique Idempotency Key (typically a UUIDv4 tied to that specific checkout intent) and sends it in the HTTP header:

http
POST /api/v1/payments/charge HTTP/1.1
Host: api.czay.dev
Idempotency-Key: 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d
Content-Type: application/json
 
{
  "orderId": "ord_8841",
  "amount": 250000,
  "currency": "TRY"
}
· · ·

3. Step-by-Step Backend Implementation (Redis + Database)

When your backend receives a request with an Idempotency-Key, it executes a strict atomic state machine:

typescript
export async function processPayment(req, res) {
  const key = req.headers['idempotency-key'];
  if (!key) return res.status(400).json({ error: 'Missing Idempotency-Key header' });
 
  const redisKey = `idempotency:${req.user.id}:${key}`;
 
  // 1. Atomic Lock (SET if Not Exists with 24h TTL)
  const acquired = await redis.set(redisKey, JSON.stringify({ status: 'IN_PROGRESS' }), 'EX', 86400, 'NX');
 
  if (!acquired) {
    const existing = JSON.parse(await redis.get(redisKey));
    
    // If the first request is still talking to the bank right now:
    if (existing.status === 'IN_PROGRESS') {
      return res.status(409).json({ error: 'Payment is currently being processed. Please wait.' });
    }
    
    // If the first request already succeeded, return the cached receipt without charging again!
    return res.status(existing.statusCode).json(existing.body);
  }
 
  try {
    // 2. Execute the actual payment gateway call (iyzico / PayTR / Stripe)
    const paymentResult = await paymentGateway.charge(req.body);
 
    // 3. Cache the final response under the Idempotency Key
    const responsePayload = { status: 'COMPLETED', statusCode: 200, body: paymentResult };
    await redis.set(redisKey, JSON.stringify(responsePayload), 'EX', 86400);
 
    return res.status(200).json(paymentResult);
  } catch (err) {
    // If a transient network error occurred before reaching the bank, release the lock so retry is allowed
    await redis.del(redisKey);
    throw err;
  }
}

Info: Always Back Redis with a Database Unique Constraint

In addition to the fast Redis lock (SET NX), always add a UNIQUE index on idempotency_key (or order_id for completed charges) in your PostgreSQL payments table as your ultimate ACID safety net.

Interactive Roadmap & Tool

Read next