Zum Inhalt springen
tecminds

Stripe hat den Webhook erneut zugestellt — und wir hätten fast doppelt belastet: event.id claimen, bevor die Side-Effect läuft

Stripe stellt Webhooks at-least-once zu: dasselbe Event kann drei Tage lang erneut landen, wenn der Handler langsam ist, ein Non-2xx zurückgibt oder nach bereits gelaufener Arbeit timeoutet. Ein Checkout, der diesen Retry als frischen Befehl behandelt, belastet doppelt. Das ist die Muster-Notiz dafür, event.id in einer Processed-Events-Tabelle zu claimen, sie mit data.object.id plus event.type zu koppeln, und an jeden ausgehenden Stripe-Aufruf einen Idempotency-Key zu hängen — bevor die Geld-Side-Effect läuft.

TTobias LüscherCo‑Founder · TecMinds2026-08-27 · 11 Min Lesezeit

Stripe hat den Webhook erneut zugestellt — und wir hätten fast doppelt belastet: event.id claimen, bevor die Side-Effect läuft

Der teuerste Checkout-Handler ist der, der einen Stripe-Webhook als Befehl behandelt. Der Payload liest sich wie eine Bestellung: checkout.session.completed, eine Session-Id, ein Kunde, ein Betrag. Die naive Lesart ist "das ist angekommen, also soll ich jetzt die Geld-Aktion ausführen" — das bezahlte Kontingent freischalten, ein PaymentIntent capturen, eine Invoice erzeugen, ein zweites PaymentIntent.create feuern, weil Checkout irgendwie unvollständig wirkte. Der dokumentierte Vertrag von Stripe ist das Gegenteil eines Befehls. Webhook-Zustellung ist at-least-once. Dasselbe Event kann erneut auf deinem Endpoint landen, weil der erste Versuch timeoutet hat, ein 5xx zurückgab oder nach bereits gelaufener Arbeit nie ein 2xx empfangen hat. Ein Handler, der nicht idempotent ist, führt die Geld-Side-Effect zweimal aus. Das ist eine Muster-Notiz zur Stripe-Webhook-Idempotenzevent.id in einer Processed-Events-Tabelle claimen, bevor die Side-Effect läuft, sie mit data.object.id plus event.type koppeln, und an jeden ausgehenden Stripe-Aufruf einen Idempotency-Key hängen — geschrieben für die Sorte einmaliger Checkout, die Schweizer KMU-Produkte tatsächlich ausliefern.

Diese Form bauen wir bei tecminds. Acurio verkauft ein Thesis-Verifikations-Paket, kein Abo: Checkout zieht einmal ein, checkout.session.completed schaltet ein Lauf-Kontingent frei, und sonst sollte für diese Session nie wieder Geld bewegt werden. Dieselbe Falle taucht in jedem Next.js- oder FastAPI-Checkout auf, der aus dem Webhook erfüllt. Der Retry ist kein Vorfall und keine Kennzahl. Er ist der Zustellvertrag. Die Doppelbelastung ist, was passiert, wenn dein Handler ihn ignoriert.

Der Zustellvertrag, den Stripe tatsächlich veröffentlicht

Die Stripe-Webhook-Docs sind ungewöhnlich explizit, und drei Klauseln tun die ganze Arbeit.

Retries sind das Produkt, nicht die Ausnahme. Im Live-Modus versucht Stripe die Zustellung bis zu drei Tage lang mit exponentiellem Backoff. Sandbox-Zustellungen werden dreimal über ein paar Stunden wiederholt. Ein Timeout, ein 5xx, ein 4xx, den dein Framework ausgespuckt hat, weil CSRF das POST gefressen hat, ein 3xx-Redirect, den Stripe als Fehler behandelt — all das plant einen weiteren Versuch. Das Dashboard kann ein Event 15 Tage lang manuell erneut senden; die CLI 30 Tage. Ein manueller Erfolg bricht den automatischen Retry-Plan nicht ab. Wenn die einzige Erinnerung deines Handlers an "das habe ich schon erledigt" der HTTP-Request ist, der gerade fertig wurde, weiss die nächste Zustellung von nichts.

Der Retry ist dasselbe Event. Die id auf dem Event-Objekt — evt_… — ist der eindeutige Identifikator für dieses Event. Ein Retry einer Zustellung, die timeoutet hat, trägt immer noch diese Id. Signatur und Zeitstempel bleiben nicht gleich: Stripe erzeugt bei jedem Zustellversuch eine neue Stripe-Signature und einen neuen Zeitstempel, also behandelt ein Handler, der über den rohen Signatur-Header dedupliziert, einen Retry als neu. Deduplizieren über event.id ist die dokumentierte Schranke: logge die Ids, die du verarbeitet hast, und verarbeite bereits geloggte Events nicht erneut.

