Next.js Edge Middleware Blocked Our Client Webhook at 2 AM cover image
Back to Blog
TechnologyPublished 16 July 2026· Updated 22 August 2026· 5 min read

Next.js Edge Middleware Blocked Our Client Webhook at 2 AM

A silent 401 from Clerk middleware killed our Stripe webhook handler in production. Here is the exact fix we shipped.

The 2 AM Pager: When Stripe Webhooks Vanish Into Next.js Middleware

At 2:17 AM IST, our phone buzzed. A client on Next.js 14 App Router, hosted on Vercel, had stopped receiving Stripe webhook events. The dashboard stayed empty. Stripe kept retrying every 3 minutes. Our logs showed nothing. No errors. No traces. Just a silent 401 that never reached our handler.

We spent the next hour chasing ghosts. The route file looked correct. The secret was right. The signature check was in place. But Stripe saw 401s and we saw zero logs.

This is the story of how Next.js Edge Middleware, Clerk auth, and a deprecated config option combined to eat our webhook alive.

What We Tried First (And Why It Failed)

We assumed the classic Pages Router pattern still worked in App Router. It did not.

First mistake: we kept export const config = { api: { bodyParser: false } } in our route file. In App Router, this config is silently ignored. It does nothing. Stripe sends a raw body. Our handler called request.json() before signature verification. The stream was consumed. The signature check failed. Stripe saw 401s.

Second mistake: we never excluded the webhook path from Clerk middleware. Clerk runs on every route by default in production. Stripe is not an authenticated user. The middleware intercepted our webhook route and returned 401 before our handler executed. No log entry. No trace.

Third mistake: we used Buffer.from() for HMAC verification. The route runs on Vercel Edge Runtime. Buffer does not exist there. The handler threw a runtime error. Stripe saw 401s. We saw nothing.

All three silently broke the handler. Stripe retried. Our logs stayed empty.

The Working Fix: Three Real Changes That Stuck

1. Removed Deprecated Config, Switched to request.text()

We deleted export const config = { api: { bodyParser: false } } from /app/api/webhooks/stripe/route.ts. In App Router, body parsing is handled by the Web Fetch API. We switched to await request.text() before any body read, then JSON.parse() after signature verification.

import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-06-20',
});

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

  if (!sig) {
    return NextResponse.json({ error: 'No signature' }, { status: 400 });
  }

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    console.error('Webhook signature verification failed:', err);
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
  }

  if (event.type === 'checkout.session.completed') {
    const session = event.data.object as Stripe.Checkout.Session;
    // handle your session here
  }

  return NextResponse.json({ received: true });
}

2. Excluded /api/webhooks from Clerk Middleware

We opened /src/middleware.ts and added the webhook path to the exclusion matcher. Clerk middleware now skips /api/webhooks entirely.

import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';

const isProtectedRoute = createRouteMatcher(['/dashboard(.*)', '/account(.*)']);

export default clerkMiddleware((auth, req) => {
  if (isProtectedRoute(req)) {
    auth().protect();
  }
});

export const config = {
  matcher: [
    '/((?!api/webhooks|_next/static|_next/image|favicon.ico).*)',
    '/(api|trpc)(.*)',
  ],
};

3. Replaced Buffer.from() with TextEncoder and crypto.subtle

The route runs on Vercel Edge Runtime. Buffer does not exist. We replaced Buffer.from() with TextEncoder and crypto.subtle for HMAC verification.

const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
  'raw',
  encoder.encode(process.env.STRIPE_WEBHOOK_SECRET!),
  { name: 'HMAC', hash: 'SHA-256' },
  false,
  ['sign']
);

const expectedSignature = await crypto.subtle.sign('HMAC', key, encoder.encode(body));
const expectedHex = Array.from(new Uint8Array(expectedSignature))
  .map(b => b.toString(16).padStart(2, '0'))
  .join('');

Commands and File Paths We Actually Used

  • Route file: /app/api/webhooks/stripe/route.ts
  • Middleware exclusion in /src/middleware.ts:
export const config = {
  matcher: [
    '/((?!api/webhooks|_next/static|_next/image|favicon.ico).*)',
    '/(api|trpc)(.*)',
  ],
};
  • Signature verification using TextEncoder and crypto.subtle.importKey instead of Buffer.from

Pitfalls We Would Warn an Intern About

  • Never call request.json() before verifying the webhook signature. The stream is consumed and you cannot rewind it.
  • export const config = { api: { bodyParser: false } } is silently ignored in App Router. It does nothing. Remove it.
  • Auth middleware (Clerk, NextAuth, Better Auth) returns 401 before your handler runs. No log entry. Always exclude webhook paths.
  • Buffer does not exist on Edge Runtime. Use TextEncoder and the Web Crypto API.
  • Comparing req.method === 'post' instead of 'POST' causes timeouts. HTTP methods are always uppercase.

What We Would Do Differently Next Time

We would route all webhooks through HookRelay as a buffer layer. Instead of Stripe hitting our API route directly, HookRelay would receive the event, handle retries and signature verification, and forward only verified events to our Next.js handler. This decouples delivery reliability from our application code and gives us a replayable event log when things go wrong.

We would also add a vercel.json with explicit route rewrites to avoid any ambiguity in how Vercel routes external callbacks.

The next time Stripe delivers a webhook at 2 AM, we want it to land in our handler, not in middleware.

Sources

Enjoyed this article?

Back to Blog