ShipAI.today payment reference
Stripe webhooks in Next.js: complete setup guide.
Stripe webhooks let your backend react to payment events — subscriptions activated, payments failed, plans changed. This guide covers creating the webhook route handler in Next.js 15 App Router, verifying signatures, handling the key SaaS events, and testing locally with the Stripe CLI.
How it works
What Stripe webhooks do and why you need them
Stripe cannot update your database directly — webhooks are how Stripe calls your server when something happens.
The webhook flow
Why signature verification is mandatory
Your webhook endpoint is a public HTTPS URL. Without verifying the signature, any attacker could POST a fake checkout.session.completed event to grant themselves free access. Stripe signs every request with your webhook secret using HMAC-SHA256. Always verify — never skip it.
Implementation
Stripe webhook route handler for Next.js 15
The complete route handler with signature verification and essential event handling.
Step 1 — Install the Stripe SDK
bun add stripe
# or: npm install stripeStep 2 — Environment variables (.env.local)
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_... # from Stripe CLI (local) or Dashboard (production)Step 3 — lib/stripe.ts (shared Stripe client)
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-12-18.acacia",
});Step 4 — app/api/webhook/route.ts (full implementation)
import { stripe } from "@/lib/stripe";
import { headers } from "next/headers";
import type { NextRequest } from "next/server";
export async function POST(req: NextRequest) {
const body = await req.text(); // raw body required for signature verification
const headersList = await headers();
const signature = headersList.get("Stripe-Signature");
if (!signature) {
return new Response("Missing Stripe-Signature header", { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error("Webhook signature verification failed:", err);
return new Response("Invalid signature", { status: 400 });
}
// Handle the event
try {
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
await handleCheckoutCompleted(session);
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;
}
case "invoice.payment_succeeded": {
const invoice = event.data.object as Stripe.Invoice;
await handlePaymentSucceeded(invoice);
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
await handlePaymentFailed(invoice);
break;
}
default:
// Unhandled event type — log and ignore
console.log(`Unhandled Stripe event: ${event.type}`);
}
} catch (err) {
console.error("Error handling webhook event:", err);
// Return 500 so Stripe retries the event
return new Response("Internal error", { status: 500 });
}
// Always return 200 to acknowledge receipt
return new Response(JSON.stringify({ received: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
const { customer, subscription, metadata } = session;
const userId = metadata?.userId; // you pass this when creating the Checkout Session
if (!userId || !subscription) return;
// Retrieve full subscription object
const sub = await stripe.subscriptions.retrieve(subscription as string);
// Update your DB: link user → Stripe customer, set plan, mark as active
await db.user.update({
where: { id: userId },
data: {
stripeCustomerId: customer as string,
stripeSubscriptionId: sub.id,
stripePriceId: sub.items.data[0].price.id,
stripeCurrentPeriodEnd: new Date(sub.current_period_end * 1000),
plan: "pro", // or derive from price ID
},
});
}
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
const customerId = subscription.customer as string;
await db.user.update({
where: { stripeCustomerId: customerId },
data: {
stripePriceId: subscription.items.data[0].price.id,
stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
plan: subscription.status === "active" ? "pro" : "free",
},
});
}
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
const customerId = subscription.customer as string;
await db.user.update({
where: { stripeCustomerId: customerId },
data: {
stripeSubscriptionId: null,
plan: "free",
},
});
}
async function handlePaymentSucceeded(invoice: Stripe.Invoice) {
// Extend subscription period in DB
const customerId = invoice.customer as string;
const subscriptionId = invoice.subscription as string | null;
if (!subscriptionId) return;
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
await db.user.update({
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 email to user to update payment method
// Consider downgrading to free after grace period
console.log(`Payment failed for customer ${customerId}`);
}Next.js config
Important: disable body parsing for the webhook route
Next.js must receive the raw request body to verify the Stripe signature. The default JSON body parser breaks signature verification.
Why raw body matters
Stripe computes its signature over the exact raw bytes of the request body. If Next.js parses the JSON body first (which reformats it), the bytes change and constructEvent() throws a signature mismatch error. Using await req.text() in the App Router route handler reads the raw body string directly — the right approach.
App Router — use req.text() (recommended)
In Next.js 13+ App Router route handlers, simply call await req.text() instead of await req.json(). No config needed.
const body = await req.text(); // ✅ raw body for signature verification
// NOT: const body = await req.json(); // ❌ destroys signaturePages Router — disable body parsing with config export
If you're on Pages Router (pages/api/webhook.ts), export a config object to disable Next.js's body parser:
// pages/api/webhook.ts
export const config = {
api: {
bodyParser: false, // required for Stripe signature verification
},
};Event reference
Key Stripe webhook events for a subscription SaaS
These are the events you need to handle for a complete billing integration.
| Event | When it fires | What to do | Priority |
|---|---|---|---|
| checkout.session.completed | User completes Stripe Checkout | Provision access, create subscription record in DB, send welcome email | Critical |
| customer.subscription.updated | Plan changed, seats added/removed, subscription period renewed | Update plan in DB, adjust feature access gates | Critical |
| customer.subscription.deleted | Subscription cancelled (at period end or immediately) | Downgrade user to free tier, revoke pro access | Critical |
| invoice.payment_succeeded | Successful recurring payment (renewal) | Extend current_period_end in DB, optionally send receipt email | High |
| invoice.payment_failed | Payment method declined on recurring charge | Send email to update card, start grace period countdown | High |
| customer.subscription.trial_will_end | 3 days before trial ends | Send conversion email urging user to add payment method | Medium |
| customer.updated | Customer email/name changed | Sync name/email changes to your user table | Low |
Local testing
Testing Stripe webhooks locally with the Stripe CLI
The Stripe CLI creates a tunnel that forwards Stripe events to your local dev server.
Install and authenticate
# macOS
brew install stripe/stripe-cli/stripe
# Windows
scoop bucket add stripe https://github.com/stripe/scoop-stripe-cli.git
scoop install stripe
# Authenticate (opens browser)
stripe loginStart the webhook listener
stripe listen --forward-to localhost:3000/api/webhookThe CLI prints your local webhook signing secret: > Ready! Your webhook signing secret is whsec_abc123...
Copy this value and add it to your .env.local as STRIPE_WEBHOOK_SECRET=whsec_abc123...
Trigger test events
# Trigger a checkout completed event
stripe trigger checkout.session.completed
# Trigger a subscription updated event
stripe trigger customer.subscription.updated
# Trigger a payment failure
stripe trigger invoice.payment_failed
# List all available event types
stripe trigger --helpPro tip: replay events from the Stripe Dashboard
In the Stripe Dashboard → Developers → Webhooks → your endpoint → Events, you can click any past event and hit "Resend" to replay it to your endpoint. Useful for debugging specific real payloads without going through the full checkout flow again.
Production
Setting up Stripe webhooks in production
Registering your live webhook endpoint in the Stripe Dashboard.
Go to Stripe Dashboard → Developers → Webhooks
Click "Add endpoint". Enter your production URL, e.g., https://yourdomain.com/api/webhook.
Select events to listen for
Add: checkout.session.completed, customer.subscription.updated, customer.subscription.deleted, invoice.payment_succeeded, invoice.payment_failed. You can add more later.
Copy the signing secret
After creating the endpoint, click "Signing secret" → "Reveal". Copy the whsec_... value and add it to your production environment as STRIPE_WEBHOOK_SECRET.
Deploy and test
Deploy your app. Stripe Dashboard shows delivery attempts, response codes, and event payloads. If you get a 400/500, check the Stripe Dashboard logs to see the exact error.
Troubleshooting
Common Stripe webhook errors and fixes
No signatures found matching the expected signature for payload
Cause: Wrong webhook secret, or body was parsed (not raw)
Fix: Use the correct STRIPE_WEBHOOK_SECRET (from Stripe CLI for local, from Dashboard for prod). Read body with req.text(), not req.json().
Timestamp outside the tolerance zone
Cause: System clock skew, or cached/buffered old request
Fix: The Stripe SDK rejects requests older than 300 seconds by default. Ensure server clock is synced. For testing, pass { clockSkew: Infinity } as the 4th arg to constructEvent() (dev only).
Webhook returns 404
Cause: Route handler not deployed, wrong URL, or wrong HTTP method
Fix: Stripe webhooks use POST. Make sure you export a POST function (not GET). In App Router: export async function POST(req) { ... }
Event fires but database not updated
Cause: Async handler throws but returns 200, so Stripe doesn't retry
Fix: Wrap handlers in try/catch. Return 500 on DB errors so Stripe retries. Return 200 only after successful processing.
Duplicate event handling
Cause: Stripe delivers at-least-once — same event may arrive multiple times
Fix: Use event.id as an idempotency key. Store processed event IDs in DB and check before processing: if (await isEventProcessed(event.id)) return 200.
Related guides
More payment & billing references
Ready to ship
Stripe billing pre-wired in ShipAI.today
ShipAI.today ships with the complete Stripe billing integration — webhook handler, subscription management, portal, pro gating, and all five critical events handled. Skip the 2-day setup.