Ein 2xx nach der Arbeit ist die Timeout-Falle. Stripe verlangt, ein 2xx zurückzugeben, bevor irgendwelche komplexe Logik timeouten könnte — "bevor du die Invoice eines Kunden in deinem Buchhaltungssystem als bezahlt markierst" ist ihr eigenes Beispiel. Der Failure-Mode, der doppelt belastet, ist die Komposition zweier vernünftiger Zeilen. Zeile eins: die Geld-Arbeit im Request erledigen, damit du "weisst, dass sie gelandet ist." Zeile zwei: am Ende 200 zurückgeben. Wenn die Arbeit fertig ist und die Antwort langsam — ein Lock, ein DNS-Blip, ein Serverless-Cold-Start, ein Proxy, der auf dem Body sass — zeichnet Stripe ein Timeout auf und retried. Der Kunde ist bereits belastet. Der Retry sieht für einen Handler ohne Processed-Events-Tabelle aus wie eine Erstzustellung.

Reihenfolge ist ebenfalls kein Versprechen. Ein Abo anzulegen kann customer.subscription.created, invoice.created, invoice.paid und charge.created in beliebiger Ankunftsreihenfolge auslösen. Ein Handler, der auf invoice.paid und auf charge.created belastet, hat einen zweiten Writer erfunden, ohne es zu merken.

Zwei Sorten Duplikat, zwei Schlüssel

Stripe dokumentiert zwei verschiedene Duplikate, und sie in ein einziges SELECT zusammenzuziehen ist, wie das zweite durchrutscht.

Die erste Sorte ist eine erneute Zustellung desselben Events. Dieselbe event.id. Eine processed_stripe_events-Zeile, deren Primary Key diese Id ist, reicht. Insert beim Empfang; bei Unique-Violation 200 zurückgeben und stoppen. Das ist die ganze Retry-Geschichte für ein einzelnes Event-Objekt.

Die zweite Sorte sind zwei Event-Objekte für dieselbe logische Aktion. Stripe sagt, das passiert: zwei getrennte Event-Objekte werden erzeugt und gesendet, und der Weg, sie zu erkennen, ist die Id des Objekts in data.object zusammen mit event.type. evt_aaa und evt_bbb können beide checkout.session.completed für cs_… sein. Eine Tabelle, die nur auf event.id keyed, wird beide brav verarbeiten. Der zweite Unique-Constraint ist (object_id, event_type).

Nicht über-vereinheitlichen. checkout.session.completed und payment_intent.succeeded sind verschiedene Typen auf verschiedenen Objekten. Sie sind verwandt, keine Duplikate. Wenn beide Handler dieselbe Bestellung erfüllen, hast du zwei Writer, und der Fix ist, ein Event als Source of Truth für die Erfüllung zu wählen — gewöhnlich checkout.session.completed für Checkout, invoice.paid für Billing — nicht, unverwandte Typen auf eine Zeile zu unique-constrainen. Die URL-Token-Portal-Aufzeichnung hat den Check-then-Insert-Race, den diese Tabelle treffen wird, bereits benannt: zwei gleichzeitige Zustellungen können beide am SELECT vorbeikommen, bevor eines der INSERT läuft. Der Unique-Constraint ist der Lock. Den Integrity-Error zu fangen und denselben "bereits verarbeitet"-Pfad wie der Pre-Check zurückzugeben, hält die zweite Zustellung davon ab, ein 500 zu werden — und ein 500 ist ein weiterer Retry.

CREATE TABLE processed_stripe_events (
  event_id   TEXT PRIMARY KEY,
  event_type TEXT NOT NULL,
  object_id  TEXT NOT NULL,
  status     TEXT NOT NULL DEFAULT 'processing',
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  UNIQUE (object_id, event_type)
);

status trägt Last. Eine nackte Insert-als-erledigt-Zeile kann "wir haben das geclaimed und dann ist der Prozess gestorben" nicht von "wir sind fertig" unterscheiden. Diese Unterscheidung ist der nächste Abschnitt.

Claimen, dann handeln — und die Aktion einen Crash überleben lassen

Die Servietten-Version von Webhook-Idempotenz ist "insert event.id, überspring wenn vorhanden." Die Servietten-Version hat ein Loch in derselben Form wie die Timeout-Falle, nur auf der anderen Seite des Writes.

