Stripe Webhooks
Recipe
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,
},
});
}Working Example
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 });
}Deep Dive
How It Works
- Stripe sends HTTP POST requests to your webhook endpoint whenever events occur (payments, subscriptions, disputes, etc.).
stripe.webhooks.constructEventverifies thestripe-signatureheader against the raw request body using your webhook secret. This prevents attackers from sending fake events.- The raw body must be read as text (
request.text()), not parsed as JSON. Parsing it first corrupts the signature verification. - Stripe retries failed webhook deliveries (non-2xx responses) up to 3 times over several hours with exponential backoff.
- Each event has a unique
id(e.g.,evt_xxx). Store processed event IDs to achieve idempotency and avoid double-processing on retries.
Variations
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;
}TypeScript Notes
event.data.objectis typed asStripe.Event.Data.Object, which is a broad union. Cast it to the specific type based onevent.type.- Use
Stripe.Eventfor the event type and specific object types likeStripe.Checkout.Session,Stripe.Invoice,Stripe.Subscriptionfor the data object.
function isCheckoutEvent(
event: Stripe.Event
): event is Stripe.DiscriminatedEvent.CheckoutSessionCompletedEvent {
return event.type === "checkout.session.completed";
}Gotchas
- You must read the request body as raw text with
request.text(). Usingrequest.json()will break signature verification. - Next.js App Router Route Handlers do not apply body parsing middleware, so raw body access works without extra configuration.
- Stripe may send the same event multiple times. Always implement idempotency by tracking processed event IDs.
- Return a 2xx response quickly (within 10 seconds). If your handler needs to do slow work, acknowledge the webhook first and process asynchronously.
- The webhook secret for the Stripe CLI (
whsec_...) is different from the one in your Dashboard. Use the CLI secret during local development. - In production, configure the webhook endpoint in the Stripe Dashboard under Developers and Webhooks. Select only the events you actually handle.
- Do not use
request.headers.get()directly in Next.js App Router. Use theheaders()function fromnext/headersinstead.
Alternatives
| 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 |
FAQs
Why must you read the request body as text instead of JSON for webhook signature verification?
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.
What is the purpose of webhook signature verification?
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.
Why is idempotency important for webhook handlers?
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.
How do you test webhooks locally with the Stripe CLI?
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.
Gotcha: Is the Stripe CLI webhook secret the same as the Dashboard 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.
How quickly must your webhook handler respond?
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.
What happens if your webhook endpoint returns a non-2xx response?
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.
Gotcha: Can you use request.headers.get() directly in a Next.js App Router Route Handler?
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.
Which webhook events should a typical SaaS app handle?
checkout.session.completed-- initial purchase/subscription createdinvoice.paid-- recurring payment succeededcustomer.subscription.updated-- plan changes, status changescustomer.subscription.deleted-- subscription fully canceled
TypeScript: How do you type the event data object for different event types?
event.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;
}TypeScript: How can you create a type guard for specific webhook events?
function isCheckoutEvent(
event: Stripe.Event
): event is Stripe.DiscriminatedEvent.CheckoutSessionCompletedEvent {
return event.type === "checkout.session.completed";
}