Stripe Webhook Duplicate: Postmortem – wie ein Stripe-Retry 18 Konten doppelte Gutschriften kostete

Ein Stripe-Retry hat due to fehlender Webhook-Idempotenz 18 Konten doppelt gutgeschrieben. Root Cause, Reparatur und nachhaltige Event-Ledger-Lösung.

Huifer
Huifer
20. Juli 202610 min read
Auch verfügbar auf:English · 中文

Geschrieben von Huifer, Solo-Entwickler und Maintainer von TanStack Ship. Ich habe dieses Produktions-Incident im Abrechnungsbereich bearbeitet, nachdem ein Stripe-Retry eine fehlende Idempotenz-Grenze aufgedeckt hatte: 18 Kundenkonten erhielten doppelte Gutschriften und zwei Abonnements wurden fälschlicherweise herabgestuft. Ich habe die finanzielle Auswirkung innerhalb von vier Stunden rückgängig gemacht. Die bleibende Lektion war nicht, dass Stripe unzuverlässig ist; sie war, dass mein Handler den Zustellungsvertrag missachtete, den Stripe dokumentiert – und Kunden bezahlten den operativen Preis.

Verifizierte Quellen: TanStack Ship GitHub · Stripe Webhook-Dokumentation · Stripe-Leitfaden zu Doppelereignissen Aktualisiert: 20. Juli 2026 · Changelog

TL;DR: Ein wiederholtes invoice.paid-Ereignis passierte einen Handler, der keinen dauerhaften Anspruch auf event.id hatte. Achtzehn Konten erhielten dieselbe Gutschrift zweimal, und ein nicht-atomarer Herabstufungspfad betraf zwei Abonnements. Ich habe die Konten repariert, ein eindeutiges Event-Ledger hinzugefügt, nachgelagerte Mutationen idempotent gemacht und Replay-Tests ergänzt. Stripe garantiert "at-least-once"-Verhalten, nicht "exactly-once"-Zustellung; der Empfänger muss dafür sorgen, dass Wiederholungen sicher sind.

Hintergrund: Eine korrekte Signaturprüfung war nicht genug

Die betroffene Route akzeptierte Stripe-Webhooks für Rechnungs- und Abonnement-Lebenszyklus-Ereignisse. Sie verifizierte die Signatur gegen den rohen Request-Body, übersetzte das Stripe-Objekt in einen internen Befehl, schrieb einen Gutschrifts-Ledger-Eintrag, aktualisierte den Abonnementstatus und hängte einen Audit-Eintrag an. Diese Sequenz sah in der Code-Review diszipliniert aus, weil jeder einzelne Vorgang Validierung und Fehlerbehandlung hatte.

Es fehlte eine systemische Eigenschaft: Die Verarbeitung desselben Ereignisses zweimal produzierte nicht dasselbe Ergebnis wie einmal. Die Signaturverifikation beantwortet die Frage: „Hat Stripe dieses Payload gesendet?" Sie beantwortet nicht: „Habe ich dieses Ereignis bereits angewendet?" Diese beiden Fragen wurden in meiner ursprünglichen Implementierung vermischt.

Stripes Dokumentation weist Integratoren darauf hin, automatische Wiederholungen und gelegentliche doppelte Ereignisse zu erwarten. Das Incident war also kein undokumentierter Überraschung des Anbieters. Das Versagen lag bei mir: Ich hatte Authentizität implementiert, aber keine Idempotenz. Die aktuellen Abrechnungsfunktionen von TanStack Ship trennen diese beiden Kontrollen explizit.

Der unsichere Verarbeitungspfad

Die ursprüngliche Reihenfolge der Operationen war im Wesentlichen:

  1. Signatur verifizieren;
  2. Kontostand lesen;
  3. Gutschriften im Zusammenhang mit der Rechnung gutschreiben;
  4. eine möglicherweise ausstehende Herabstufung prüfen und gegebenenfalls anwenden;
  5. eine Audit-Zeile schreiben;
  6. HTTP 200 zurückgeben.

Es gab keine Tabelle, die auf Stripes Ereignis-ID-Schlüssel basierte. Der Gutschrift-Eintrag hatte ebenfalls keine eindeutige Quell-Ereignis-Einschränkung. Wenn zwei Zustellungen sich überschnitten, konnten beide denselben Voraktualisierungs-Zustand lesen und beide eine scheinbar gültige Mutation durchführen.

