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.
Verifieer de ruwe payload voordat je JSON aanraakt
Elke betalingsprovider ondertekent de exacteeks bytes die over de lijn wordt gestuurd. Als een route-handler await request.json() parseert en het object opnieuw serialiseert voordat de handtekening wordt gecontroleerd, maken verschillen in sleutelvolgorde of witruimte legitieme webhooks ongeldig — of verleiden ze ontwikkelaars om verificatie in staging over te slaan.
Lees de ruwe body één keer uit met await request.text() in je Next.js App Router POST-handler, verifieer de cryptografische handtekening tegen het server-only webhook-geheim en wijs alles wat faalt af met HTTP 400 voordat je ook maar één databasequery uitvoert. De handleiding voor de facturatiemodule laat zien hoe supastack handtekeningcontroles isoleert achter één BFF-toegangspunt.
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 });
}- Lees de ruwe request-body met
request.text()— serialiseer geparseerde JSON nooit opnieuw. - Bewaar
STRIPE_WEBHOOK_SECRETen Lemon Squeezy-ondertekeningsgeheimen in server-only configuratie. - Gebruik vergelijking in constante tijd (
crypto.timingSafeEqual) wanneer je HMAC-handtekeningen handmatig verifieert.
Ontdubbel op de databasegrens, niet in het geheugen
Stripe en Lemon Squeezy proberen webhooks opnieuw zodra een deployment herstart, een serverless-functie een time-out krijgt of een netwerk-hop het antwoord verliest. Proces-lokale sets of kortlevende cachesleutels verdwijnen tussen instanties en houden niet tegen dat twee gelijktijdige retries dubbele licenties uitgeven of twee ontvangstbewijzen mailen.
Leg het event-ID van de provider of de afwikkelingsstatus van de bestelling vast binnen dezelfde Postgres-transactie die de abonnementstoegang wijzigt. Een atomaire INSERT ... ON CONFLICT DO NOTHING of een voorwaardelijke update op status != 'paid' maakt van een gelijktijdige herhaling een veilige no-op. Bij het ontwerpen van de plannen die deze webhooks afhandelen, helpt de SaaS-prijspaginagenerator om vooraf pakketten en stoelregels te modelleren.
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;
});Vang events buiten volgorde en overlappende abonnementen op
Webhooks komen niet gegarandeerd binnen in de volgorde waarin de gebruiker heeft geklikt. customer.subscription.updated kan vóór checkout.session.completed arriveren, en een klant die per ongeluk twee afrekentabbladen opent kan kortstondig twee abonnementen in dezelfde workspace hebben.
Koppel opzeggingen en mislukte betalingen aan het specifieke abonnements-ID dat het event veroorzaakte. Controleer vóór het intrekken van het betaalde recht van een workspace of een ander actief abonnement die workspace nog steeds dekt — anders verwijdert het opzeggen van een dubbel abonnement stilzwijgend de toegang waarvoor de klant nog steeds betaalt.
- Sleutel abonnementsstatus op
providerSubscriptionId, niet alleen opworkspaceId. - Houd het betaalde recht van een workspace actief zolang ten minste één abonnement
activeoftrialingis. - Controleer of het betaalde bedrag en de valuta overeenkomen met de verwachte SKU voordat je eenmalige bestellingen uitlevert.
Veelgestelde vragen over SaaS-facturatiewebhooks
Deze vragen komen naar boven zodra een team de facturatie van sandbox-tests naar productie-verkeer brengt.
- Kan ik betaalde functies ontgrendelen bij de succes-redirect van de checkout? — Nee. Redirect-URL's kunnen worden gebookmarkt, gedeeld of bezocht voordat asynchrone betaalmethoden zijn afgewikkeld. Gebruik de succespagina om de serverstatus te pollen terwijl de geverifieerde webhook toegang verleent.
- Welke statuscode moet een dubbele webhook teruggeven? — Geef
200 OKterug zodra je database bevestigt dat het event al is verwerkt. Een4xxof5xxteruggeven vertelt de provider om te blijven proberen. - Hoe test ik webhook-retries lokaal? — Schrijf deterministische unittests die exact dezelfde ondertekende payload twee keer versturen en controleer dat er maar één licentie, recht of transactionele e-mail wordt aangemaakt.
- Hoe passen mobiele in-app-aankopen in hetzelfde model? — Routeer RevenueCat-servermeldingen via dezelfde idempotente rechtentabel zodat web- en mobiele aankopen elkaar nooit overschrijven — bekijk de lenchecklist voor de controles vóór livegang.