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.
Verifica il payload grezzo prima di toccare il JSON
Ogni fornitore di pagamenti firma l'esatta sequenza di byte che invia sulla rete. Se un route handler analizza await request.json() e riassembla l'oggetto prima di controllare la firma, le differenze nell'ordine delle chiavi o negli spazi renderanno non validi i webhook legittimi — o spingeranno gli sviluppatori a saltare la verifica in staging.
Leggi il corpo grezzo una sola volta con await request.text() nel tuo handler POST dell'App Router di Next.js, verifica la firma crittografica rispetto al segreto riservato al server e rifiuta qualsiasi errore con HTTP 400 prima di eseguire una singola query sul database. La guida al modulo di fatturazione mostra come supastack isola i controlli delle firme dietro un unico punto di ingresso BFF.
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 });
}- Leggi il corpo grezzo della richiesta con
request.text()— non serializzare mai di nuovo un JSON già analizzato. - Conserva
STRIPE_WEBHOOK_SECRETe i segreti di firma di Lemon Squeezy esclusivamente nella configurazione server. - Usa il confronto a tempo costante (
crypto.timingSafeEqual) quando verifichi manualmente le firme HMAC.
Deduplica al confine del database, non in memoria
Stripe e Lemon Squeezy ritentano l'invio dei webhook ogni volta che un deploy si riavvia, una funzione serverless va in timeout o un salto di rete perde la risposta. I set locali al processo o le chiavi di cache a breve durata svaniscono tra le istanze e non impediranno a due tentativi concorrenti di concedere licenze duplicate o inviare due email di ricevuta.
Registra l'ID evento del fornitore o lo stato di saldo dell'ordine all'interno della stessa transazione Postgres che modifica l'accesso all'abbonamento. Un INSERT ... ON CONFLICT DO NOTHING atomico o un aggiornamento condizionale su status != 'paid' trasforma una ripetizione concorrente in una semplice operazione senza effetto. Quando progetti i piani attivati da quei webhook, il generatore di pagine prezzi SaaS aiuta a modellare in anticipo livelli e regole per postazione.
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;
});Gestisci eventi fuori ordine e abbonamenti sovrapposti
Non è garantito che i webhook arrivino nell'ordine in cui l'utente ha fatto clic. customer.subscription.updated può arrivare prima di checkout.session.completed e un cliente che apre per errore due schede di checkout può detenere brevemente due abbonamenti nello stesso workspace.
Associa disdette ed errori di pagamento allo specifico ID di abbonamento che ha generato l'evento. Prima di revocare il diritto a pagamento di un workspace, verifica se un altro abbonamento attivo copre ancora quel workspace — altrimenti annullare un abbonamento duplicato rimuove silenziosamente l'accesso per cui il cliente sta ancora pagando.
- Indicizza lo stato dell'abbonamento per
providerSubscriptionId, non solo perworkspaceId. - Mantieni attivo il diritto a pagamento di un workspace finché almeno un abbonamento è
activeotrialing. - Verifica che l'importo pagato e la valuta corrispondano allo SKU previsto prima di evadere ordini una tantum.
Domande frequenti sui webhook di fatturazione SaaS
Queste domande emergono ogni volta che un team porta la fatturazione dai test in sandbox al traffico di produzione.
- Posso sbloccare le funzionalità a pagamento sul redirect di successo del checkout? — No. Gli URL di redirect possono essere salvati nei preferiti, condivisi o visitati prima che i metodi di pagamento asincroni vengano saldati. Usa la pagina di successo per interrogare lo stato del server mentre il webhook verificato concede l'accesso.
- Quale codice di stato deve restituire un webhook duplicato? — Restituisci
200 OKnon appena il database conferma che l'evento è già stato elaborato. Restituire4xxo5xxdice al fornitore di continuare a riprovare. - Come testo i tentativi ripetuti dei webhook in locale? — Scrivi test unitari deterministici che inviano due volte lo stesso identico payload firmato e verifica che venga prodotta una sola licenza, un solo diritto o una sola email transazionale.
- Come si inseriscono gli acquisti in-app mobili nello stesso modello? — Instrada le notifiche server di RevenueCat attraverso la stessa tabella idempotente dei diritti in modo che gli acquisti web e mobili non si sovrascrivano mai — consulta la checklist di lancio per i controlli pre-rilascio.