Incident und Auswirkung auf Kunden

Eine vorübergehende Fehlerantwort führte dazu, dass Stripe ein invoice.paid-Ereignis erneut zustellte. Die Wiederholung führte dieselbe Stripe-event.id mit sich, wie erwartet. Mein Endpoint behandelte es als neue Arbeit. Zusätzliche Wiederholungszustellungen stießen auf dieselbe fehlende Sperre, bevor ich den betroffenen Mutationspfad deaktivierte.

Die begrenzte Auswirkung aus dem Incident-Datensatz war:

  • 18 Kundenkonten erhielten doppelte interne Gutschriften;
  • 2 Abonnements wurden nach doppelter Herabstufungsbehandlung in die falsche niedrigere Stufe verschoben;
  • die kontoseitige finanzielle Auswirkung wurde innerhalb von vier Stunden rückgängig gemacht;
  • es wird keine erfundene Verlustsumme beansprucht, da Kundenkontostände und Preise privat sind;
  • keine Kundennamen, Rechnungs-IDs oder Payloads werden veröffentlicht.

Die Formulierung „kostete uns 18 Kunden" im Titel beschreibt 18 betroffene Kundenbeziehungen, nicht 18 Kündigungen. Ich kann nicht verantwortungsvoll behaupten, dass jeder betroffene Kunde abgewandert ist. Dieser Unterschied ist in einem Incident-Bericht wichtig.

Erkennung kam aus Reconciliation, nicht aus einem perfekten Alert

Das erste nützliche Signal war eine Diskrepanz zwischen Rechnungs-Ereignissen und Gutschrifts-Ledger-Zeilen: Eine Quellrechnung entsprach mehr als einer Gutschrift. Der Stripe-Zustellungsverlauf zeigte dann wiederholte Zustellungen mit derselben Ereignis-ID. Schließlich enthüllte die Abonnement-Audit-Trail zwei Herabstufungsübergänge für dieselbe Rechnungsquelle.

Mein Monitoring konnte Handler-Fehler melden, aber nicht „eine externe Tatsache produzierte zwei interne Effekte". Deshalb schützte das HTTP-Fehler-Rate-Monitoring allein die Geschäftsinvariante nicht. Das dauerhafte Ledger machte später doppelte Versuche als normalen Zähler beobachtbar – anstatt als kunden sichtbare Überraschung.

Untersuchung: Identität über jeden Schreibvorgang verfolgen

Ich habe das Ereignis als Datenherkunftsproblem untersucht. Ausgehend von der Stripe-Ereignis-ID habe ich jede interne Zeile verfolgt, die es hätte erstellen sollen. Die Gutschriftstabelle konnte auf eine Rechnung verweisen, erforderte aber keine eindeutige Ereignisquelle. Die Herabstufungsaktualisierung hatte eine Lese-dann-Schreib-Form. Die Audit-Tabelle beschrieb Aktionen nachdem sie passiert waren, war aber nicht die Instanz, die sie erlaubt hatte.

Das两次 Replay eines redigierten Ereignisses reproduzierte beide Defekte. Sequentielles Replay duplizierte die Gutschrift, weil keine Eindeutigkeitsregel existierte. Concurrent Replay erweiterte das Timing-Fenster um die Herabstufungsprüfung. Dieselbe Geschäftstatsache wurde zweimal durch zwei unabhängig gültige Anfragen akzeptiert.

Root Cause, beitragende Faktoren und Nicht-Ursachen

Die Root Cause war das Fehlen eines datenbankerzwungenen Idempotenz-Schlüssels basierend auf Stripe event.id. Eine doppelte Zustellung konnte in die Geschäftslogik eintreten, ohne vorher einen exklusiven Anspruch zu erhalten.

Drei Faktoren trugen bei:

  • Gutschrifts-Mutationen führten keine eindeutige source_event_id;
  • die Herabstufungsbehandlung verwendete „Prüfen, dann Schreiben" anstelle einer atomaren Anweisung;
  • Tests deckten verschiedene Ereignisse, malformed Signaturen und Handler-Fehler ab, aber nicht wiederholte Zustellung desselben gültigen Ereignisses.

