Webhooks are how your SaaS stays in sync with external services. But most Next.js webhook tutorials are fragile. Here is a robust pattern that works for Stripe, GitHub, and any webhook provider.
The pattern
Every webhook handler follows the same structure:
- Read raw body (not JSON-parsed)
- Verify signature
- Parse event
- Handle by event type
- Return 200 quickly
// src/app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: NextRequest) {
// 1. Raw body (critical — JSON.parse breaks signature verification)
const body = await req.text();
const signature = req.headers.get("stripe-signature");
if (!signature) {
return NextResponse.json({ error: "No signature" }, { status: 400 });
}
// 2. Verify signature
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch (err) {
console.error("Webhook signature failed:", err);
return NextResponse.json({ error: "Invalid" }, { status: 400 });
}
// 3-4. Handle by event type
try {
switch (event.type) {
case "checkout.session.completed":
await handleCheckoutComplete(event.data.object);
break;
case "invoice.payment_succeeded":
await handlePaymentSuccess(event.data.object);
break;
case "customer.subscription.deleted":
await handleSubscriptionCanceled(event.data.object);
break;
default:
// Log unhandled events for debugging
console.log(`Unhandled: ${event.type}`);
}
} catch (err) {
console.error(`Handler failed for ${event.type}:`, err);
return NextResponse.json({ error: "Handler failed" }, { status: 500 });
}
// 5. Return 200 quickly
return NextResponse.json({ received: true });
}
Critical gotchas
1. Use req.text(), not req.json()
Stripe (and most webhook providers) verify signatures against the raw request body. If you parse it as JSON first, the signature check fails because JSON.stringify(JSON.parse(body)) may not produce the exact same string.
2. Return 200 before heavy processing
Stripe retries on non-2xx responses. If your handler takes 30 seconds to process, Stripe thinks it failed and retries. For heavy work, acknowledge receipt immediately and process async:
// Quick acknowledge
const response = NextResponse.json({ received: true });
// Process in background (edge-compatible)
waitUntil(handleCheckoutComplete(event.data.object));
return response;
3. Make handlers idempotent
Webhooks can be delivered multiple times. Use upsert instead of create:
await db.subscription.upsert({
where: { stripeSubscriptionId: sub.id },
create: { /* ... */ },
update: { /* ... */ },
});
4. Log everything
console.log(`Webhook: ${event.type} | ID: ${event.id}`);
You will debug webhook issues at 2am. Logs are your friend.
GitHub webhook example
Same pattern, different verification:
import { createHmac } from "crypto";
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = req.headers.get("x-hub-signature-256");
const expected = "sha256=" + createHmac("sha256", process.env.GITHUB_WEBHOOK_SECRET!)
.update(body)
.digest("hex");
if (signature !== expected) {
return NextResponse.json({ error: "Invalid" }, { status: 401 });
}
const event = req.headers.get("x-github-event");
const payload = JSON.parse(body);
switch (event) {
case "push":
// Handle push
break;
case "pull_request":
// Handle PR
break;
}
return NextResponse.json({ ok: true });
}
Local development
For Stripe: stripe listen --forward-to localhost:3000/api/webhooks/stripe
For GitHub: use smee.io to forward webhooks locally.
Full implementation
The Stripe webhook handler (with all three critical events) comes pre-built in LaunchKit — a production-ready SaaS starter kit.
Top comments (0)