Act-then-claim ist die Doppelbelastung. Du capturst das PaymentIntent oder erzeugst die Invoice, dann insertest du event.id. Stirbt der Prozess — oder timeoutet Stripe den Request — nach dem Stripe-Aufruf und vor dem Insert, findet die nächste Zustellung keine Zeile und macht den Aufruf erneut. Zwei Belastungen, ein Event, ein sehr kurzer Ausfall.

Claim-then-act mit einer terminalen Zeile ist das stille Überspringen. Du insertest event.id zuerst, gibst bei Konflikt 200 zurück, dann machst du die Arbeit. Crashst du nach dem Insert und gibst trotzdem 200 zurück (oder der Insert ist committed und der Worker ist nie gelaufen), ist Stripe zufrieden und retried nicht. Der Kunde wurde nie belastet, das Kontingent nie freigeschaltet, und keine spätere Zustellung wird es merken. Du hast eine Doppelbelastung gegen eine fallen gelassene Erfüllung getauscht.

Das Muster, das beides überlebt, ist ein Zwei-Zustands-Claim. Insert (event_id, status='processing') in demselben Statement, das die Eindeutigkeit erzwingt. Verliert der Insert, schau auf die bestehende Zeile: processed heisst 200 zurückgeben und stoppen; processing älter als dein Handler-Budget heisst, der erste Versuch ist gestorben und diese Zustellung darf die Side-Effect zu Ende führen, weil die Side-Effect selbst idempotent ist. Nachdem die Side-Effect committed, kipp die Zeile auf processed. Der Insert ist der Lock. Der Status ist der Crash-Record. Die Side-Effect muss sicher zweimal laufen dürfen, damit der stale-processing-Zweig legal ist — weshalb der ausgehende Stripe-Aufruf einen eigenen Schlüssel braucht, nicht nur die Tabelle.

Das ist dieselbe Disziplin wie der Postgres-Backstop-Sweep: ein Writer auf dem Happy Path, ein Unique-Constraint plus SKIP LOCKED überall dort, wo zwei Container dieselbe Zeile claimen könnten, und eine explizite Antwort auf "was wenn der Prozess zwischen Claim und Arbeit stirbt." Ein Rolling-Coolify-Deploy, das alte und neue Webhook-Worker überlappt, sind zwei Container. Beide werden dasselbe Event sehen, wenn Stripe in die Überlappung retried. FOR UPDATE SKIP LOCKED auf dem Claim, oder ein Unique-Insert, der nur einmal gelingen kann, ist die Linie, die dieses Deploy davon abhält, eine zweite Belastung zu werden.

Für die Sorte Checkout, die Acurio ausliefert, ist die Erfüllung keine zweite Stripe-Belastung — Checkout hat bereits eingezogen. Die geldnahe Side-Effect ist "dieses Session-Paket als bezahlt markieren und das Kontingent freischalten." Dieser Write braucht denselben Claim. Zwei Zustellungen von checkout.session.completed, die beide UPDATE users SET credits = credits + 40 machen, verdoppeln das Kontingent; zwei Zustellungen, die beide PaymentIntent.create aufrufen, weil jemand einen "Zahlung bestätigen"-Helfer in den Webhook verdrahtet hat, verdoppeln die Belastung. Die Tabelle interessiert nicht, welche Side-Effect du angehängt hast. Sie interessiert, dass du das Event geclaimed hast, bevor eine von beiden gelaufen ist.

Der andere Schlüssel, den Leute mit event.id verwechseln

event.id hält dich davon ab, dasselbe eingehende Event zweimal zu verarbeiten. Es tut nichts für den Stripe-API-Aufruf, den dein Handler auf dem Weg nach draussen macht. Das sind verschiedene Objekte, verschiedene Retries, verschiedene Failure-Domains.

Die Stripe-API akzeptiert einen Idempotency-Key-Header. Auf API v1 spielen zwei POSTs mit demselben Schlüssel innerhalb von 24 Stunden das erste Ergebnis erneut ab, statt ein zweites Objekt zu erzeugen. Wenn dein Webhook-Handler ein PaymentIntent erzeugt, eines capturt, eine Invoice anlegt oder refundet, und du diesen Header nicht sendest, erzeugt ein Netzwerk-Retry aus deinem Prozess — nicht Stripes Webhook-Retry — ein zweites Objekt, selbst wenn event.id bereits geclaimed ist. Die eingehende Tabelle und der ausgehende Header decken entgegengesetzte Richtungen ab.

Leite den ausgehenden Schlüssel vom Geschäftsobjekt ab, nicht nur von event.id. Ein Schlüssel charge:${event.id} ist sicher für Retries dieses Events und unsicher für das zweite Event-Objekt, vor dem Stripe dich gewarnt hat. Ein Schlüssel charge:${checkout_session.id} oder charge:${your_order_id} legt beide Event-Objekte auf ein Stripe-seitiges Replay. Lege deine eigene Order-Id als metadata auf die Checkout-Session, damit der Handler sie aus data.object holen kann, ohne bei jeder Zustellung einen neuen Identifikator zu erfinden.

