How do I handle Stripe and Lemon Squeezy webhooks idempotently in Next.js?
It depends on your billing providers, but the core architecture is identical: verify the raw payload signature before parsing JSON, normalize provider events into a shared internal contract, and enforce idempotency at the database boundary using the provider's event or order ID. Never grant product entitlements from a client-side checkout redirect; let the verified webhook transactionally update subscription state and entitlements once. supastack prewires this pipeline for Stripe, Lemon Squeezy, and mobile RevenueCat, though single-plan apps can start with a simpler handler.
Prüfe den rohen Payload vor dem JSON-Parsing
Jeder Zahlungsanbieter signiert exakt die Bytefolge, die er über die Leitung sendet. Wenn ein Route-Handler await request.json() parst und das Objekt vor der Signaturprüfung erneut serialisiert, machen Abweichungen bei Schlüsselreihenfolge oder Leerzeichen gültige Webhooks ungültig — oder verleiten Entwickler dazu, die Prüfung im Staging abzuschalten.
Lies den rohen Body einmal mit await request.text() in deinem Next.js-App-Router-POST-Handler, prüfe die kryptografische Signatur gegen das serverseitige Webhook-Secret und lehne fehlerhafte Anfragen mit HTTP 400 ab, bevor eine einzige Datenbankabfrage läuft. Der Billing-Modul-Leitfaden zeigt, wie supastack Signaturprüfungen hinter einem zentralen BFF-Einstiegspunkt isoliert.
export async function POST(request: Request) {
const rawBody = await request.text();
const signature = request.headers.get("stripe-signature");
if (!signature) return new Response("Missing signature", { status: 400 });
const event = stripe.webhooks.constructEvent(
rawBody,
signature,
env.STRIPE_WEBHOOK_SECRET,
);
await handleNormalizedBillingEvent(event);
return Response.json({ received: true });
}- Lies den rohen Request-Body mit
request.text()— serialisiere niemals geparstes JSON neu. - Halte
STRIPE_WEBHOOK_SECRETund Lemon-Squeezy-Signaturgeheimnisse in rein serverseitiger Konfiguration. - Nutze zeitkonstanten Vergleich (
crypto.timingSafeEqual), wenn du HMAC-Signaturen manuell prüfst.
Dedupliziere an der Datenbankgrenze, nicht im Arbeitsspeicher
Stripe und Lemon Squeezy wiederholen Webhooks, sobald ein Deployment neu startet, eine Serverless-Funktion in ein Timeout läuft oder ein Netzwerk-Hop die Antwort verliert. Prozesslokale Sets oder kurzlebige Cache-Keys verschwinden über Instanzen hinweg und verhindern nicht, dass zwei gleichzeitige Retries doppelte Lizenzen vergeben oder zwei Quittungs-E-Mails senden.
Speichere die Event-ID des Anbieters oder den Abrechnungsstatus der Bestellung in derselben Postgres-Transaktion, die den Abo-Zugriff ändert. Ein atomares INSERT ... ON CONFLICT DO NOTHING oder ein bedingtes Update auf status != 'paid' macht aus einer parallelen Wiederholung einen sauberen No-op. Beim Entwerfen der Tarife hilft der SaaS-Preisseiten-Generator, Stufen und Sitzplatzregeln vorab zu modellieren.
const settled = await db.transaction(async (tx) => {
const [inserted] = await tx
.insert(billingWebhookEvents)
.values({ providerEventId: event.id, provider: "stripe" })
.onConflictDoNothing({ target: billingWebhookEvents.providerEventId })
.returning({ id: billingWebhookEvents.id });
if (!inserted) return false; // Already processed
await applyWorkspaceEntitlement(tx, event);
return true;
});Behandle unsortierte Events und überlappende Abonnements
Webhooks kommen nicht garantiert in der Reihenfolge an, in der der Nutzer geklickt hat. customer.subscription.updated kann vor checkout.session.completed eintreffen, und ein Kunde mit zwei offenen Checkout-Tabs kann kurzzeitig zwei Abos im selben Workspace halten.
Binde Kündigungen und Zahlungsfehler an die konkrete Abo-ID, die das Event ausgelöst hat. Prüfe vor dem Entzug einer bezahlten Berechtigung, ob ein anderes aktives Abo denselben Workspace weiterhin abdeckt — sonst entzieht die Kündigung eines doppelten Abos stillschweigend den Zugriff, für den der Kunde weiter bezahlt.
- Schlüssele den Abo-Status nach
providerSubscriptionId, nicht nur nachworkspaceId. - Halte die bezahlte Berechtigung eines Workspaces aktiv, solange mindestens ein Abo
activeodertrialingist. - Prüfe vor der Erfüllung von Einmalbestellungen, dass gezahlter Betrag und Währung exakt zur erwarteten SKU passen.
Häufige Fragen zu SaaS-Billing-Webhooks
Diese Fragen tauchen auf, sobald ein Team die Abrechnung von Sandbox-Tests auf echten Produktionsverkehr umstellt.
- Kann ich bezahlte Funktionen direkt beim Checkout-Success-Redirect freischalten? — Nein. Redirect-URLs können gespeichert, geteilt oder aufgerufen werden, bevor asynchrone Zahlungsmethoden abgeschlossen sind. Nutze die Erfolgsseite zum Abfragen des Serverstatus, während der verifizierte Webhook den Zugriff vergibt.
- Welchen Statuscode sollte ein doppelter Webhook zurückgeben? — Gib
200 OKzurück, sobald deine Datenbank bestätigt, dass das Event bereits verarbeitet wurde. Ein4xxoder5xxweist den Anbieter an, weiter Wiederholungen zu senden. - Wie teste ich Webhook-Retries lokal? — Schreibe deterministische Unit-Tests, die exakt denselben signierten Payload zweimal senden, und prüfe, dass nur eine Lizenz, Berechtigung oder Transaktions-E-Mail entsteht.
- Wie passen mobile In-App-Käufe in dasselbe Modell? — Leite RevenueCat-Serverbenachrichtigungen durch dieselbe idempotente Berechtigungstabelle, damit Web- und Mobile-Käufe sich niemals gegenseitig überschreiben — siehe die Launch-Checkliste für die Prüfungen vor dem Release.