How to handle Stripe webhooks in Next.js (the right way)
Webhooks are the only reliable signal from Stripe. Learn how to verify signatures, handle subscription events with an upsert pattern, and trigger emails — all in a Next.js App Router route handler.
Stripe webhooks are how your application learns that a payment succeeded, a subscription renewed, or a customer canceled. If you get the implementation wrong — skipping signature verification, mishandling event ordering, or writing to the database from the wrong place — you end up with billing state that silently diverges from reality. This guide walks through the complete webhook handler pattern used in GetLaunchpad.
Why signature verification is not optional
Before you touch the event payload, you must verify that it actually came from Stripe. Stripe signs every webhook delivery with a Stripe-Signature header derived from the raw request body and your webhook endpoint secret. The stripe.webhooks.constructEvent method validates this signature and throws if it does not match.
Skipping this check exposes you to replay attacks and outright forgery. Any HTTP client that discovers your webhook URL can POST a crafted payload — say, a checkout.session.completed event with a fake customer ID — and your handler will dutifully write it to your database. Signature verification makes that impossible: the attacker does not have your webhook secret, so they cannot produce a valid signature.
One important detail: you must pass the raw request body to constructEvent, not the parsed JSON. Next.js App Router route handlers expose the raw body via request.text(). Reading it as JSON first corrupts the bytes that the signature was computed over.
// app/api/stripe/webhook/route.ts
import Stripe from "stripe";
import { NextRequest, NextResponse } from "next/server";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(request: NextRequest) {
const body = await request.text(); // raw bytes — do NOT use .json()
const sig = request.headers.get("stripe-signature") ?? "";
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
} catch (err) {
console.error("Webhook signature verification failed:", err);
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
// safe to process now
await handleEvent(event);
return NextResponse.json({ received: true });
}The three events that matter for subscriptions
A typical SaaS subscription lifecycle generates three key events. Your handler needs to deal with all three — and do so idempotently, because Stripe will retry deliveries on network errors and your handler may see the same event more than once.
checkout.session.completed
This fires when a customer finishes the Checkout flow. At this point you know the Stripe customer ID and the subscription ID. Write them to your database so subsequent events can look up the right row.
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
if (session.mode !== "subscription") break;
const customerId = session.customer as string;
const subscriptionId = session.subscription as string;
// Fetch the full subscription object to get status and period end
const sub = await stripe.subscriptions.retrieve(subscriptionId);
await adminClient.from("subscriptions").upsert({
stripe_customer_id: customerId,
stripe_subscription_id: subscriptionId,
status: sub.status,
plan: sub.items.data[0]?.price.lookup_key ?? "pro",
current_period_end: new Date(sub.current_period_end * 1000).toISOString(),
}, { onConflict: "stripe_customer_id" });
break;
}customer.subscription.updated
Stripe fires this on every subscription state change — renewals, plan upgrades, trial endings, payment failures that move the subscription to past_due. The handler is the same upsert as above, which means renewals are free: the new current_period_end overwrites the old one automatically.
case "customer.subscription.updated": {
const sub = event.data.object as Stripe.Subscription;
await adminClient.from("subscriptions").upsert({
stripe_customer_id: sub.customer as string,
stripe_subscription_id: sub.id,
status: sub.status,
plan: sub.items.data[0]?.price.lookup_key ?? "pro",
current_period_end: new Date(sub.current_period_end * 1000).toISOString(),
}, { onConflict: "stripe_customer_id" });
break;
}customer.subscription.deleted
This fires when a subscription is fully canceled (at period end or immediately). Mark the row as canceled rather than deleting it — you may want the history for analytics or to show a "reactivate" banner.
case "customer.subscription.deleted": {
const sub = event.data.object as Stripe.Subscription;
await adminClient.from("subscriptions").update({
status: "canceled",
}).eq("stripe_subscription_id", sub.id);
break;
}Why upsert instead of insert + update
Stripe's event delivery is eventually consistent and occasionally out-of-order. A customer.subscription.updated event can arrive before checkout.session.completed if your handler restarted mid-flight or Stripe retried an earlier delivery. Using upsert with onConflictmeans the handler is correct regardless of arrival order — whichever event lands first creates the row; subsequent events update it. A separate insert-then-update pattern requires you to handle the "row does not exist yet" race condition yourself.
Using the admin Supabase client
Webhook handlers run in server-side route handlers, not in a user session. There is no authenticated user principal, so the Row Level Security policies that protect your subscriptions table will block any write using the anon-key client. You need the admin (service role) client, which bypasses RLS entirely.
// lib/supabase/admin.ts
import "server-only";
import { createClient } from "@supabase/supabase-js";
export const adminClient = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY! // never expose this to the browser
);The server-only import at the top causes a build-time error if you accidentally import this client in a client component. That is intentional — the service role key would end up in your JavaScript bundle and bypass every RLS policy for every user.
Putting the full handler together
The complete handler wraps the switch inside the verified event, lets unhandled event types fall through silently, and always returns 200 for events it recognizes. Stripe interprets any non-2xx response as a delivery failure and will retry — you do not want it retrying events you already processed successfully.
async function handleEvent(event: Stripe.Event) {
switch (event.type) {
case "checkout.session.completed": { /* ... */ break; }
case "customer.subscription.updated": { /* ... */ break; }
case "customer.subscription.deleted": { /* ... */ break; }
default:
// ignore unhandled event types — do not throw
break;
}
}All of this — signature verification, the three-event upsert pattern, the admin Supabase client, and Resend email triggers on subscription activation and cancellation — is pre-wired in GetLaunchpad, a Next.js 16 SaaS boilerplate for $29/month. Clone the repo and your webhook handler is already production-ready on day one.