DEV Community

Build Wright
Build Wright

Posted on

I open-sourced the Stripe webhook verifier I built for Next.js 15

The problem

I shipped my first SaaS a few weeks ago. Stripe webhooks were one of the things that took longer than they should have, because the official Stripe documentation example breaks in Next.js 15 App Router.

The issue is how App Router reads request bodies: The standard pattern in older Next.js used req.body directly. App Router uses request.text() and the body can only be consumed once, which trips up the standard Stripe verification flow if you read the body for any reason before passing it to the verifier.

I solved it for myself, then realized other people were probably hitting the same wall. So I extracted what I built into a standalone package.

What it is

@northvane/stripe-webhook-verifier-nextjs

A type-safe Stripe webhook signature verifier for Next.js 15 App Router, with structured failure reasons so you can tell the difference between "wrong signature" and "expired timestamp" and "malformed header" without parsing error strings.

  • MIT license
  • 19 unit tests covering forged signatures, replay attacks, body tampering, malformed headers, edge cases
  • Zero dependencies beyond the Stripe SDK (which you already have)
  • Returns a typed Stripe.Event on success, or a structured failure with a specific reason on failure

Quick example

import { verifyStripeWebhook } from '@northvane/stripe-webhook-verifier-nextjs';
import { NextRequest } from 'next/server';

export async function POST(request: NextRequest) {
  const body = await request.text();
  const signature = request.headers.get('stripe-signature');

  const result = verifyStripeWebhook({
    body,
    signature,
    signingSecret: process.env.STRIPE_WEBHOOK_SECRET!,
  });

  if (!result.ok) {
    return new Response(`Webhook error: ${result.reason}`, { status: 400 });
  }

  // result.event is a typed Stripe.Event
  switch (result.event.type) {
    case 'checkout.session.completed':
      // handle
      break;
  }

  return new Response('ok');
}
Enter fullscreen mode Exit fullscreen mode

Common pitfalls this addresses

  1. Body parsing in App Router. If you read request.json() or request.text() more than once, the second call returns empty. The package documents the right pattern.

  2. Stale signing secret rotation. Stripe lets you rotate webhook signing secrets, but if you cache the secret in module scope, you keep verifying against the old one. The package recommends reading from env vars per-request.

  3. Timestamp tolerance defaults. Stripe's default tolerance is 5 minutes, which protects against replay attacks. The package surfaces this as a structured failure (reason: 'replay') so you can log and alert separately from "wrong signature" failures.

  4. Documented quirk: Stripe's tolerance check only rejects signatures from the past, not the future. Far-future timestamps pass verification. The package documents this so you're not surprised.

Get it

Repo: https://github.com/aibuildwright/stripe-webhook-verifier-nextjs

Issues, PRs, and feedback welcome. This is the first of several utilities I'm extracting from my own SaaS launch and open-sourcing while building BuildEngine in semi-public.

If you've hit similar webhook issues or are about to build your first Stripe integration, I'd love to hear what bit you.

Top comments (0)