Stripe Webhooks in Next.js: The Complete Guide to Getting It Right
Stripe webhooks are where most SaaS payment integrations quietly fall apart. The checkout works. The user pays. But three days later you realize that cancelled subscriptions aren't being revoked, failed payments aren't being handled, and your entitlement sync is running on vibes.
This guide covers Stripe webhooks in Next.js properly — not just the happy path, but the full subscription lifecycle, how to sync entitlements reliably, and how to test everything locally before you ship.
Why Webhooks Are Hard to Get Right
The common mistake is treating webhooks as an afterthought. You wire up a checkout, test the happy path, and ship. Then:
- A user cancels their subscription but still has access
- A payment fails and the user gets silently downgraded with no email
- Your database and Stripe fall out of sync and you can't tell who's actually paying
Webhooks are Stripe's way of telling your server what happened. If you're not handling them correctly, you're flying blind.
The Events That Actually Matter
You don't need to handle every Stripe event — just the ones that affect your users' access:
| Event | What it means | What you should do |
|---|---|---|
| checkout.session.completed | User completed checkout | Create subscription in DB, grant access |
| customer.subscription.updated | Plan changed, trial ended | Update entitlements in DB |
| customer.subscription.deleted | Subscription cancelled | Revoke access |
| invoice.payment_succeeded | Recurring payment worked | Extend subscription period |
| invoice.payment_failed | Payment failed | Send email, flag account |
Step 1: Create the Webhook Route
In Next.js App Router, create app/api/webhooks/stripe/route.ts:
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { prisma } from "@/lib/prisma";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-12-18",
});
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = req.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err) {
console.error("Webhook signature verification failed:", err);
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
try {
switch (event.type) {
case "checkout.session.completed":
await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
break;
case "customer.subscription.updated":
await handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
break;
case "customer.subscription.deleted":
await handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
break;
case "invoice.payment_succeeded":
await handlePaymentSucceeded(event.data.object as Stripe.Invoice);
break;
case "invoice.payment_failed":
await handlePaymentFailed(event.data.object as Stripe.Invoice);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
return NextResponse.json({ received: true });
} catch (err) {
console.error("Webhook handler error:", err);
return NextResponse.json({ error: "Webhook handler failed" }, { status: 500 });
}
}
Critical: Use req.text() not req.json(). Stripe verifies the raw request body. If you parse it as JSON first, the signature verification will fail every time.
Step 2: Handle Each Event
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
const customerId = session.customer as string;
const subscriptionId = session.subscription as string;
const userId = session.metadata?.userId;
if (!userId) {
console.error("No userId in checkout session metadata");
return;
}
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const priceId = subscription.items.data[0].price.id;
await prisma.user.update({
where: { id: userId },
data: {
stripeCustomerId: customerId,
stripeSubscriptionId: subscriptionId,
stripePriceId: priceId,
stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
plan: getPlanFromPriceId(priceId),
},
});
}
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
const customerId = subscription.customer as string;
const priceId = subscription.items.data[0].price.id;
await prisma.user.updateMany({
where: { stripeCustomerId: customerId },
data: {
stripePriceId: priceId,
stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
plan: getPlanFromPriceId(priceId),
},
});
}
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
const customerId = subscription.customer as string;
await prisma.user.updateMany({
where: { stripeCustomerId: customerId },
data: {
stripeSubscriptionId: null,
stripePriceId: null,
stripeCurrentPeriodEnd: null,
plan: "free",
},
});
}
async function handlePaymentSucceeded(invoice: Stripe.Invoice) {
const customerId = invoice.customer as string;
const subscriptionId = invoice.subscription as string;
if (!subscriptionId) return;
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
await prisma.user.updateMany({
where: { stripeCustomerId: customerId },
data: {
stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
},
});
}
async function handlePaymentFailed(invoice: Stripe.Invoice) {
const customerId = invoice.customer as string;
// Send a payment failed email here
// Don't immediately revoke access — Stripe retries failed payments
console.log(`Payment failed for customer: ${customerId}`);
}
function getPlanFromPriceId(priceId: string): string {
const planMap: Record<string, string> = {
[process.env.STRIPE_PRO_PRICE_ID!]: "pro",
[process.env.STRIPE_STARTER_PRICE_ID!]: "starter",
};
return planMap[priceId] ?? "free";
}
Step 3: Add the Right Database Fields
Update your Prisma schema:
model User {
// ... existing fields
stripeCustomerId String? @unique
stripeSubscriptionId String? @unique
stripePriceId String?
stripeCurrentPeriodEnd DateTime?
plan String @default("free")
}
Run the migration:
npx prisma migrate dev --name add-stripe-fields
Step 4: Pass userId Through Checkout
This is the most commonly skipped step. When you create a Stripe checkout session, pass the user's ID in the metadata so your webhook can look them up:
const session = await stripe.checkout.sessions.create({
customer_email: user.email,
line_items: [{ price: priceId, quantity: 1 }],
mode: "subscription",
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
metadata: {
userId: user.id, // 👈 this is what the webhook uses to find the right user
},
});
Without this, your webhook receives a payment notification with no way to know which user paid.
Step 5: Test Webhooks Locally
Install the Stripe CLI:
brew install stripe/stripe-cli/stripe
stripe login
Forward webhooks to your local server:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
The CLI will output a webhook signing secret — use this as STRIPE_WEBHOOK_SECRET in your .env.local during development. It's different from your production webhook secret.
Trigger test events:
stripe trigger checkout.session.completed
stripe trigger customer.subscription.deleted
stripe trigger invoice.payment_failed
Test every event in your switch statement before you ship.
Step 6: Protect Routes Based on Subscription
A utility function to check entitlements server-side:
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
export async function getUserPlan() {
const session = await auth();
if (!session?.user?.id) return null;
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: {
plan: true,
stripeCurrentPeriodEnd: true,
},
});
if (!user) return null;
const isActive =
user.stripeCurrentPeriodEnd &&
user.stripeCurrentPeriodEnd > new Date();
return {
plan: isActive ? user.plan : "free",
isActive,
};
}
Use this in your Server Components and API routes to gate features by plan.
Common Mistakes That Cost You Money
Not verifying the webhook signature. Anyone can send a POST request to your webhook endpoint. Always verify with stripe.webhooks.constructEvent. The example above does this — don't skip it.
Parsing the body as JSON before verification. Covered above, but worth repeating. Raw body only.
Only handling checkout.session.completed. Subscriptions change. People cancel. Payments fail. Handle all the lifecycle events or your database will drift from Stripe's state.
Not idempotent handlers. Stripe may send the same event more than once. Make sure your database updates use upsert or updateMany rather than create, or you'll hit duplicate key errors.
Revoking access immediately on payment failure. Stripe retries failed payments. Give users a grace period before downgrading their account.
The Honest Assessment
A properly wired Stripe webhook integration takes about 6–9 hours the first time you do it — including the checkout flow, all the lifecycle events, entitlement syncing, and local testing. It's not glamorous work, but it's the difference between a SaaS that handles money correctly and one that leaks revenue or frustrates users.
If you'd rather spend that time building your actual product, ZeroDrag ships with the full Stripe integration pre-wired — checkout, webhooks, subscription lifecycle, entitlement sync, and protected routes. It's all done before you open the repo.
Written by Utkarsh Singh. Last updated March 2026.