Drei kleinere Regeln fahren mit. Einen Idempotency-Key nicht wiederverwenden, nachdem du den Request-Body geändert hast — Stripe lehnt das Mismatch ab, statt still neue Parameter anzuwenden. Ein 500 eines erzeugenden Aufrufs nicht als "sicher, mit frischem Schlüssel zu retrien" behandeln; Stripe sagt, dieses Ergebnis als unbestimmt zu behandeln, weil der erste Versuch bereits Side-Effects gehabt haben kann. Und das Webhook-Signing-Secret und den API-Key nicht in denselben mentalen Eimer legen: eines verifiziert eingehende Authentizität, das andere autorisiert ausgehende Mutation. Signaturprüfung bleibt Pflicht — ohne sie kann ein Angreifer ein gefälschtes checkout.session.completed POSTen — aber ein verifizierter Retry eines echten Events ist kein Angriff. Es ist Stripe, das tut, was die Docs gesagt haben.

Drei Regeln für einen Handler, der zweimal laufen darf

Drei Regeln überleben diese Aufzeichnung und verallgemeinern sich auf jeden Webhook, der Geld bewegen oder eine bezahlte Berechtigung gewähren kann:

Gib 2xx zurück, bevor die Geld-Arbeit läuft, und persistiere das Event vor beidem. Die HTTP-Antwort ist, woran Stripe entscheidet, ob es die nächsten drei Tage retried. Die Processed-Events-Zeile ist, woran du entscheidest, ob diese Zustellung laufen darf. Die Belastung im Request zu machen und die Zeile danach zu inserten, ist die timeout-förmige Doppelbelastung. 200 auf einem Claim zurückzugeben, den du nie erfüllt hast, ist die crash-förmige fallen gelassene Bestellung. Insert processing, enqueue die Arbeit, return 200, lass einen Worker fertigmachen und processed markieren. Stripes eigene Best-Practice-Zeile hat dieselbe Form: Events asynchron behandeln.

Key eingehend auf event.id, und zusätzlich auf (data.object.id, event.type). Das erste fängt Stripes Retry desselben Events. Das zweite fängt zwei Event-Objekte für eine Aktion. Keines von beiden ersetzt, ein einzelnes Erfüllungs-Event zu wählen. Wenn checkout.session.completed das Paket freischaltet, ist payment_intent.succeeded Telemetrie, keine zweite Kasse.

Häng einen Idempotency-Key an jedes ausgehende Stripe-POST, abgeleitet von der Order, nicht von der Zustellung. Die Processed-Events-Tabelle kann dich nicht davor retten, dass dein eigener HTTP-Client PaymentIntent.create retried. Der Header kann das, 24 Stunden lang auf API v1, und nur wenn der Schlüssel die Geschäftsoperation benennt. event.id ist der eingehende Claim. Die Order-Id ist der ausgehende Claim. Du brauchst beide, und sie sind nicht derselbe String.

Die Komposition ist die ganze Notiz. Stripe wird retrien. Die Signatur wird neu aussehen. Die Event-Id nicht. Ein Handler, der glaubt "dieses POST ist diese Belastung", belastet doppelt, sobald eine Antwort zum ersten Mal langsam ist. Ein Handler, der event.id in processing claimed, einmal unter einem order-scoped Idempotency-Key erfüllt und 200 vor der langsamen Arbeit zurückgibt, überlebt das Drei-Tage-Retry-Fenster und den manuellen Resend-Button und das Deploy, das zwei Worker überlappt hat.

Wenn du Checkout für ein einmaliges Paket verdrahtest — oder bereits einen Webhook hast, der aus checkout.session.completed erfüllt und noch nie ein verarbeitetes event.id geloggt hat — buche einen kostenlosen AI-Potenzial-Check. Die Postgres-Backstop-Sweep-Aufzeichnung ist die Durability-Hälfte derselben "wer darf diese Zeile schreiben"-Frage; die URL-Token-Portal-Notiz ist die Check-then-Insert-Hälfte.

acurio · Halluzinierte Zitate? Nicht in deinem Manuskript.

Citation‑Checker für Zotero. Findet halluzinierte oder nur teilweise gestützte Quellen in KI‑geschriebenen Texten. Thesis‑Pakete ab CHF 19, Schweizer Datenverarbeitung.

NÄCHSTER SCHRITTHat dich das interessiert?