DH
14 min read

Implementing Webhooks in Next.js 15 App Router

Discover practical strategies for implementing efficient Next.js webhook solutions.

nextjswebhooks

Webhooks let your Next.js app react to external events the moment they happen — a payment succeeds, a subscription is cancelled, a CMS publishes new content — without polling. A flaky webhook handler is one of those bugs that looks fine in dev and then quietly double-charges someone in production, so this guide is deliberately thorough: routing, signature verification, replay protection, retry logic, idempotency, a complete Stripe example, and how to actually test the thing locally before you ship it. All code targets the App Router on Next.js 15.

What You'll Build

LayerWhat we cover
Routingapp/api/webhooks/route.ts with raw-body access
SecurityHMAC-SHA256 signature verification + timestamp replay protection
ReliabilityExponential backoff retry with jitter
IdempotencyDeduplication via stored event IDs
Real-world exampleFull Stripe webhook handler
Local testingStripe CLI + ngrok workflows

Understanding Webhooks

A webhook is an HTTP POST request that a third-party service sends to your server when something noteworthy happens. You register a public URL (the webhook endpoint), and the service calls it with a JSON body describing the event.

// Example webhook payload — payment success
{
"event": "payment.success",
"data": {
"orderId": "order_123",
"amount": 99.99,
"currency": "USD",
"status": "completed",
"timestamp": "2024-01-05T12:00:00Z"
}
}

Why not polling? Polling burns requests asking "anything new?" on a fixed schedule. Webhooks flip that: the service calls you, so your app only does work when there's something to do.

Common use cases in Next.js

  • Payment processing — Stripe, Paddle, or Lemon Squeezy fire events for payment_intent.succeeded, subscription.deleted, refunds, disputes.
  • CMS publishing — Contentful, Sanity, and Payload CMS can trigger a webhook to revalidate Next.js cache when content changes.
  • Repository events — GitHub sends push, pull_request, and release events that CI pipelines consume.
  • Communication — Twilio and SendGrid push delivery receipts and inbound messages via webhook.

Setting Up the Webhook Endpoint

In the App Router, every file at app/api/**/route.ts becomes an HTTP endpoint. The key constraint for webhooks: you need the raw request body as a string or Buffer before JSON-parsing it, because signature verification hashes the exact bytes that arrived.

Project prerequisites

# Install dependencies used in this guide
npm install stripe
npm install --save-dev @types/node

Add your secrets to .env.local:

STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
WEBHOOK_SECRET=your_generic_webhook_secret

Basic webhook route

// app/api/webhooks/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { headers } from 'next/headers';
import { verifyWebhookSignature } from '@/utils/webhook';

export async function POST(req: NextRequest) {
// headers() is async as of Next.js 15 — you must await it.
const headersList = await headers();
const signature = headersList.get('x-webhook-signature') ?? '';

// Read as text to preserve raw bytes for signature verification
const rawBody = await req.text();

const isValid = verifyWebhookSignature(
rawBody,
signature,
process.env.WEBHOOK_SECRET!
);

if (!isValid) {
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 401 }
);
}

try {
const body = JSON.parse(rawBody);
console.log('Received webhook event:', body.event);

await processWebhookData(body);

return NextResponse.json({ success: true });
} catch (error) {
console.error('Webhook processing error:', error);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}

async function processWebhookData(body: Record<string, unknown>) {
// Dispatch to your event handlers here
}

App Router vs Pages Router: In the Pages Router you had to disable the built-in body parser with export const config = { api: { bodyParser: false } } and then read req as a stream. The App Router uses the Web Request API — there is no equivalent bodyParser config at all. Simply call await req.text() before JSON.parse() and you get the raw bytes intact, no extra config or middleware required.

Next.js 15 gotcha: headers() (and cookies()) became async in Next.js 15 — they now return a Promise instead of the value directly. Code written for Next.js 13/14 that calls headers() synchronously still runs today for backwards compatibility, but it's deprecated and will break in a future major version. Always await headers() in new code.


Implementing Webhook Security

A public HTTP endpoint that triggers side-effects is an attractive target. Three layers of defence cover the main attack vectors.

