DEV Community

huangyongshan46-a11y
huangyongshan46-a11y

Posted on

The Next.js Webhook Handler Pattern: Stripe, GitHub, and Beyond (2026)

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:

  1. Read raw body (not JSON-parsed)
  2. Verify signature
  3. Parse event
  4. Handle by event type
  5. 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 });
}
Enter fullscreen mode Exit fullscreen mode

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

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

4. Log everything

console.log(`Webhook: ${event.type} | ID: ${event.id}`);
Enter fullscreen mode Exit fullscreen mode

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

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.

GitHub | Get LaunchKit ($49)

Top comments (0)