Integrate Stripe webhooks with Next.js 15 App Router. Learn stripe.webhooks.constructEvent, raw body handling, payment event processing, fulfillment logic, and testing with the Stripe CLI. This practical guide covers step-by-step setup, real-world code snippets, security hardening, and common production edge cases.
Stripe webhooks are the backbone of any reliable payment integration. Rather than polling the Stripe API to discover whether a payment succeeded, a subscription renewed, or a refund was issued, webhooks let Stripe push real-time event notifications directly to your server the moment something happens. This event-driven model is faster, cheaper to operate, and far more resilient than polling loops.
Next.js 15 App Router introduces a subtlety that trips up most developers: it streams request bodies instead of buffering them, which breaks Stripe's cryptographic signature verification out of the box. Getting webhooks right in this environment means understanding exactly how stripe.webhooks.constructEvent works, why raw bytes matter, and how to structure your route handler so it is both secure and maintainable.
In this guide you will build a production-ready Stripe webhook endpoint from the ground up. You will handle the three most commercially important event types — checkout.session.completed, payment_intent.succeeded, and the subscription lifecycle events — wire up a fulfillment layer that talks to your database, and use the Stripe CLI to drive the entire flow locally without touching a real card.
Prerequisites
Before diving in, make sure you have the following ready:
- Next.js 15 project using the App Router (
app/directory) - Node.js 18+ (required for the native
ReadableStreamAPIs used below) - A Stripe account — free tier is fine for testing
- Stripe CLI installed locally (
brew install stripe/stripe-cli/stripeon macOS, or the Windows installer from the Stripe docs) - Basic familiarity with TypeScript and async/await patterns
- A database client (examples use Prisma, but the pattern is database-agnostic)
1. Setting Up Stripe Webhooks in the Dashboard
Log into the Stripe Dashboard and navigate to Developers → Webhooks. Click Add endpoint and fill in:
- Endpoint URL:
https://yourdomain.com/api/webhooks/stripe - Events to listen for: Select the events your application needs (see the table later in this article)
Once saved, Stripe generates a Webhook Signing Secret that looks like whsec_.... Copy it immediately — you will only see it once. Add it to your environment variables:
# .env.local
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
Never commit these values to version control. Use .env.local for local development and your hosting platform's secret manager (Vercel Environment Variables, AWS Secrets Manager, etc.) for production.
2. Installing the Stripe SDK and Configuration
Install the official Stripe Node.js library and its TypeScript types:
npm install stripe
# Types are bundled with the package — no @types/stripe needed
Create a singleton Stripe client so you do not instantiate a new connection on every request:
// lib/stripe.ts
import Stripe from "stripe";
if (!process.env.STRIPE_SECRET_KEY) {
throw new Error("STRIPE_SECRET_KEY environment variable is not set");
}
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: "2024-06-20",
typescript: true,
});
Exporting a singleton here ensures that Next.js module caching keeps a single authenticated Stripe instance across hot reloads in development and across serverless function invocations that share the same container in production.
3. The Raw Body Requirement in Next.js App Router
Stripe's constructEvent method verifies a HMAC-SHA256 signature computed over the raw request body bytes. If your framework parses the body first — converting it to a JavaScript object — the byte sequence changes and verification always fails with a StripeSignatureVerificationError.
In Next.js 15 App Router you must read the raw ArrayBuffer from the request and convert it to a Buffer before doing anything else with the body:
// app/api/webhooks/stripe/route.ts (excerpt — full version below)
export const runtime = "nodejs"; // Ensures Node.js Buffer is available
export async function POST(request: Request) {
const rawBody = await request.arrayBuffer();
const body = Buffer.from(rawBody);
// ✅ body is now raw bytes — safe to pass to constructEvent
}
Do not call request.json() or request.text() before passing the body to Stripe. Also add the export const runtime = "nodejs" directive to the route file — the Edge runtime does not have Node.js Buffer and will fail at runtime.
4. Verifying Webhooks with stripe.webhooks.constructEvent
With the raw buffer in hand, grab the Stripe-Signature header and call constructEvent. Wrap it in a try/catch — an invalid signature means the request is not from Stripe and should be rejected with a 400:
// app/api/webhooks/stripe/route.ts
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import type Stripe from "stripe";
export const runtime = "nodejs";
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(request: Request) {
const rawBody = await request.arrayBuffer();
const body = Buffer.from(rawBody);
const signature = request.headers.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, webhookSecret);
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
console.error(`⚠️ Webhook signature verification failed: ${message}`);
return NextResponse.json({ error: `Webhook Error: ${message}` }, { status: 400 });
}
// Route the verified event to the correct handler
try {
await handleStripeEvent(event);
} catch (err) {
console.error("Webhook handler error:", err);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
return NextResponse.json({ received: true }, { status: 200 });
}
Always return 200 promptly — Stripe retries any endpoint that does not respond within 30 seconds. Keep fulfillment logic fast or dispatch it to a background queue.
5. Handling checkout.session.completed
This event fires when a customer completes the Stripe-hosted checkout page. It is the most common trigger for granting access to a product:
// lib/webhook-handlers.ts
import { stripe } from "@/lib/stripe";
import { db } from "@/lib/db"; // Your Prisma or DB client
import type Stripe from "stripe";
export async function handleCheckoutSessionCompleted(
session: Stripe.Checkout.Session
): Promise<void> {
// Expand line_items if not already included
const fullSession = await stripe.checkout.sessions.retrieve(session.id, {
expand: ["line_items", "customer"],
});
const customerId = fullSession.customer as string;
const customerEmail =
typeof fullSession.customer === "object"
? (fullSession.customer as Stripe.Customer).email
: fullSession.customer_details?.email;
if (!customerEmail) {
throw new Error(`No customer email on session ${session.id}`);
}
// Upsert user and record the purchase
await db.user.upsert({
where: { email: customerEmail },
create: {
email: customerEmail,
stripeCustomerId: customerId,
purchases: {
create: {
stripeSessionId: session.id,
amount: session.amount_total ?? 0,
currency: session.currency ?? "usd",
status: "COMPLETED",
},
},
},
update: {
stripeCustomerId: customerId,
purchases: {
create: {
stripeSessionId: session.id,
amount: session.amount_total ?? 0,
currency: session.currency ?? "usd",
status: "COMPLETED",
},
},
},
});
console.log(`✅ Checkout completed for ${customerEmail}`);
}
6. Handling payment_intent.succeeded
payment_intent.succeeded fires for every successful charge, including those outside of Checkout. Use it when you build a custom payment flow with Elements:
export async function handlePaymentIntentSucceeded(
paymentIntent: Stripe.PaymentIntent
): Promise<void> {
const { id, amount, currency, metadata } = paymentIntent;
const userId = metadata?.userId;
if (!userId) {
// Metadata not set — nothing to fulfill; log and move on
console.warn(`PaymentIntent ${id} has no userId in metadata`);
return;
}
await db.payment.upsert({
where: { stripePaymentIntentId: id },
create: {
stripePaymentIntentId: id,
userId,
amount,
currency,
status: "SUCCEEDED",
},
update: { status: "SUCCEEDED" },
});
// Trigger downstream fulfillment (email, license key, etc.)
await fulfillOrder(userId, id);
}
The metadata object is your bridge between the payment intent and your application's domain. Always attach userId (or orderId) when you create the intent on your server.
7. Processing Subscription Events
Subscription management requires tracking three events at minimum:
export async function handleSubscriptionEvent(
event: Stripe.Event
): Promise<void> {
const subscription = event.data.object as Stripe.Subscription;
const customerId = subscription.customer as string;
const status = subscription.status; // active | past_due | canceled | etc.
const currentPeriodEnd = new Date(subscription.current_period_end * 1000);
await db.subscription.upsert({
where: { stripeSubscriptionId: subscription.id },
create: {
stripeSubscriptionId: subscription.id,
stripeCustomerId: customerId,
status,
currentPeriodEnd,
priceId: subscription.items.data[0]?.price.id ?? "",
},
update: {
status,
currentPeriodEnd,
priceId: subscription.items.data[0]?.price.id ?? "",
},
});
console.log(
`📦 Subscription ${subscription.id} → ${status} (customer: ${customerId})`
);
}
Listen to customer.subscription.created, customer.subscription.updated, and customer.subscription.deleted and route all three to this single handler — the logic is identical because the upsert captures the current status regardless of which transition occurred.
8. Implementing Fulfillment Logic
Wire all the handlers together in a central dispatcher:
// lib/webhook-handlers.ts (continued)
import type Stripe from "stripe";
import {
handleCheckoutSessionCompleted,
handlePaymentIntentSucceeded,
handleSubscriptionEvent,
} from "./handlers";
export async function handleStripeEvent(event: Stripe.Event): Promise<void> {
switch (event.type) {
case "checkout.session.completed":
await handleCheckoutSessionCompleted(
event.data.object as Stripe.Checkout.Session
);
break;
case "payment_intent.succeeded":
await handlePaymentIntentSucceeded(
event.data.object as Stripe.PaymentIntent
);
break;
case "customer.subscription.created":
case "customer.subscription.updated":
case "customer.subscription.deleted":
await handleSubscriptionEvent(event);
break;
case "invoice.payment_failed":
await handleInvoicePaymentFailed(event.data.object as Stripe.Invoice);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
}
async function handleInvoicePaymentFailed(invoice: Stripe.Invoice): Promise<void> {
const customerId = invoice.customer as string;
// Notify customer, suspend access after grace period, etc.
await db.subscription.updateMany({
where: { stripeCustomerId: customerId },
data: { status: "past_due" },
});
console.warn(`⚠️ Invoice payment failed for customer ${customerId}`);
}
async function fulfillOrder(userId: string, paymentIntentId: string): Promise<void> {
// Example: send confirmation email, generate license, unlock course access
await db.user.update({
where: { id: userId },
data: { accessGranted: true },
});
console.log(`🎉 Fulfilled order for user ${userId} (PI: ${paymentIntentId})`);
}
9. Testing Locally with the Stripe CLI
The Stripe CLI can forward live webhook events to your local Next.js dev server without exposing a public URL:
# Step 1: Authenticate the CLI (one-time)
stripe login
# Step 2: Start your Next.js dev server
npm run dev
# Step 3: In a second terminal, start forwarding
stripe listen --forward-to localhost:3000/api/webhooks/stripe
The CLI prints a local webhook signing secret (whsec_test_...) at startup. Replace your STRIPE_WEBHOOK_SECRET in .env.local with this test secret while developing — Stripe signs the forwarded events with it.
Trigger specific events to test your handlers:
# Trigger a successful checkout
stripe trigger checkout.session.completed
# Trigger a successful payment intent
stripe trigger payment_intent.succeeded
# Trigger a subscription creation
stripe trigger customer.subscription.created
# Trigger a failed invoice payment
stripe trigger invoice.payment_failed
Each command fires a real event through your local handler with a realistic mock payload. You can inspect every request and response in the CLI output, and replay failed deliveries with stripe events resend <event_id>.
Important Stripe Webhook Events Reference
| Event Type | Trigger | Common Use Case |
|---|---|---|
checkout.session.completed | Hosted Checkout completed | Grant product access, send receipt |
checkout.session.expired | Checkout page abandoned | Clean up pending records |
payment_intent.succeeded | Payment charged successfully | Custom Elements fulfillment |
payment_intent.payment_failed | Charge declined or errored | Notify customer, retry logic |
customer.subscription.created | New subscription started | Activate subscription tier |
customer.subscription.updated | Plan changed, trial ended | Sync plan changes to DB |
customer.subscription.deleted | Subscription canceled | Revoke access, offboard |
invoice.payment_succeeded | Recurring invoice paid | Extend subscription period |
invoice.payment_failed | Recurring invoice failed | Grace period, dunning email |
customer.updated | Customer profile changed | Sync billing details |
charge.refunded | Refund issued | Revoke access, notify user |
charge.dispute.created | Chargeback opened | Flag account, gather evidence |
Conclusion
Stripe webhooks in Next.js 15 App Router boil down to four non-negotiable rules:
- Read the raw
ArrayBuffer— never let the framework parse the body before Stripe inspects it. - Set
export const runtime = "nodejs"— Edge workers lack the Buffer API thatconstructEventrelies on. - Always return
200quickly — delegate slow work to a background queue if needed; Stripe retries after timeouts. - Verify every single request — reject anything that fails
constructEventwith a400; do not process unverified payloads.
With the singleton Stripe client, the raw-body reader, a type-safe event dispatcher, and the Stripe CLI wired into your local dev loop, you now have a production-grade foundation for handling every payment event your application will ever need. From one-time purchases to subscription billing to dispute management, the pattern scales without modification — just add a new case to the switch and wire up the handler.
Written by Dev Pattern LabsCore Engineering
Dev Pattern Labs is an engineering lab and digital consultancy specializing in custom Next.js web applications, WooCommerce REST API integrations, and workflow automation for growing businesses.
Recommended Companion Articles

Webhook Automation with Next.js: Receive, Verify and Process Events
Build a robust webhook automation system with Next.js. Learn signature verification with HMAC-SHA256, idempotency, async queue processing, and integrating common providers like Stripe, GitHub, and WooCommerce.