1. HMAC-SHA256 signature verification

The sending service signs the raw payload with a shared secret using HMAC-SHA256. You recompute the signature server-side and compare. If they don't match, the request didn't come from the expected sender.

// utils/webhook.ts
import crypto from 'crypto';

export function verifyWebhookSignature(
payload: string,
signature: string,
secret: string
): boolean {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');

// timingSafeEqual prevents timing-based signature oracle attacks
try {
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
} catch {
// Buffers of different lengths throw — treat as invalid
return false;
}
}

Why timingSafeEqual? A naive === comparison short-circuits as soon as bytes differ, leaking timing information an attacker can exploit to forge signatures one byte at a time. timingSafeEqual always runs in constant time regardless of where the strings diverge, making it impossible to iteratively brute-force the correct signature via response latency.

2. Timestamp / replay-attack protection

Even a valid signature can be replayed if an attacker captures a legitimate request and re-sends it later. Include a timestamp in the signed payload and reject requests outside a tolerance window (typically 5 minutes).

// utils/webhook.ts (extended)
export function verifyTimestamp(
timestamp: string,
toleranceSeconds = 300
): boolean {
const requestTime = new Date(timestamp).getTime();
const now = Date.now();
return Math.abs(now - requestTime) < toleranceSeconds * 1000;
}

Call both checks before processing:

const { timestamp, ...rest } = body;

if (!verifyTimestamp(timestamp as string)) {
return NextResponse.json(
{ error: 'Request expired' },
{ status: 400 }
);
}

3. Supporting defences

DefenceImplementation
HTTPS onlyVercel and most hosts enforce TLS. Never accept webhooks over plain HTTP in production.
IP allowlistingSome services (Stripe, GitHub) publish their IP ranges. Add a middleware check if your infra supports it.
Rate limitingUse Vercel's Edge Middleware or an upstream proxy (Cloudflare) to cap requests per minute per IP.
Audit loggingLog every webhook attempt — signature pass/fail, event type, processing outcome — to aid debugging.

Error Handling and Retry Logic

Webhook senders expect a 2xx within their timeout window (Stripe's default is 30 seconds). If they get anything else — or no response — they retry. Your handler should:

  1. Respond 200 immediately when the payload is valid, even if processing is still in-flight.
  2. Offload heavy work to a background queue so you don't race the timeout.
  3. Implement idempotency so retried events don't create duplicate side-effects.

Common failure modes

CauseSymptomFix
Network timeoutSender retries; handler runs twiceIdempotency keys (store processed event IDs in DB)
Unhandled event typeUnhandled switch branch throwsAdd a default case that logs and returns 200
Database connection error500 response; sender retriesRetry logic with backoff inside the handler
Payload parse errorJSON.parse throwsWrap in try/catch; return 400
Signature mismatchReject with 401Check secret env var matches the sender's configured secret

Exponential backoff with jitter

When your handler calls downstream services (a database, an email API) that may fail transiently, wrap those calls in retries — not the entire webhook response. The jitter spreads retry storms across time, preventing every failed call from hammering the downstream service at the same millisecond.

// utils/webhook-processor.ts
interface RetryOptions {
maxRetries?: number;
backoffFactor?: number;
initialDelay?: number; // ms
}

export async function withRetry<T>(
fn: () => Promise<T>,
options: RetryOptions = {}
): Promise<T> {
const {
maxRetries = 3,
backoffFactor = 2,
initialDelay = 1000,
} = options;

let delay = initialDelay;

for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === maxRetries) throw error;

// Jitter: add up to 1 s of random noise to prevent thundering herd
const jitter = Math.random() * 1000;
const wait = delay + jitter;

console.warn(`Attempt ${attempt} failed. Retrying in ${Math.round(wait)}ms…`);
await new Promise(resolve => setTimeout(resolve, wait));

delay *= backoffFactor;
}
}

// TypeScript: unreachable, but keeps the return type happy
throw new Error('withRetry exhausted');
}

Usage inside your webhook handler:

await withRetry(() => db.orders.update({ where: { id: orderId }, data: { status: 'paid' } }), {
maxRetries: 3,
initialDelay: 500,
});

