DEV Community

Ahmed Mahmoud
Ahmed Mahmoud

Posted on Originally published at devya.dev

Webhooks in the Next.js App Router: Field Notes on Raw Bodies, Signature Verification, and Returning 200 Before the Work

Headline: A webhook signature is an HMAC computed over the raw request bytes, so a handler that parses the body before verifying has already destroyed the evidence. In the Next.js App Router, await req.text() inside a route handler returns those raw bytes — verify first, acknowledge fast, and do the real work idempotently after the response.

A webhook is an HTTP POST a provider sends to my server when something happens on their side — a payment settles, a repository receives a push, a subscription cancels. I have wired webhooks into several Next.js App Router apps this year, and every one of them broke in the same three places first: verifying a signature against a body I had already parsed, doing too much work before responding, and processing the same event twice. These notes are the checklist I now start from.

Key takeaways

  • Webhook signatures — Stripe's Stripe-Signature, GitHub's X-Hub-Signature-256 — are HMACs over the raw request bytes. Verify against await req.text(), never a re-serialized JSON.parse result.
  • App Router route handlers do not pre-parse request bodies, so the Pages Router bodyParser: false escape hatch is unnecessary — req.text() is already the raw payload.
  • Return a 2xx quickly. Providers treat a slow response as a failed delivery and retry it, so a slow handler races its own retries.
  • Delivery is at-least-once and unordered: dedupe by event ID with a database unique constraint, and fetch the current object from the provider's API instead of trusting payload state.
  • Compare signatures with crypto.timingSafeEqual and enforce a timestamp tolerance so a captured request cannot be replayed later.

Why does my signature check fail on a genuine request?

Because the signature was computed over the exact bytes the provider sent, and the handler is verifying different bytes. A webhook signature is a keyed hash — an HMAC — of the raw request body, produced with a shared signing secret. If I call JSON.parse on the body and re-serialize it to verify, key order, whitespace, and unicode escaping can all change, and the HMAC no longer matches even though the request is genuine. The failure is silent and total: every event gets a 400, the provider retries, and the retry queue fills while the code looks correct.

The App Router makes the correct version easy. A route handler receives the standard web Request object, and Next.js does not pre-parse it — await req.text() returns the payload byte-for-byte. The Pages Router needed export const config = { api: { bodyParser: false } } for the same access; that configuration does nothing in the App Router.

// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const rawBody = await req.text();               // raw bytes — parse AFTER verification
  const signature = req.headers.get('stripe-signature');

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      rawBody,
      signature!,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch {
    return new Response('invalid signature', { status: 400 });
  }

  return new Response('ok', { status: 200 });
}
Enter fullscreen mode Exit fullscreen mode

When the provider ships a verification helper, I use it. stripe.webhooks.constructEvent checks both the HMAC and the signed timestamp in one call and throws on either failure.

How do I verify a webhook signature without an SDK helper?

Compute the HMAC yourself and compare in constant time. Two rules are non-negotiable. First, compare with crypto.timingSafeEqual, because an ordinary string comparison returns early at the first differing character and leaks timing information an attacker can use to probe signatures byte by byte. Second, enforce a timestamp tolerance — most providers include a signed timestamp, and rejecting anything older than about five minutes stops a captured request from being replayed later.

import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifySignature(rawBody: string, header: string, secret: string) {
  const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
  const received = header.replace(/^sha256=/, '');   // GitHub prefixes the hex digest
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(received, 'hex');
  return a.length === b.length && timingSafeEqual(a, b);
}
Enter fullscreen mode Exit fullscreen mode

One structural note: a webhook endpoint must be a route handler, not a Server Action. A Server Action is an RPC mechanism for my own application's frontend, addressed by framework-generated identifiers. A webhook needs a stable public POST URL with byte-level body access, and app/api/webhooks/<provider>/route.ts is exactly that.

Why should the handler return 200 before doing the real work?

