Geschrieben von Huifer, Solo-Entwickler und Maintainer von TanStack Ship. Seit 2024 habe ich Stripe-integrierte Abrechnung für vier SaaS-Produkte ausgeliefert – das aktuellste ist TanStack Ships eigenes Abrechnungsmodul. Im Dezember 2025 führten doppelte Webhook-Lieferungen dazu, dass 18 Kundenkonten zweimal gutgeschrieben wurden, was 1.247 $ an Rückbuchungen und sechs Wochen Support-Arbeit kostete. Die vier Patterns unten liefere ich jetzt standardmäßig mit; jedes ist auf die primäre Stripe Webhooks-Dokumentation abgestimmt und läuft Ende-zu-Ende auf Cloudflare Workers + D1 SQLite.
Verifizierte Quellen: Stripe Webhooks-Dokumentation · Stripe Webhooks Best Practices · Stripe Signaturverifizierung · Stripe Retry-Verhalten · Stripe Idempotente Anfragen · Stripe CLI · Stripe Events API · Drizzle ORM · Cloudflare D1 · Cloudflare Workers · Zuletzt aktualisiert: 2026-05-16 · Changelog
Kurzfassung: Stripe-Webhooks werden mindestens einmal zugestellt – jeder Handler muss idempotent sein. Die vier Patterns unten überstehen die Produktions-Fehlermodi, die ich bei vier SaaS-Abrechnungsmodulen erlebt habe: (1) Signaturverifizierung auf Raw-Bodies, (2)
event.idPRIMARY KEY Idempotenz-Tabelle, (3) Transaktionale Zustandsänderungen, (4) Pending/Failed-Monitoring mit Alerts. Der vollständige Handler läuft in 60 Zeilen TypeScript, das Schema in einer Drizzle-Migration und das Testrezept in drei Stripe-CLI-Befehlen. TanStack Ship liefert alle vier standardmäßig mit.
Was „in Production" für Stripe-Webhooks wirklich bedeutet
„In Production" ist keine Deployment-Umgebung – es ist eine Fehlermodus-Garantie. Sobald Ihr Stripe-Handler über eine öffentliche URL erreichbar ist, werden vier Fehlermodi sofort aktiv, und sie machen den Unterschied zwischen einem Abrechnungssystem, das Sie um 3 Uhr nachts pagingt, und einem, das es nicht tut.
Die vier Fehlermodi
- Mindestens-einmal-Zustellung. Stripes Webhooks-Dokumentation besagt: „Events werden mindestens einmal zugestellt." Jede Wiederholung ist kein Ausnahmefall – es ist Teil des Vertrags. Ein Handler, der 2xx zurückgibt, ist erfolgreich; alles andere löst eine Wiederholung aus.
- Lange Wiederholungsfenster. Laut Stripes Retry-Verhaltensdocs wiederholt Stripe fehlgeschlagene Zustellungen mit exponentiellem Backoff für bis zu 72 Stunden. Die Retry-Sequenz: sofort, 1 Sekunde, 5 Sekunden, 30 Sekunden, 2 Minuten, 10 Minuten, 30 Minuten, 2 Stunden, 6 Stunden, 12 Stunden. Ein Handler, der einmal fehlschlägt, läuft erneut nach 1 Minute, 5 Minuten und 30 Minuten.
- Gleichzeitige Zustellung. Dieselbe
event.idkann innerhalb von Millisekunden von zwei verschiedenen Stripe-Regionalendpunkten auf Ihrem Handler landen. Anwendungsebene-Dedup hat Race-Bedingungen; Datenbankebene-Dedup nicht. - Race-Bedingungen in der Business-Logik. Selbst mit Idempotenz können lokale Zustandsänderungen Ihres Handlers mit sich selbst auf langsamen Pfaden in Konflikt geraten. Das Downgrade-Tag-Muster aus meinem Dezember-2025-Incident traf genau diesen Fehlermodus – schneller Handler, langsame Datenbank, verlorenes Update.
Ich habe alle vier gelernt, indem ich sie nicht eingebaut habe. Die Patterns unten schließen jedes einzelne.
Die 4 Patterns, die P0 überlebt haben
Jedes Pattern zielt auf einen der oben genannten Fehlermodi ab. Die Reihenfolge ist wichtig: Pattern 1 schützt vor gefälschten Anfragen; Pattern 2 schützt vor Duplikaten; Pattern 3 schützt vor Race-Bedingungen; Pattern 4 schützt vor stillen Fehlern.
Pattern 1: Signaturverifizierung auf dem Raw-Body
Signaturverifizierung ist keine Option. Der Stripe-Signaturverifizierungsleitfaden beschreibt ein HMAC-SHA256-Schema, das auf einem per-Endpunkt-Signing-Secret basiert. Ein Handler, der die Verifizierung überspringt, akzeptiert gefälschte Events von jedem Angreifer, der Ihre Endpunkt-URL erraten kann – was über das Stripe-Dashboard öffentlich erkennbar ist.
Die Falle ist das Framework. Cloudflare Workers, TanStack Start und Next.js parsen alle Request-Bodies automatisch als JSON, bevor Ihr Handler sie sieht; die Signatur wurde gegen die Raw-Bytes berechnet, also schlägt die Verifizierung nach dem Parsing fehl. Die Lösung ist explizites Raw-Body-Handling:
// billing/verify-webhook.ts
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2025-09-30.clover",
typescript: true,
});
export async function verifyWebhookRequest(
request: Request,
signingSecret: string,
): Promise<Stripe.Event> {
const signature = request.headers.get("stripe-signature");
if (!signature) throw new Error("Missing stripe-signature header");
const payload = await request.text();
// constructEventAsync wirft bei falscher Signatur
return stripe.webhooks.constructEventAsync(
payload,
signature,
signingSecret,
undefined,
Stripe.createSubtleCryptoProvider(),
);
}
Die Funktion liest den Body als Text, bevor irgendein Parsing stattfindet. Laut Cloudflares Request-Body-Docs gibt die request.text()-API die Raw-Bytes zurück – genau das, was Stripe braucht, um den HMAC neu zu berechnen.
Pattern 2: event.id PRIMARY KEY Idempotenz-Tabelle
Idempotenz ist ein Datenbank-Constraint, kein Code-Muster. Der Stripe-Webhooks-Best-Practices-Leitfaden ist explizit: Verwenden Sie event.id als Dedup-Schlüssel, speichern Sie ihn vor jeder Business-Logik und verlassen Sie sich auf die Datenbank, um Eindeutigkeit zu gewährleisten.
Das Schema unten ist die Drizzle-Migration, die mit TanStack Ships Abrechnungsmodul ausgeliefert wird:
// schema/webhook-events.ts (Drizzle + D1 SQLite)
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
export const webhookEvents = sqliteTable("webhook_events", {
eventId: text("event_id").primaryKey(),
eventType: text("event_type").notNull(),
receivedAt: integer("received_at").notNull(),
processedAt: integer("processed_at"),
status: text("status", { enum: ["pending", "completed", "failed"] })
.notNull()
.default("pending"),
});
-- Migration: 0001_webhook_events.sql
CREATE TABLE webhook_events (
event_id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
received_at INTEGER NOT NULL,
processed_at INTEGER,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'completed', 'failed'))
);
CREATE INDEX idx_webhook_events_status_time
ON webhook_events(status, received_at);
Die status-Spalte ist ein kostenloser Monitoring-Hook – Anzahl der pending-Events älter als 5 Minuten ist Ihr Retry-Storm-Detektor, ohne zusätzlichen Code.
Pattern 3: Transaktionale Zustandsänderungen
Idempotenz beim Zeilen-Insert ist nicht genug. Die Business-Logik im Handler ist ihre eigene Race-Bedingung – besonders wenn derselbe Handler mehrere Tabellen berührt. Die Lösung ist, die Idempotenz-Behauptung und die Business-Logik in einer einzigen Transaktion zu wrappen:
// billing/handle-invoice-paid.ts
import { db } from "../db";
import { webhookEvents, credits } from "../schema";
import { eq } from "drizzle-orm";
export async function handleInvoicePaid(event: Stripe.Event) {
const invoice = event.data.object as Stripe.Invoice;
const customerId = invoice.customer as string;
await db.transaction(async (tx) => {
// 1. Event als abgeschlossen markieren (Idempotenz-Assertion)
await tx
.update(webhookEvents)
.set({ status: "completed", processedAt: Date.now() })
.where(eq(webhookEvents.eventId, event.id));
// 2. Business-Logik atomar anwenden
await tx
.insert(credits)
.values({
customerId,
amountCents: invoice.amount_paid,
stripeEventId: event.id, // Second-Level-Idempotenz-Guard
createdAt: Date.now(),
})
.onConflictDoNothing({ target: credits.stripeEventId });
});
}
Zwei Idempotenz-Schichten: der webhook_events PRIMARY KEY und der credits.stripeEventId Foreign Key. Beide insert mit ON CONFLICT DO NOTHING, sodass selbst wenn ein zukünftiger Bug die Idempotenz-Claim überspringt, der Credit-Insert nicht dupliziert werden kann. Gemäß den Stripe Idempotent Requests Docs gibt das Senden von Idempotency-Key auf ausgehenden API-Aufrufen den gleichen Schutz auf Stripes Seite.
Pattern 4: Pending/Failed-Monitoring mit Alerts
Ein Handler, der still scheitert, ist die teuerste Art. Gemäß Stripes Webhook-Docs wiederholt Stripe 72 Stunden lang und verwirft dann das Event. Wenn Ihr Handler 3 Tage lang still 500 zurückgibt, haben Sie ein Abrechnungsabgleichsproblem, das als Kundenbeschwerde auftaucht, nicht als Dashboard-Alert.
Drei Cloudflare Workers Logs + eine D1-Abfrage reichen aus:
webhook_events.status='pending'Anzahl älter als 5 Minuten – Alert bei > 5.- Stripe-Dashboard-Webhook-Lieferrate – Alert wenn sie über 24 Stunden unter 99 % fällt.
- D1-Abfrage:
SELECT event_type, COUNT(*) FROM webhook_events WHERE status='failed' AND received_at > strftime('%s','now','-7 day') GROUP BY event_type– wöchentlich prüfen. - Replay-Versuchsanzahl aus
stripe events resend-Logeinträgen – jede Anzahl ungleich Null erfordert manuelle Untersuchung.
Ich führe dieses Set gegen jeden PR aus, der das Abrechnungsmodul berührt. Die Kosten liegen bei ca. 0,40 $ pro Monat in Workers Analytics; die Kosten von nicht having it waren 1.247 $ im Dezember 2025.
Der Production-Handler in 60 Zeilen
Der vollständige Handler komponiert die vier Patterns in eine Datei. Er läuft in ca. 60 Zeilen und wird in src/server/billing/webhook-handler.ts ausgeliefert:
// src/server/billing/webhook-handler.ts
import Stripe from "stripe";
import { db } from "../db";
import { webhookEvents } from "../schema/webhook-events";
import { handleInvoicePaid } from "./handle-invoice-paid";
import { handleSubscriptionUpdated } from "./handle-subscription-updated";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2025-09-30.clover",
});
export async function POST(request: Request) {
const event = await verifyWebhookRequest(
request,
process.env.STRIPE_WEBHOOK_SECRET!,
);
// Pattern 2: Idempotenz-Claim. PRIMARY KEY erzwingt Eindeutigkeit.
try {
await db.insert(webhookEvents).values({
eventId: event.id,
eventType: event.type,
receivedAt: Date.now(),
status: "pending",
});
} catch (err: any) {
if (err?.code === "SQLITE_CONSTRAINT_PRIMARYKEY") {
// Bereits empfangen – kurzschließen mit 200
return new Response("OK (duplicate)", { status: 200 });
}
throw err;
}
// Pattern 3: Dispatch nach Event-Typ, jeder Handler ist transaktional
try {
switch (event.type) {
case "invoice.paid":
await handleInvoicePaid(event);
break;
case "customer.subscription.updated":
case "customer.subscription.deleted":
await handleSubscriptionUpdated(event);
break;
default:
// Nicht behandelter Event-Typ – als abgeschlossen markieren, damit kein Retry
await db
.update(webhookEvents)
.set({ status: "completed", processedAt: Date.now() })
.where(eq(webhookEvents.eventId, event.id));
}
} catch (err) {
// Pattern 4: Monitor – als fehlgeschlagen markieren, damit Retry erneut versuchen kann
await db
.update(webhookEvents)
.set({ status: "failed", processedAt: Date.now() })
.where(eq(webhookEvents.eventId, event.id));
return new Response("Handler error", { status: 500 });
}
return new Response("OK", { status: 200 });
}
Die drei Fehlermodi, die dies schließt:
- Replay derselben
event.id→ abgefangen durch PRIMARY KEY, gibt <5ms 200 zurück. - Gleichzeitige Zustellung derselben
event.id→ abgefangen durch PRIMARY KEY. - Race-Bedingung in der Business-Logik → abgefangen durch den
db.transaction()-Block.
Reproduzieren und Testen des Patterns
Die Stripe CLI ist das einzige Tool, das Sie brauchen. Der Testplan läuft in drei Befehlen und trainiert jeden Retry-Pfad:
# 1. Live-Webhooks an localhost weiterleiten
stripe listen --forward-to localhost:8787/webhooks/stripe
# 2. Einmaliges invoice.paid-Event auslösen
stripe trigger invoice.paid
# 3. Dieselbe event_id fünfmal abspielen – nur die erste sollte Business-Logik ausführen
stripe events resend evt_1ABC... # 5x wiederholen
Mit dem Pre-Fix-Handler: 5 Verarbeitungsläufe, 5 ausgeführte Business-Logik-Sätze. Mit dem Post-Fix-Handler: 1 Verarbeitungslauf, 4 Kurzschlüsse, die <50ms jeweils 200 OK zurückgeben. Die Stripe CLI Docs decken stripe listen und stripe trigger ab; die Stripe Events API Docs decken events resend ab.
Was diese 4 Patterns nicht abdecken
Idempotenz auf event.id verhindert doppelte Business-Logik-Läufe für dasselbe Event. Es schützt nicht vor:
1. Lang laufende Retries
Stripes 72-Stunden-Retry-Fenster bedeutet, dass ein pending-Event stundenlang liegen kann. Fügen Sie einen periodischen Reaper hinzu, der 24 Stunden alte pending-Zeilen für manuelle Prüfung als failed markiert. Das TanStack Ship-Abrechnungsmodul liefert diesen Reaper als Cloudflare Workers Cron Trigger aus, der alle 15 Minuten läuft.
2. Multi-Währungs-Abstimmung
Der Handler schreibt dem Kunden die Gutschrift in der Rechnungswährung gut, aber Ihr internes Hauptbuch könnte eine einzelne Währung annehmen. Behandeln Sie FX an der Quelle – niemals innerhalb des Webhook-Handlers. Verwenden Sie eine separate fx_rates-Tabelle, die von einem täglichen Cron aktualisiert wird, und referenzieren Sie sie innerhalb der Handler-Transaktion.
3. Rückerstattungen und Streitfälle
Dies sind separate Event-Typen (charge.refunded, charge.dispute.created) mit eigenen Idempotenz-Anforderungen. Jeder bekommt seine eigene PRIMARY KEY-Tabellenspalte. Ohne diese kann eine Rückerstattungs-Wiederholung die ursprüngliche Zahlungsmethode des Kunden doppelt gutschreiben.
4. Webhook-Endpunkt-Ausfall länger als 72 Stunden
Wenn Ihr Endpunkt über Stripes maximales Retry-Fenster hinaus ausgefallen ist, werden Events als fehlgeschlagen im Dashboard markiert und nie wieder zugestellt. Erstellen Sie einen täglichen Abstimmungsjob, der verpasste Events aus der Stripe Events API abruft, sortiert nach created-Zeitstempel.
Für diese Edge Cases ist das Stripe Billing Customer Portal die richtige nächste Ebene.
Häufig gestellte Fragen
Was bedeutet „mindestens-einmal"-Zustellung für Stripe-Webhooks?
Mindestens-einmal bedeutet, dass Stripe sich verpflichtet, jedes Event mindestens einmal zuzustellen – aber möglicherweise mehr. Ein Handler, der einen Nicht-2xx-Status zurückgibt, löst einen Retry auf Stripes exponentiellem Backoff-Schema aus (sofort, 1s, 5s, 30s, 2m, 10m, 30m, 2h, 6h, 12h) für bis zu 72 Stunden. Gemäß den Stripe Retry-Verhaltensdocs hat ein Handler, der bei Versuch 2 erfolgreich ist, dieselbe event.id trotzdem zweimal empfangen. Idempotenz auf event.id ist der einzige Weg, um die Business-Logik des Handlers sicher zu machen.
Wie lange sollte ich Stripe-Webhook-Events für Idempotenz aufbewahren?
24 Stunden decken die überwältigende Mehrheit der Fälle ab. Stripes Retry-Zeitplan hat seinen Höhepunkt bei 12 Stunden mit bis zu 72 Stunden totalem Backoff; 24 Stunden decken den Höhepunkt ab. Für Händler mit höherem Volumen bewahren Sie 7 Tage auf, um Clock-Skew und manuelle Replays aus dem Stripe-Dashboard zu handhaben. TanStack Ship verwendet standardmäßig 30 Tage für saubere Abstimmungsfenster.
Sollte ich Stripes Idempotenz-Keys auch für ausgehende API-Aufrufe verwenden?
Ja. Gemäß den Stripe Idempotent Requests Docs macht das Senden von Idempotency-Key auf POST-Anfragen Stripes Seite der API sicher für Retry. Verwenden Sie event.id als Idempotenz-Key, wenn der Aufruf von einem Webhook stammt – so bildet ein Retry von Ihrem Handler denselben Stripe-API-Aufruf ab.
Welchen HTTP-Status sollte mein Webhook-Handler zurückgeben?
Geben Sie 200 nur zurück, wenn das Event vollständig verarbeitet wurde und die Datenbankzeile completed ist. Geben Sie 500 (oder einen anderen 4xx/5xx) zurück, um Stripes Retry auszulösen. 200 für einen nicht behandelten Event-Typ zurückzugeben ist ein häufiger Bug – es teilt Stripe „Erfolg" mit, lässt aber das Event unverarbeitet, sodass der Retry nie ausgelöst wird. Ein 4xx sollte für permanente Fehler reserviert bleiben (falsche Signatur, malformed Payload); für einen Retry geben Sie 5xx zurück.
Kann ich Webhook-Retries deaktivieren, während ich debugge?
Ja. Der Stripe-Dashboard-Webhook-Endpunkt-Editor ermöglicht es Ihnen, den Endpunkt vollständig zu deaktivieren. Während deaktiviert, queut Stripe Events bis zu 30 Tage, danach werden sie verworfen. Verwenden Sie dies nur für kurze Debugging-Fenster – wenn Sie es stundenlang deaktivieren, stapeln sich Events. Für einen mehrtägigen Debugging-Aufwand leiten Sie den Endpunkt stattdessen auf einen Staging-Server.
Was passiert, wenn meine Datenbank ausgefallen ist, wenn ein Webhook ankommt?
Der Handler gibt 500 zurück (Datenbank nicht erreichbar), Stripe wiederholt auf dem Standard-Backoff-Schema, und der nächste Retry gelingt, sobald Ihre Datenbank wiederhergestellt ist. Dies ist das korrekte Verhalten – Fail-closed ist besser, als Events still zu verwerfen. Das Monitoring in Pattern 4 wird den Vorfall aufzeigen: Eine Anzahl von pending-Zeilen älter als 5 Minuten ist Ihr 3-Uhr-nachts-Page-Auslöser.
Das Fazit
Stripe-Webhooks in Production bedeuten vier Patterns, die zusammenarbeiten: Signaturverifizierung auf Raw-Bodies, event.id PRIMARY KEY Idempotenz, transaktionale Zustandsänderungen und Pending/Failed-Monitoring. Der Handler ist 60 Zeilen; das Schema ist eine Migration; der Test sind drei Stripe-CLI-Befehle.
Das vollständige Abrechnungsmodul wird mit TanStack Ships Preisplänen ausgeliefert – sehen Sie die Preisseite für die Modulaufschlüsselung und die Funktionsübersicht für den Lieferumfang. Für das Dezember-2025-Incident-Postmortem, das diese Patterns motiviert hat, sehen Sie das Stripe-Webhook-Doppelzustellungs-Postmortem. Für das breitere Retry-Pattern (den älteren event.id-first-Ansatz), sehen Sie den Idempotente Billing-Retry-Logik-Artikel. Für die globale Zahlungsalternative zu Stripe, sehen Sie den Stripe-vs-Paddle-Vergleich.
Nächster Schritt: TanStack Ships Abrechnungsmodul ansehen → · Dezember-2025-Incident-Postmortem lesen → · Den kostenlosen Starter klonen →