Idempotency pattern

Webhook senders retry on any non-2xx response, and occasionally retry even after receiving a 2xx (e.g., due to network errors on their side). Without idempotency, a single successful payment can trigger two database writes, two confirmation emails, or two provisioning calls.

The safest approach is a two-step deduplication: check for the event ID before processing, then record it before doing any work. Using a database transaction or an upsert with a unique constraint on eventId prevents a rare race condition where two concurrent retries slip through simultaneously.

// utils/idempotency.ts
import { db } from '@/lib/db'; // your Prisma/Drizzle/etc. client

export async function processOnce(
eventId: string,
handler: () => Promise<void>
): Promise<{ duplicate: boolean }> {
// Attempt to create the record — unique constraint on eventId will throw on duplicate
try {
await db.webhookEvents.create({
data: {
eventId,
processedAt: new Date(),
status: 'processing',
},
});
} catch (err: unknown) {
// Unique constraint violation — already processed (or in-flight)
const isUniqueViolation =
err instanceof Error && err.message.includes('Unique constraint');
if (isUniqueViolation) {
return { duplicate: true };
}
throw err;
}

// Safe to process — no duplicate
await handler();

await db.webhookEvents.update({
where: { eventId },
data: { status: 'processed' },
});

return { duplicate: false };
}

Use it in your route handler:

const { duplicate } = await processOnce(body.id, async () => {
await handleSuccessfulPayment(body.data.object);
});

if (duplicate) {
console.log(`Duplicate event ignored: ${body.id}`);
}

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

Schema tip: Add a unique index on eventId and a processedAt timestamp. Periodically prune records older than your provider's maximum retry window (Stripe retries for up to 3 days; GitHub retries for 3 days too) to keep the table lean.


Stripe Webhook Handler — Complete Example

Stripe is the most common webhook integration for Next.js apps. Their SDK handles signature verification internally via stripe.webhooks.constructEvent, using a slightly extended format that includes the timestamp in the signed string — so you never need to manually call verifyWebhookSignature for Stripe events. constructEvent throws if the signature or timestamp doesn't check out, so a single try/catch covers both checks.

// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
import { NextResponse } from 'next/server';
import { headers } from 'next/headers';
import { processOnce } from '@/utils/idempotency';

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

export async function POST(req: Request) {
let event: Stripe.Event;

try {
// headers() is async in Next.js 15 — await it before calling .get()
const signature = (await headers()).get('stripe-signature');

event = stripe.webhooks.constructEvent(
await req.text(),
signature as string,
process.env.STRIPE_WEBHOOK_SECRET as string
);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
console.error(`❌ Webhook signature verification failed: ${errorMessage}`);
return NextResponse.json(
{ message: `Webhook Error: ${errorMessage}` },
{ status: 400 }
);
}

// Only handle the events you actually act on — everything else falls through
const permittedEvents: string[] = [
'checkout.session.completed',
'payment_intent.succeeded',
'payment_intent.payment_failed',
];

if (permittedEvents.includes(event.type)) {
try {
const { duplicate } = await processOnce(event.id, async () => {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
console.log(`💰 Checkout session completed: ${session.payment_status}`);
// e.g. fulfill the order, provision access, send a receipt email
break;
}
case 'payment_intent.succeeded': {
const intent = event.data.object as Stripe.PaymentIntent;
console.log(`💰 PaymentIntent succeeded: ${intent.id}`);
break;
}
case 'payment_intent.payment_failed': {
const intent = event.data.object as Stripe.PaymentIntent;
console.log(`❌ Payment failed: ${intent.last_payment_error?.message}`);
break;
}
default:
console.warn(`Unhandled event type: ${event.type}`);
}
});

if (duplicate) {
console.log(`Duplicate Stripe event ignored: ${event.id}`);
}
} catch (error) {
// Return 500 so Stripe retries — this is a processing failure, not a bad signature
console.error('Error processing Stripe event:', error);
return NextResponse.json(
{ message: 'Webhook handler failed' },
{ status: 500 }
);
}
}

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

