DH
13 min read

Implementing Webhooks in Next.js 15 App Router

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

nextjswebhooks

Implementing Webhooks in Next.js 15 App Router

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. This guide walks through every layer: routing, signature verification, error handling, retry logic, idempotency, and local testing, with complete TypeScript code for the App Router.

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) {
const headersList = 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.


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, using a slightly extended format that includes the timestamp in the signed string — so you never need to manually call verifyWebhookSignature for Stripe events.

// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { headers } from 'next/headers';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
// Pin to the API version your code was written against.
// Check your Stripe Dashboard → Developers → API version for the latest.
apiVersion: '2023-10-16',
});

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

if (!signature) {
return NextResponse.json({ error: 'Missing stripe-signature header' }, { status: 400 });
}

let event: Stripe.Event;

try {
// req.text() preserves the raw body — required for Stripe's signature check
const rawBody = await req.text();

event = stripe.webhooks.constructEvent(
rawBody,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
console.error('Stripe webhook verification failed:', message);
return NextResponse.json(
{ error: `Webhook verification failed: ${message}` },
{ status: 400 }
);
}

// Dispatch on event type
try {
switch (event.type) {
case 'payment_intent.succeeded': {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
await handleSuccessfulPayment(paymentIntent);
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionCancelled(subscription);
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
await handleFailedInvoice(invoice);
break;
}
default:
// Log unhandled types — do NOT throw; return 200 so Stripe stops retrying
console.log(`Unhandled Stripe event type: ${event.type}`);
}

return NextResponse.json({ received: true });
} catch (err) {
console.error('Error processing Stripe event:', err);
// Return 500 intentionally — Stripe will retry this event
return NextResponse.json({ error: 'Processing failed' }, { status: 500 });
}
}

async function handleSuccessfulPayment(intent: Stripe.PaymentIntent) {
// Update your DB, send a confirmation email, provision access, etc.
console.log('Payment succeeded:', intent.id, intent.amount);
}

async function handleSubscriptionCancelled(sub: Stripe.Subscription) {
console.log('Subscription cancelled:', sub.id, sub.customer);
// Revoke user access, update subscription status in DB, etc.
}

async function handleFailedInvoice(invoice: Stripe.Invoice) {
console.log('Invoice payment failed:', invoice.id, invoice.customer);
// Notify the customer, flag the account for follow-up, etc.
}

apiVersion note: The string '2023-10-16' pins your integration to a known-good version. When you're ready to upgrade, test against your Stripe Dashboard's "Upgrade" preview, then bump the string and redeploy. The stripe-node SDK always accepts the version your account was created on plus any version you've explicitly tested.


Local Testing

You need a public URL to receive webhooks from services like Stripe during development. Two tools cover this well.

Option 1 — Stripe CLI (recommended for Stripe)

The Stripe CLI forwards events directly to your local server without exposing a public URL:

# Install (macOS example — see docs.stripe.com/stripe-cli for other platforms)
brew install stripe/stripe-cli/stripe

# Log in
stripe login

# Start forwarding to your local dev server
stripe listen --forward-to localhost:3000/api/webhooks/stripe

The CLI prints a webhook signing secret (starts with whsec_) — use that as STRIPE_WEBHOOK_SECRET in .env.local while developing. It's different from your production secret.

Trigger a test event in a second terminal:

stripe trigger payment_intent.succeeded

Option 2 — ngrok (any service)

ngrok tunnels your local port to a public HTTPS URL that any webhook sender can reach:

# Install ngrok (see ngrok.com/download)
ngrok http 3000

Copy the https://xxxx.ngrok-free.app URL and register it as your webhook endpoint in the third-party service's dashboard. Remember to update it each time ngrok restarts (unless you're on a paid plan with a static domain).

Checklist before going to production

  • STRIPE_WEBHOOK_SECRET (or equivalent) is set in your production environment variables — not the CLI's development secret.
  • The endpoint returns 200 within the provider's timeout window (30 s for Stripe).
  • Idempotency is implemented so duplicate deliveries are harmless.
  • Unhandled event types return 200, not 500, so the provider doesn't keep retrying them.
  • Signature verification runs before any business logic.
  • Audit logging captures every event ID, type, and outcome.

Frequently Asked Questions

Do I need a special Next.js config to read the raw body in route handlers? No. App Router route handlers receive a standard Web Request object. Call await req.text() and you get the raw string — no bodyParser: false config, no custom middleware, no req.pipe() gymnastics. This is one of the cleaner ergonomic improvements over the Pages Router.

What HTTP status should I return when I don't recognise an event type? Return 200. Returning 400 or 500 signals to the sender that delivery failed, which triggers retries. Since you'll never recognise that event type (it's intentionally unhandled), retrying it wastes both parties' resources. Log it and move on.

Can I use Edge Runtime for webhook handlers? With caution. The crypto module's timingSafeEqual is available in the Edge Runtime via the Web Crypto API (crypto.subtle), but the Node.js crypto import used in the examples above requires the Node.js runtime. Add export const runtime = 'nodejs' to your route file to be explicit, or rewrite the HMAC check using SubtleCrypto if you need the Edge Runtime.

How do I verify webhooks from GitHub? GitHub signs with HMAC-SHA256 using the secret you set when creating the webhook, and sends the signature in the x-hub-signature-256 header as sha256=<hex>. Strip the sha256= prefix before comparing with timingSafeEqual.

const githubSig = headersList.get('x-hub-signature-256') ?? '';
const hexSig = githubSig.replace('sha256=', '');
const isValid = verifyWebhookSignature(rawBody, hexSig, process.env.GITHUB_WEBHOOK_SECRET!);
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