Stripe-Wiederholungen waren ein Auslöser, nicht die Ursache. D1 war ebenfalls nicht die Ursache; eine andere SQL-Datenbank würde dieselbe Race-Condition erlauben, wenn Schema und Transaktionsgrenze unverändert blieben. Keine Person wird beschuldigt. Die Design-Review konzentrierte sich auf Happy-Path-Korrektheit und übersah eine Distributed-Systems-Invariante.

Die dauerhafte Lösung: Anspruch, Mutation, Abschluss

Die Reparatur beginnt mit einem Webhook-Ereignis-Ledger. Dessen Primärschlüssel macht Identität dauerhaft. Geschäftstabellen behalten ebenfalls die Quell-ID bei, was dem System eine zweite Verteidigungslinie und einen Audit-Pfad gibt.

sql
-- Tabelle für empfangene Stripe-Ereignisse mit idempotentem Anspruch
CREATE TABLE stripe_webhook_events (
  event_id TEXT PRIMARY KEY,
  event_type TEXT NOT NULL,
  status TEXT NOT NULL CHECK (status IN ('processing', 'completed', 'failed')),
  attempts INTEGER NOT NULL DEFAULT 1,
  received_at INTEGER NOT NULL,
  processed_at INTEGER,
  last_error TEXT
);

-- Gutschrifts-Ledger mit eindeutiger Quell-Ereignis-Bindung
CREATE TABLE credit_ledger (
  id TEXT PRIMARY KEY,
  account_id TEXT NOT NULL,
  amount INTEGER NOT NULL,
  source_event_id TEXT NOT NULL UNIQUE,
  created_at INTEGER NOT NULL,
  FOREIGN KEY (source_event_id) REFERENCES stripe_webhook_events(event_id)
);

Der Handler verifiziert zuerst das raw Payload, dann versucht er einen atomaren Anspruch. Ein fehlgeschlagenes Ereignis kann erneut beansprucht werden; ein 'processing' oder 'completed' kann es nicht. Geschäftsschreibungen und der Abschlussmarker werden gemeinsam über einen D1-Batch commitet. Projektspezifische Mapping-Funktionen sind weggelassen, aber der Kontrollfluss ist vollständig.

typescript
export async function handleStripeWebhook(request: Request, env: Env) {
  // Signatur mit unberührtem Raw-Body verifizieren
  const rawBody = await request.text()
  const signature = request.headers.get('stripe-signature') ?? ''
  const event = await verifyStripeEvent(rawBody, signature, env.STRIPE_WEBHOOK_SECRET)
  const now = Date.now()

  // Atomarer Anspruch auf dieses Ereignis – duplikatsicher
  const claim = await env.DB.prepare(`
    INSERT INTO stripe_webhook_events
      (event_id, event_type, status, attempts, received_at)
    VALUES (?, ?, 'processing', 1, ?)
    ON CONFLICT(event_id) DO UPDATE SET
      status = 'processing', attempts = attempts + 1, last_error = NULL
    WHERE stripe_webhook_events.status = 'failed'
    RETURNING event_id
  `).bind(event.id, event.type, now).first<{ event_id: string }>()

  // Bereits verarbeitet oder abgeschlossen – 200 zurückgeben, keine Wiederholung
  if (!claim) return new Response('Already accepted', { status: 200 })

  try {
    // Idempotente Abrechnungsanweisungen atomar ausführen
    const statements = buildIdempotentBillingStatements(env.DB, event, now)
    statements.push(env.DB.prepare(`
      UPDATE stripe_webhook_events
      SET status = 'completed', processed_at = ?
      WHERE event_id = ? AND status = 'processing'
    `).bind(Date.now(), event.id))
    await env.DB.batch(statements)
    return new Response('Accepted', { status: 200 })
  } catch (error) {
    // Fehler markieren für sicheres Retry durch nächsten Delivery-Versuch
    await env.DB.prepare(`
      UPDATE stripe_webhook_events SET status = 'failed', last_error = ?
      WHERE event_id = ?
    `).bind(String(error), event.id).run()
    return new Response('Retry later', { status: 500 })
  }
}

Die UNIQUE-Einschränkung auf credit_ledger.source_event_id bleibt essenziell. Der Ereignis-Anspruch kontrolliert die Ausführung, während die Geschäftstabelleneinschränkung den geldähnlichen Effekt schützt, falls eine zukünftige Refaktorierung den Wrapper umgeht.