This mirrors the structure of Stripe's own official Next.js webhook example, with the idempotency wrapper and 500-on-processing-failure added so Stripe's built-in retry logic actually helps you instead of silently masking dropped events.


Testing Webhooks Locally

You can't POST to localhost:3000 from Stripe's servers, so local testing means either forwarding events through a CLI tool or exposing your dev server with a tunnel.

Option 1: Stripe CLI (recommended for Stripe integrations)

The Stripe CLI forwards real Stripe events to your local server over an authenticated connection — no public URL required.

# Install (macOS example; see Stripe's docs for Linux/Windows)
brew install stripe/stripe-cli/stripe

# Authenticate
stripe login

# Forward events to your local route and print the webhook signing secret
stripe listen --forward-to localhost:3000/api/webhooks/stripe

stripe listen prints a whsec_... value the first time you run it — use that as STRIPE_WEBHOOK_SECRET in .env.local for local testing (it's different from your production signing secret). Trigger a specific event without going through checkout:

stripe trigger payment_intent.succeeded

Watch your next dev terminal — you should see the console.log output from the handler, and the Stripe CLI terminal will show the response status your route returned.

Option 2: ngrok (or Vercel preview deployments) for anything else

Not every provider has a CLI like Stripe's. For GitHub, Contentful, Sanity, Twilio, etc., tunnel your local server through a public HTTPS URL:

# Install ngrok, then:
ngrok http 3000

Copy the https://<random>.ngrok-free.app URL ngrok prints and register https://<random>.ngrok-free.app/api/webhooks as the webhook endpoint in the provider's dashboard. Requests hit ngrok's edge, tunnel to your machine, and land on next dev exactly like a production request would — including real signatures from the provider, so you're testing the actual verification path, not a mock.

A couple of things that trip people up here:

  • The tunnel URL changes every time you restart ngrok on the free tier, so you'll need to update the webhook URL in the provider's dashboard each session (or pin a static domain on a paid ngrok plan).
  • next dev's fast refresh can drop an in-flight request if you edit the route file while a webhook is mid-flight. If you see intermittent failures only in dev, retry before assuming your handler is broken.

Summary

A production-ready Next.js webhook handler needs the raw body (not the parsed JSON) for signature verification, HMAC + timestamp checks to stop forged and replayed requests, a fast 200 response with heavy work offloaded, idempotency keyed on the sender's event ID, and a local testing loop via the Stripe CLI or a tunnel like ngrok. Skipping any one of these tends to surface later as a hard-to-debug production incident — usually a duplicate charge or a silently dropped event — rather than an obvious bug.

FAQ

Do I need to disable Next.js's body parser like in the Pages Router? No. The App Router's Route Handlers use the Web Request API, which has no built-in body-parsing middleware to disable. Just call await req.text() to get the raw body before parsing it as JSON.

Why does my signature verification fail even though the secret is correct? The most common cause is parsing the body to JSON and then re-serializing it before hashing — JSON.stringify(JSON.parse(rawBody)) is not guaranteed to produce byte-identical output to the original payload (key order, whitespace, number formatting can all differ). Always hash the raw text/Buffer you received, not a re-serialized copy.

Is headers() still synchronous in Next.js 15? It still works without await for backwards compatibility, but it's deprecated — headers() (and cookies()) return a Promise in Next.js 15 and you should await them in new code.

How long should I keep processed webhook event IDs for idempotency? Long enough to cover the sender's maximum retry window — Stripe and GitHub both retry for up to 3 days — plus some buffer. Prune older records on a schedule so the table doesn't grow unbounded.

Damian Hodgkiss

Damian Hodgkiss

Senior Staff Engineer at Sumo Group, leading development of AppSumo marketplace. Technical solopreneur with 25+ years of experience building SaaS products.

Creating Freedom

Join me on the journey from engineer to solopreneur. Learn how to build profitable SaaS products while keeping your technical edge.

    Proven strategies

    Learn the counterintuitive ways to find and validate SaaS ideas

    Technical insights

    From choosing tech stacks to building your MVP efficiently

    Founder mindset

    Transform from engineer to entrepreneur with practical steps