DEV Community

huangyongshan46-a11y
huangyongshan46-a11y

Posted on

Next.js 16 Webhook Handler Pattern: Stripe, GitHub, and More

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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 } };
Enter fullscreen mode Exit fullscreen mode

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
  },
};
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode
// 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.
    }
  },
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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() },
});
Enter fullscreen mode Exit fullscreen mode

Your Prisma schema:

model ProcessedWebhook {
  id          String   @id @default(cuid())
  eventId     String   @unique
  processedAt DateTime @default(now())
}
Enter fullscreen mode Exit fullscreen mode

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)