Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Create a Next.js Route Handler that receives Stripe webhook events, verifies the signature, and processes key events like successful payments and subscription changes.
Add the webhook secret to your environment:
# .env.local
STRIPE_WEBHOOK_SECRET=whsec_...Create the webhook Route Handler:
// app/api/webhooks/stripe/route.ts
import { stripe } from "@/lib/stripe";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import type Stripe from "stripe";
export async function POST(request: Request) {
const body = await request.text();
const headersList = await headers();
const signature = headersList.get("stripe-signature");
if (!signature) {
return NextResponse.json(
{ error: "Missing stripe-signature header" },
{ status: 400 }
);
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
console.error(`Webhook signature verification failed: ${message}`);
return NextResponse.json({ error: message }, { status: 400 });
}
try {
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
await handleCheckoutCompleted(session);
break;
}
case "invoice.paid": {
const invoice = event.data.object as Stripe.Invoice;
await handleInvoicePaid(invoice);
break;
}
case "customer.subscription.updated": {
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionUpdated(subscription);
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionDeleted(subscription);
break;
}
default:
console.log(`Unhandled event type: ${event.type}`);
}
} catch (err) {
console.error(`Error processing webhook event ${event.type}:`, err);
return NextResponse.json(
{ error: "Webhook handler failed" },
{ status: 500 }
);
}
return NextResponse.json({ received: true });
}Implement the handler functions:
// lib/webhook-handlers.ts
import type Stripe from "stripe";
import { db } from "@/lib/db";
export async function handleCheckoutCompleted(
session: Stripe.Checkout.Session
) {
const userId = session.metadata?.userId;
if (!userId) return;
if (session.mode === "subscription") {
await db.user.update({
where: { id: userId },
data: {
stripeCustomerId: session.customer as string,
stripeSubscriptionId: session.subscription as string,
plan: "pro",
},
});
} else if (session.mode === "payment") {
await db.purchase.create({
data: {
userId,
sessionId: session.id,
amount: session.amount_total!,
status: "completed",
},
});
}
}
export async function handleInvoicePaid(invoice: Stripe.Invoice) {
const subscriptionId = invoice.subscription as string;
if (!subscriptionId) return;
await db.user.updateMany({
where: { stripeSubscriptionId: subscriptionId },
data: {
planStatus: "active",
currentPeriodEnd: new Date(invoice.lines.data[0]?.period.end * 1000),
},
});
}
export async function handleSubscriptionUpdated(
subscription: Stripe.Subscription
) {
await db.user.updateMany({
where: { stripeSubscriptionId: subscription.id },
data: {
planStatus: subscription.status,
plan: subscription.items.data[0]?.price.lookup_key ?? "unknown",
},
});
}
export async function handleSubscriptionDeleted(
subscription: Stripe.Subscription
) {
await db.user.updateMany({
where: { stripeSubscriptionId: subscription.id },
data: {
plan: "free",
planStatus: "canceled",
stripeSubscriptionId: null,
},
});
}Full webhook handler with idempotency:
// app/api/webhooks/stripe/route.ts
import { stripe } from "@/lib/stripe";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import type Stripe from "stripe";
async function isEventProcessed(eventId: string): Promise<boolean> {
const existing = await db.stripeEvent.findUnique({
where: { eventId },
});
return !!existing;
}
async function markEventProcessed(eventId: string): Promise<void> {
await db.stripeEvent.create({
data: { eventId, processedAt: new Date() },
});
}
export async function POST(request: Request) {
const body = await request.text();
const headersList = await headers();
const signature = headersList.get("stripe-signature");
if (!signature) {
return NextResponse.json({ error: "No signature" }, { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
// Idempotency: skip already-processed events
if (await isEventProcessed(event.id)) {
return NextResponse.json({ received: true, skipped: true });
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
const userId = session.metadata?.userId;
if (userId) {
await db.user.update({
where: { id: userId },
data: {
stripeCustomerId: session.customer as string,
stripeSubscriptionId: session.subscription as string,
plan: "pro",
planStatus: "active",
},
});
}
break;
}
case "invoice.paid": {
const invoice = event.data.object as Stripe.Invoice;
if (invoice.subscription) {
await db.user.updateMany({
where: { stripeSubscriptionId: invoice.subscription as string },
data: { planStatus: "active" },
});
}
break;
}
case "customer.subscription.deleted": {
const sub = event.data.object as Stripe.Subscription;
await db.user.updateMany({
where: { stripeSubscriptionId: sub.id },
data: { plan: "free", planStatus: "canceled" },
});
break;
}
}
await markEventProcessed(event.id);
return NextResponse.json({ received: true });
}stripe.webhooks.constructEvent verifies the stripe-signature header against the raw request body using your webhook secret. This prevents attackers from sending fake events.request.text()), not parsed as JSON. Parsing it first corrupts the signature verification.id (e.g., evt_xxx). Store processed event IDs to achieve idempotency and avoid double-processing on retries.Local testing with Stripe CLI:
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Login to Stripe
stripe login
# Forward events to your local server
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# In another terminal, trigger test events
stripe trigger checkout.session.completed
stripe trigger invoice.paid
stripe trigger customer.subscription.deletedThe CLI prints a webhook signing secret (whsec_...) when you start listening. Use that as your STRIPE_WEBHOOK_SECRET during local development.
Handle payment_intent.succeeded directly:
case "payment_intent.succeeded": {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
console.log(`Payment ${paymentIntent.id} succeeded: $${paymentIntent.amount / 100}`);
break;
}event.data.object is typed as Stripe.Event.Data.Object, which is a broad union. Cast it to the specific type based on event.type.Stripe.Event for the event type and specific object types like Stripe.Checkout.Session, Stripe.Invoice, Stripe.Subscription for the data object.function isCheckoutEvent(
event: Stripe.Event
): event is Stripe.DiscriminatedEvent.CheckoutSessionCompletedEvent {
return event.type === "checkout.session.completed";
}request.text(). Using request.json() will break signature verification.whsec_...) is different from the one in your Dashboard. Use the CLI secret during local development.request.headers.get() directly in Next.js App Router. Use the headers() function from next/headers instead.
| Approach | Pros | Cons |
|---|---|---|
| Route Handler webhook | Full control, server-side processing | Must handle verification and idempotency |
| Stripe CLI (local dev) | Instant testing, trigger specific events | Only for development |
| Polling the API | No webhook infrastructure needed | Delayed, wasteful, not recommended |
| Svix (webhook proxy) | Retry management, monitoring dashboard | Extra service to manage |
stripe.webhooks.constructEvent verifies the raw body bytes against the signature header. Parsing with request.json() first alters the body (key ordering, whitespace), which corrupts the signature check and causes verification to fail.
It ensures the event was actually sent by Stripe, not by an attacker sending fake payloads to your endpoint. The signature is computed using your webhook secret, which only you and Stripe know.
Stripe may deliver the same event multiple times (retries on failure). Without idempotency, you could double-process events -- e.g., granting a user double credits. Store processed event IDs to skip duplicates.
stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# In another terminal:
stripe trigger checkout.session.completedThe CLI prints a whsec_... secret to use as your local STRIPE_WEBHOOK_SECRET.
No. The CLI generates its own signing secret when you run stripe listen. Use the CLI secret during local development and the Dashboard secret in production. They are different values.
Return a 2xx response within 10 seconds. If your handler needs to do slow work (sending emails, complex DB updates), acknowledge the webhook first and process asynchronously.
Stripe retries the delivery up to 3 times over several hours with exponential backoff. After all retries fail, the event is marked as failed in your Dashboard.
No. In Next.js App Router, use the headers() function from next/headers to read request headers. Direct request.headers.get() may not work as expected for all headers.
checkout.session.completed -- initial purchase/subscription createdinvoice.paid -- recurring payment succeededcustomer.subscription.updated -- plan changes, status changescustomer.subscription.deleted -- subscription fully canceledevent.data.object is typed as a broad union. Cast it based on event.type:
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
break;
}
case "invoice.paid": {
const invoice = event.data.object as Stripe.Invoice;
break;
}function isCheckoutEvent(
event: Stripe.Event
): event is Stripe.DiscriminatedEvent.CheckoutSessionCompletedEvent {
return event.type === "checkout.session.completed";
}Reviewed by Chris St. John·Last updated Jul 19, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