Validierung und Rollout

Ich habe die Daten repariert, bevor ich die normale Verarbeitung wieder aktiviert habe. Das Remediation-Skript gruppierte Gutschriftszeilen nach Quellereignis, identifizierte Extras, machte nur die doppelten Einträge rückgängig und überprüfte resultierende Kontostände gegen Rechnungen. Die zwei falschen Abonnementzustände wurden aus dem letzten gültigen Audit-Übergang wiederhergestellt. Ich habe jedes betroffene Konto geprüft, anstatt mich auf eine Bulk-Zeilenzählung zu verlassen.

Die Regressionstestsuite sendet nun dieselbe signierte Test-Fixtur wiederholt und gleichzeitig. Die Akzeptanzkriterien sind ergebnisbasiert: ein Ledger-Effekt, ein abschließender Abonnementübergang, ein abgeschlossener Ereignisdatensatz und erfolgreiche Bestätigungen für Duplikate. Ein separater Fehlertest erzwingt einen Fehlschlag des Geschäfts-Batchs, verifiziert status='failed' und bestätigt, dass ein späteres Retry den Anspruch beanspruchen und abschließen kann.

Monitoring, das zur Invariante passt

Ich überwache nun doppelte Versuche, Alter fehlgeschlagener Ereignisse und Reconciliation-Mismatches. Ein Duplikat-Zähler über null ist nicht automatisch ein Incident; er beweist, dass die Sperre funktioniert. Ein ungelöstes fehlgeschlagenes Ereignis oder zwei Geschäftseffekte, die sich eine Quell-ID teilen, sind jedoch handlungsrelevant.

Die Admin-Oberfläche verknüpft das Ereignis-Ledger auch mit der Abrechnungs-Audit-Trail. Diese operative Ansicht ist nützlicher als die Suche in unstrukturierten Logs während eines Zahlungs-Incidents. Leser, die die breitere Plattform evaluieren, können den Feature-Katalog einsehen und den Ansatz mit anderen Startern im Vergleichs-Hub vergleichen.

Grenzen und Fehlerarten, die noch bestehen

Idempotenz schließt einen gefährlichen Pfad, macht aber nicht das gesamte Abrechnungssystem unfehlbar. Ich habe vier Grenzen dokumentiert, damit der nächste Incident-Responder den Schutz nicht überschätzt.

Erstens garantiert Ereignis-Eindeutigkeit nicht die Ereignisreihenfolge. Eine Abonnementaktualisierung kann vor einem früheren Rechnungsereignis eintreffen, und verschiedene Stripe-Ereignistypen bilden keine Transaktion mit meiner Datenbank. Handler leiten daher Zustand aus autoritativen Objektfeldern ab, wenn möglich, und lehnen unmögliche Rückwärtsübergänge ab. Für risikoreiche Übergänge ruft der Recovery-Pfad den aktuellen Zustand von Stripe ab, anstatt aus der Ankunftsreihenfolge zu raten.

Zweitens kann ein Endpoint erfolgreich commiten und seine HTTP-Antwort verlieren. Stripe kann wiederholen, weil es die Bestätigung nie gesehen hat. Dies ist genau der Fall, den das abgeschlossene Ledger behandelt: Die nächste Zustellung findet dieselbe ID und gibt 200 zurück, ohne den Effekt zu wiederholen. Dieses Verhalten wird getestet, indem ein Antwortfehler nach dem Datenbank-Batch-Commit erzwungen wird.

Drittens benötigen permanente Mapping-Fehler einen Dead-Letter-Workflow. Ein endloses Wiederholen eines malformed internen Befehls erzeugt Rauschen ohne Fortschritt. Fehlgeschlagene Zeilen behalten eine redigierte Diagnose, Versuchszähler und Zeitstempel; ein Operator kann den Mapper reparieren und das ursprüngliche Provider-Ereignis über einen kontrollierten Befehl erneut abspielen. Raw Payment Payloads werden nicht in allgemeine Logs gesprüht, und die Aufbewahrung folgt der Datenschutzrichtlinie der Anwendung.

