Webhooks are the backbone of every modern SaaS. Stripe fires one when a payment succeeds. GitHub fires one when a PR is merged. Clerk fires one when a user signs up. If your webhook handlers are a mess of copy-pasted boilerplate, this guide is for you.
We’re going to build a clean, type-safe webhook handler pattern for Next.js 16 App Router — one you can reuse across every provider.
The Problem With Ad-Hoc Webhook Handlers
Most tutorials show you how to handle a Stripe webhook in isolation:
// pages/api/stripe-webhook.ts (the old way)
export default async function handler(req, res) {
const sig = req.headers["stripe-signature"];
const event = stripe.webhooks.constructEvent(rawBody, sig, secret);
// ... handle event
}
This works, but it doesn’t scale. By the time you’re handling 10 event types across 3 providers, you’ve got spaghetti.
The Pattern: A Typed Webhook Router
Here’s the architecture we’ll build:
app/
api/
webhooks/
stripe/
route.ts ← signature verification + routing
github/
route.ts
clerk/
route.ts
lib/
webhooks/
stripe-handlers.ts ← one function per event type
github-handlers.ts
Each route handles signature verification and dispatches to a typed handler map.
Step 1: The Stripe Webhook Route
First, you need the raw body — Next.js 16 App Router gives you this via request.arrayBuffer():
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { stripeHandlers } from "@/lib/webhooks/stripe-handlers";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: NextRequest) {
const body = await req.arrayBuffer();
const rawBody = Buffer.from(body);
const signature = req.headers.get("stripe-signature");
if (!signature) {
return NextResponse.json({ error: "No signature" }, { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
} catch (err) {
console.error("Stripe webhook signature failed:", err);
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
const handler = stripeHandlers[event.type];
if (handler) {
try {
await handler(event);
} catch (err) {
console.error(`Handler failed for ${event.type}:`, err);
return NextResponse.json({ error: "Handler error" }, { status: 500 });
}
} else {
console.log(`Unhandled Stripe event: ${event.type}`);
}
return NextResponse.json({ received: true });
}
Critical: You must disable body parsing for webhook routes. In Next.js App Router, the raw body is already available — no config needed. But if you’re still on Pages Router, you need:
export const config = { api: { bodyParser: false } };
Step 2: Typed Handler Map
Now the clean part — a handler map keyed by event type:
// lib/webhooks/stripe-handlers.ts
import Stripe from "stripe";
import { db } from "@/lib/db";
import { sendWelcomeEmail } from "@/lib/email";
type StripeHandler = (event: Stripe.Event) => Promise<void>;
export const stripeHandlers: Partial<Record<Stripe.Event["type"], StripeHandler>> = {
"checkout.session.completed": async (event) => {
const session = event.data.object as Stripe.Checkout.Session;
await db.user.update({
where: { stripeCustomerId: session.customer as string },
data: {
subscriptionStatus: "active",
plan: session.metadata?.plan ?? "pro",
},
});
await sendWelcomeEmail(session.customer_email!);
},
"customer.subscription.deleted": async (event) => {
const sub = event.data.object as Stripe.Subscription;
await db.user.update({
where: { stripeCustomerId: sub.customer as string },
data: { subscriptionStatus: "cancelled", plan: "free" },
});
},
"invoice.payment_failed": async (event) => {
const invoice = event.data.object as Stripe.Invoice;
await db.user.update({
where: { stripeCustomerId: invoice.customer as string },
data: { subscriptionStatus: "past_due" },
});
// TODO: send dunning email
},
};
This pattern is readable, testable, and easy to extend. Adding a new event type is just adding a new key.
Step 3: GitHub Webhooks
Same structure, different verification (HMAC-SHA256):
// app/api/webhooks/github/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createHmac, timingSafeEqual } from "crypto";
import { githubHandlers } from "@/lib/webhooks/github-handlers";
const secret = process.env.GITHUB_WEBHOOK_SECRET!;
async function verifyGitHubSignature(body: Buffer, signature: string): Promise<boolean> {
const hmac = createHmac("sha256", secret);
hmac.update(body);
const expected = `sha256=${hmac.digest("hex")}`;
try {
return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
} catch {
return false;
}
}
export async function POST(req: NextRequest) {
const body = await req.arrayBuffer();
const rawBody = Buffer.from(body);
const signature = req.headers.get("x-hub-signature-256") ?? "";
const event = req.headers.get("x-github-event") ?? "";
const valid = await verifyGitHubSignature(rawBody, signature);
if (!valid) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
const payload = JSON.parse(rawBody.toString());
const handler = githubHandlers[event];
if (handler) {
await handler(payload);
}
return NextResponse.json({ ok: true });
}
// lib/webhooks/github-handlers.ts
type GitHubHandler = (payload: any) => Promise<void>;
export const githubHandlers: Record<string, GitHubHandler> = {
push: async (payload) => {
const branch = payload.ref.replace("refs/heads/", "");
if (branch === "main") {
console.log(`Push to main by ${payload.pusher.name}`);
// Trigger deploy, notify Slack, etc.
}
},
pull_request: async (payload) => {
if (payload.action === "closed" && payload.pull_request.merged) {
console.log(`PR merged: ${payload.pull_request.title}`);
// Update changelog, notify team, etc.
}
},
};
Step 4: Testing Webhooks Locally
Use the Stripe CLI to forward events to localhost:
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Login
stripe login
# Forward to local dev server
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Trigger a test event
stripe trigger checkout.session.completed
For GitHub, use ngrok or smee.io to expose your local server:
# Using smee (free, no account required)
npx smee-client --url https://smee.io/YOUR_CHANNEL --path /api/webhooks/github --port 3000
Step 5: Idempotency
Webhooks can fire more than once. Stripe retries failed deliveries for 3 days. Your handlers must be idempotent — running them twice should produce the same result.
Simple approach: store processed event IDs:
// In your handler, before doing any work:
const existing = await db.processedWebhook.findUnique({
where: { eventId: event.id },
});
if (existing) {
console.log(`Already processed ${event.id}, skipping.`);
return;
}
// Do the work...
// Then record it
await db.processedWebhook.create({
data: { eventId: event.id, processedAt: new Date() },
});
Your Prisma schema:
model ProcessedWebhook {
id String @id @default(cuid())
eventId String @unique
processedAt DateTime @default(now())
}
Common Gotchas
1. Wrong Content-Type header
Always set Content-Type: application/json on your test requests. Stripe does this automatically, but manual testing often misses it.
2. Environment variables in production
Your STRIPE_WEBHOOK_SECRET in production is different from the one the Stripe CLI gives you locally. Make sure you’ve set the correct live secret in your hosting env.
3. 5xx = Stripe retries
If your handler throws and returns a 500, Stripe will retry the webhook. This is usually what you want — but make sure you implement idempotency first.
4. Timeouts
Stripe expects a response in under 30 seconds. If your handler does heavy work, return 200 immediately and process async (e.g., queue a background job).
Putting It All Together
This pattern scales cleanly. Each provider gets its own route (signature verification) and handler map (business logic). Adding a new event type is a one-liner. Testing is easy because handlers are plain async functions.
If you want a production-ready implementation of this pattern — along with Stripe subscriptions, Auth.js v5, AI chat, and a full SaaS UI — check out LaunchKit. All the webhook boilerplate is already wired up and ready to customize.
The full source is also on GitHub if you want to see how it fits into a real project structure.
Happy shipping. 🚀
Top comments (0)