Because the provider treats a slow response as a failed delivery. Delivery timeouts are measured in seconds, and a handler that exceeds one gets marked failed and retried — so the slow handler ends up running concurrently with its own retry. Fulfillment logic that takes ten seconds guarantees every real event arrives at least twice.

The shape I use now is verify, record, acknowledge, then work. On Vercel, waitUntil from @vercel/functions keeps the function instance alive after the response has been sent.

import { waitUntil } from '@vercel/functions';

export async function POST(req: Request) {
  const event = await verifyAndParse(req);
  if (!event) return new Response('invalid signature', { status: 400 });

  const fresh = await recordEventId(event.id);   // unique constraint = dedupe
  if (fresh) waitUntil(processEvent(event));     // runs after the response is sent

  return new Response('ok', { status: 200 });    // acknowledge in milliseconds
}
Enter fullscreen mode Exit fullscreen mode
Where the work runs Ack speed On a crash Reach for it when
Inline, before the response Slow — bounded by the work Provider retry re-delivers the event Trivial work: set a flag, update one row
waitUntil, after the response Fast Work is lost; event already acknowledged Losable side effects: cache warming, notifications
Queue or job row, separate worker Fast Job survives and is retried Money, entitlements, anything you cannot lose

The queue row is the only option I trust for money. waitUntil work that dies in a crash was already acknowledged with a 200, and the provider will never resend it.

How do I handle retries and out-of-order events?

By assuming at-least-once delivery and no ordering, because both are documented provider behavior, not edge cases.

Deduplication: every provider event carries a stable ID — evt_… on a Stripe event, the X-GitHub-Delivery header on a GitHub delivery. I insert that ID into a table with a unique constraint before processing; a constraint violation means the event was already handled, so the handler returns 200 and stops. A SELECT-then-INSERT check is a race under concurrent retries — the constraint is the lock.

Ordering: I do not build state by applying payloads in arrival order. The event is a notification that something changed, not the change itself. For anything stateful I fetch the current object from the provider's API before writing, so a stale payload cannot overwrite newer state.

How do I test webhooks locally?

A provider cannot reach localhost, so local testing needs a bridge. Three options cover my use: a provider CLI that forwards events — stripe listen --forward-to localhost:3000/api/webhooks/stripe prints a temporary signing secret for the session; a tunnel such as cloudflared or ngrok plus the provider dashboard's manual redelivery button; and signed fixtures in tests.

The fixtures are the ones that pay rent. I capture one real payload, compute its HMAC with a test secret, and assert three things: the verifier accepts the valid pair, rejects a mutated body, and rejects an expired timestamp. That test catches the raw-body regression — someone adding a JSON middleware or moving the parse above the verify — before it ships and silently 400s every event.

FAQ

Q: Can a Server Action be a webhook endpoint?

A: No. A Server Action is invoked through framework-generated identifiers and is designed for your own application's components. A webhook provider needs a stable public POST URL with raw-body access, which is a route handler.

Q: Do I still need bodyParser: false in the App Router?

A: No. That option configures Pages Router API routes. An App Router route handler leaves the body untouched until you call req.text(), req.json(), or req.formData().

Q: What should I return for event types I do not handle?

A: Return 200. A non-2xx response tells the provider the delivery failed, so it retries events you will never process, and some providers disable an endpoint that keeps failing. Reserve 400 for signature failures.

Q: What happens to events sent while my deployment was down?

A: Providers retry failed deliveries with backoff — Stripe retries for days — so short downtime is usually absorbed. For critical state I also run a periodic reconciliation job against the provider's API.

Q: How do I rotate a webhook secret without downtime?

A: Verify incoming signatures against both the old and the new secret during the rotation window. Providers like Stripe allow an old signing secret to stay active for an overlap period for exactly this reason.


Originally published on devya.dev. Also on eng-ahmed.com. Built by Devya Solutions.

Top comments (0)