Viertens kann das Ereignis-Ledger nicht beweisen, dass der Provider und das lokale Konto übereinstimmen. Manuelle Stripe-Änderungen, alte Ereignisse von vor dem Ledger und Administratoraktionen können weiterhin Divergenz erzeugen. Ein Reconciliation-Job vergleicht aktive Abonnements, Rechnungsstatus und gutgeschriebene Berechtigungen und meldet dann Differenzen zur Überprüfung. Er überschreibt nicht blind Kundenzustände.

Diese Grenzen prägten den Rollout. Ich habe den Patch nicht als „exakt-once Webhooks" beworben. Ich habe ihn als duplikatsichere Verarbeitung mit recoverbaren Fehlern und Reconciliation beschrieben. Diese Sprache ist weniger dramatisch, aber sie sagt einem On-Call-Entwickler, welche Garantien tatsächlich verfügbar sind.

Eine praktische Review-Checkliste

Bevor ich einen weiteren Provider-Webhook genehmige, frage ich mich: Verwendet die Signaturverifikation den unveränderten Body? Wird eine stabile Provider-ID eindeutig gespeichert? Sind gleichzeitige Zustellungen sicher? Kann ein fehlgeschlagener Anspruch wiederholt werden? Tragen Geschäftseffekte auch Quellidentität? Und kann ein Operator Reconciliation durchführen, ohne Produktionszeilen von Hand zu bearbeiten? Wenn eine Antwort unklar ist, ist der Handler nicht fertig.

Lektionen, die ich behalten habe

Exakt-once ist ein Ergebnis, kein Zustellungsmodus

Webhook-Transport kann mindestens einmal zustellen. „Exakt einmal" muss an der Anwendungsgrenze mit stabiler Identität, atomaren Ansprüchen, Eindeutigkeitsregeln und sicheren Retries angenähert werden. Ein In-Memory-Set, Cache-Key oder Preflight-SELECT reicht nicht über gleichzeitige Workers hinweg.

Die Invariante in die Datenbank legen

Anwendungsschecks verbessern Fehlermeldungen; Datenbankeinschränkungen bewahren Wahrheit unter Race-Conditions. Geld, Gutschriften, Berechtigungen und Abonnementübergänge benötigen Quell-Identitäten und Eindeutigkeitsregeln. Audit-Logs beschreiben Geschichte, aber sie sollten nicht der einzige Mechanismus sein, der ungültige Geschichte verhindert.

Reconciliation gehört neben die Webhook-Behandlung

Selbst ein korrekter Receiver kann Mapping-Bugs oder manuelle Änderungen erleben. Ein geplanter Vergleich von Provider-Tatsachen gegen interne Effekte fängt eine andere Klasse von Fehlern. Idempotenz verhindert Wiederholung; Reconciliation erkennt Divergenz.

Ehrliche Postmortems begrenzen Behauptungen

Ich kann die Anzahl der betroffenen Konten, die zwei falschen Herabstufungen und das vierstündige Remediation-Fenster aus dem vorliegenden Incident-Datensatz verifizieren. Ich kann keine privaten Payloads veröffentlichen oder Kündigungsverhalten ableiten. Diese Grenzen zu benennen, ist Teil der Reparatur: Operatives Vertrauen hängt von präziser Sprache ebenso ab wie von präzisem Code.

Wie TanStack Ship die Wiederholung verhindert

Das aktuelle TanStack Ship Abrechnungsmuster behandelt die Stripe-Ereignisidentität als Schema-Level-Vertrag. Es kombiniert Signaturverifikation, Ereignis-Anspruch, idempotente Geschäftsschreibungen, eine Audit-Trail, Replay-Tests und Reconciliation-Sichtbarkeit. Die zugehörige Stripe-Integration ist auf Retries ausgelegt, anstatt sie als Ausnahme zu betrachten.

Wenn Sie bereits eine Webhook-Route haben, beginnen Sie mit einer Review-Frage: Kann dieselbe gültige event.id gleichzeitig zweimal eintreffen, ohne einen zweiten Geschäftseffekt zu erzeugen? Wenn die Antwort vom Timing abhängt, fügen Sie das Ledger und eindeutige Quell-Einschränkungen hinzu, bevor Sie weitere Ereignistypen ergänzen.

Erfahren Sie, wie TanStack Ships Stripe-Integration Idempotenz im Abrechnungsmodul handhabt, und überprüfen Sie dann die Preise, wenn Sie bereit sind, das vollständige System zu deployen.