Artikel

Der Nutzer hat 3-mal auf „Bezahlen“ geklickt! Idempotency Keys in Zahlungssystemen richtig entwerfen

Ein Nutzer mit wackeliger Mobilfunkverbindung tippt dreimal auf „Jetzt bezahlen“ und wird dreimal für dieselbe Bestellung belastet. Warum es nicht reicht, den Button im Frontend zu deaktivieren, und wie Backends auf Stripe-Niveau mit Idempotency Keys eine genau einmalige Ausführung garantieren.

Furkan Özay23. Oktober 2026 · 3 Min. Lesezeit
czCZAY.DEV // EDITORIALDE
SYSTEM ARCHITECTURE

#Sistem Mimarisi · 3 MIN LESEZEIT

FURKAN ÖZAYVOL. 2026 // #10EC

Stell dir den klassischen E-Commerce-Albtraum vor: Ein Kunde sitzt in der U-Bahn, mit einer holprigen 4G-Verbindung. Er tippt auf „2.500 TL bezahlen“. Die Anfrage erreicht dein Backend und belastet die Karte, aber die Antwort geht verloren, bevor sie sein Handy erreicht. Genervt tippt er noch zweimal auf den Button, oder sein HTTP-Client wiederholt die Anfrage automatisch.

Zehn Sekunden später bekommt er drei SMS seiner Bank: Seine Kreditkarte wurde dreimal für dieselbe Bestellung belastet.

· · ·

1. Warum „Deaktivier den Button einfach in React“ in Produktion nicht reicht

Junior-Entwickler antworten auf dieses Szenario oft: „Ganz einfach! Setz im Frontend einfach disabled={isLoading} auf den Submit-Button.“

Einen Button zu deaktivieren ist zwar gute UX, garantiert aber nie Sicherheit im Backend:

  1. Retries auf Netzwerkebene: Mobile Apps, API-Gateways und Webhook-Sender wiederholen Anfragen automatisch, wenn eine TCP-Verbindung in einen Timeout läuft.
  2. Anfragen aus mehreren Tabs/Geräten: Ein Nutzer kann den Checkout in zwei Tabs öffnen oder die Einschränkungen der Frontend-Oberfläche komplett umgehen.
  3. Das Zwei-Generäle-Problem: Bricht die Verbindung mittendrin ab, kann der Client nicht wissen, ob der Server vor oder nach der Abbuchung der Karte gescheitert ist.

Um das sicher zu lösen, muss die Zahlungsoperation idempotent sein.

· · ·

2. Was ist Idempotenz (und ein Idempotency Key)?

In Mathematik und Informatik ist eine Operation idempotent, wenn ihre einmalige Ausführung exakt denselben Systemzustand erzeugt wie zehn Ausführungen.

  • GET /orders/123 und DELETE /orders/123 sind von Natur aus idempotent.
  • POST /payments/charge ist NICHT von Natur aus idempotent: Jeder Aufruf erzeugt eine neue Abbuchung.

Damit POST /payments/charge idempotent wird, erzeugt der Client einen eindeutigen Idempotency Key (typischerweise eine UUIDv4, die an genau diesen Checkout-Vorgang gebunden ist) und sendet ihn im 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. Backend-Umsetzung Schritt für Schritt (Redis + Datenbank)

Wenn dein Backend eine Anfrage mit einem Idempotency-Key erhält, durchläuft es eine strikte, atomare Zustandsmaschine:

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: Sichere Redis immer mit einem Unique Constraint in der Datenbank ab

Zusätzlich zum schnellen Redis-Lock (SET NX) solltest du in deiner PostgreSQL-Tabelle payments immer einen UNIQUE-Index auf idempotency_key (bzw. order_id für abgeschlossene Abbuchungen) anlegen, als letztes ACID-Sicherheitsnetz.

Interaktive Roadmap & Tool

Als Nächstes lesen