Stripe
Stripe webhooks with Next.js
Verify Stripe webhook signatures in a Next.js route handler and update subscription state from the event.
Checkout can succeed and your database can still say free. The webhook is the request Stripe sends to your server. The Stripe Subscription Starter handles three event types in src/app/api/stripe/webhook/route.ts.
Verify before you trust the body
constructEvent checks the Stripe-Signature header against STRIPE_WEBHOOK_SECRET. Pass the raw body. If you parse JSON first, the signature will not match.
export async function POST(request: Request) {
const event = stripe().webhooks.constructEvent(
await request.text(),
request.headers.get("stripe-signature") || "",
process.env.STRIPE_WEBHOOK_SECRET || ""
);
// branch on event.type, then:
return NextResponse.json({ received: true });
}
The kit uses the App Router handler signature (Request in, NextResponse out), which is valid on Next.js 15. There is no export const config = { api: { bodyParser: false } } because that option belongs to the Pages Router.
A bad signature throws. That is what you want: do not answer 200 for a body you could not verify. Stripe retries non-2xx responses.
Events this kit handles
if (event.type === "checkout.session.completed") {
const session = event.data.object;
const userId = session.metadata?.userId;
if (userId) {
await prisma.user.update({
where: { id: userId },
data: {
plan: "pro",
stripeCustomerId: String(session.customer || ""),
stripeSubscriptionId: String(session.subscription || "")
}
});
}
}
if (event.type === "customer.subscription.updated" || event.type === "customer.subscription.deleted") {
const subscription = event.data.object;
const plan = subscription.status === "active" || subscription.status === "trialing" ? "pro" : "free";
await prisma.user.updateMany({
where: { stripeSubscriptionId: subscription.id },
data: { plan }
});
}
checkout.session.completed runs when the hosted page finishes. The subscription id on that session is what later updated and deleted events match. updateMany is used because the lookup is not the primary key. If no row has that subscription id, Prisma updates zero rows and the handler still returns 200.
past_due and canceled both fall through to free because they are neither active nor trialing. The kit does not implement a grace period.
Local delivery
Install the Stripe CLI and forward events to the route:
stripe listen --forward-to localhost:3000/api/stripe/webhook
The CLI prints a webhook signing secret. Put that value in STRIPE_WEBHOOK_SECRET for local work. The secret in the Dashboard is for the deployed endpoint. They are different strings.
Trigger a test after a real Checkout in test mode, or:
stripe trigger checkout.session.completed
A triggered event may not contain your metadata.userId. The handler no-ops in that case. A session you created in the app includes the metadata.
Idempotency
The handler writes the same plan if Stripe delivers the event twice. It does not store event.id. A second checkout.session.completed for the same user sets the same columns again. That is safe for this schema. If you later insert a row per invoice, record the event id first.
The rest of the state machine is in synchronizing Stripe subscription state with a database.
Want to skip the setup? The Stripe Subscription Starter already includes this webhook, Checkout, and the Customer Portal.