Get Started

The Complete Guide to Stripe Webhooks in Next.js

Everything you need to know about Stripe webhooks in Next.js: signature verification, retry handling, idempotency, timeouts, and production debugging.

The Complete Guide to Stripe Webhooks in Next.js#

Stripe webhooks notify your application when events happen—payments succeed, subscriptions renew, disputes are opened. In Next.js, you handle these webhooks by creating API routes or route handlers that receive HTTP POST requests from Stripe.

This guide covers everything you need to know about Stripe webhook handling in Next.js, from basic setup to production reliability patterns.

Quick Start: Basic Stripe Webhook Handler#

Here’s a minimal Stripe webhook handler in Next.js 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(request: Request) {
  const body = await request.text();
  const signature = request.headers.get('stripe-signature')!;

  let event: Stripe.Event;

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

  // Handle the event
  switch (event.type) {
    case 'checkout.session.completed':
      const session = event.data.object as Stripe.Checkout.Session;
      // Fulfill the order
      break;
    case 'invoice.paid':
      // Handle successful payment
      break;
    case 'invoice.payment_failed':
      // Handle failed payment
      break;
  }

  return new Response('OK', { status: 200 });
}

This works for development, but production Stripe webhooks require careful handling of signatures, retries, idempotency, and timeouts. The rest of this guide covers these patterns.

Signature Verification#

Stripe signs every webhook payload with a signature header. You must verify this signature before processing events—otherwise attackers could forge webhook requests to your endpoint.

How Stripe Signatures Work#

Stripe uses HMAC-SHA256 to sign webhooks. The signature includes:

  • The timestamp when Stripe generated the webhook
  • The raw request body (exact bytes)
  • Your webhook signing secret

The Stripe SDK’s constructEvent method handles verification automatically, but it requires the raw request body—not a parsed JSON object.

Common Signature Verification Mistakes#

  1. Using parsed JSON instead of raw body: Signature verification fails because the reconstructed JSON differs from the original
  2. Middleware consuming the body: Other middleware parses the request before your handler sees it
  3. Wrong webhook secret: Using the test secret in production or vice versa

Stripe Webhook Signature Verification in Next.js - Complete guide to signature verification patterns

Accessing Raw Request Body - How to get raw body in different Next.js routing patterns

Stripe’s Retry Behavior#

When your webhook handler returns an error (non-2xx status), Stripe retries the webhook with exponential backoff:

AttemptDelay
1Immediate
2~1 hour
3~3 hours
4~7 hours
5+Up to 3 days total

Why Retries Happen#

Stripe considers these scenarios as failures that trigger retries:

  • 5xx responses: Server errors from your handler
  • 4xx responses (except 400): Client errors (though 400 with invalid signature is treated as permanent failure)
  • Timeouts: No response within Stripe’s timeout window
  • Connection failures: Network issues reaching your endpoint

The Problem with Retries#

Retries create several challenges:

  1. Duplicate processing: The same event may be processed multiple times
  2. Out-of-order delivery: Retries may arrive after later events
  3. Confusing logs: Hard to distinguish retry attempts from new events

Why Stripe Retries Your Webhooks (And When to Worry) - Understanding retry behavior and handling it correctly

Idempotency: Handling Duplicate Events#

Because Stripe retries failed webhooks, your handler must be idempotent—processing the same event multiple times should have the same effect as processing it once.

The Idempotency Pattern#

export async function POST(request: Request) {
  const event = await verifyStripeWebhook(request);

  // Check if we've already processed this event
  const processed = await db.query.processedEvents.findFirst({
    where: eq(processedEvents.stripeEventId, event.id)
  });

  if (processed) {
    // Already processed - return success without reprocessing
    return new Response('OK', { status: 200 });
  }

  // Process the event within a transaction
  await db.transaction(async (tx) => {
    // Do your business logic
    await handleCheckoutCompleted(event, tx);

    // Record that we processed this event
    await tx.insert(processedEvents).values({
      stripeEventId: event.id,
      eventType: event.type,
      processedAt: new Date()
    });
  });

  return new Response('OK', { status: 200 });
}

Key Points#

  • Use event.id as your idempotency key (it’s unique per event)
  • Check before processing, not after
  • Use database transactions to ensure atomicity
  • Return 200 for already-processed events (don’t make Stripe retry)

Stripe Webhook Idempotency in Next.js - Complete idempotency patterns with database examples

Timeouts in Serverless Environments#

Stripe expects webhook responses within 20 seconds. Serverless platforms have their own limits:

PlatformDefault Timeout
Vercel Hobby10 seconds
Vercel Pro60 seconds
AWS Lambda15 seconds (configurable)

If your webhook processing exceeds these limits, the handler terminates and Stripe retries.

The Async Processing Pattern#

For complex processing, acknowledge the webhook immediately and process asynchronously:

export async function POST(request: Request) {
  const event = await verifyStripeWebhook(request);

  // Quick validation - can we handle this event?
  if (!supportedEventTypes.includes(event.type)) {
    return new Response('Ignored', { status: 200 });
  }

  // Queue for async processing
  await queue.add('stripe-webhook', {
    eventId: event.id,
    type: event.type,
    data: event.data.object
  });

  // Respond immediately - within timeout
  return new Response('Queued', { status: 200 });
}

Fix Stripe Webhook Timeouts on Vercel and Edge Functions - Patterns for handling long-running webhook processing

Common Stripe Webhook Events#

Here are the most commonly handled Stripe webhook events:

Checkout & Payments#

switch (event.type) {
  case 'checkout.session.completed':
    // Customer completed checkout - fulfill order
    break;
  case 'payment_intent.succeeded':
    // Payment successful
    break;
  case 'payment_intent.payment_failed':
    // Payment failed - notify customer
    break;
}

Subscriptions#

switch (event.type) {
  case 'customer.subscription.created':
    // New subscription - provision access
    break;
  case 'customer.subscription.updated':
    // Plan changed - update access level
    break;
  case 'customer.subscription.deleted':
    // Subscription cancelled - revoke access
    break;
  case 'invoice.paid':
    // Subscription renewed successfully
    break;
  case 'invoice.payment_failed':
    // Renewal failed - warn customer
    break;
}

Disputes#

switch (event.type) {
  case 'charge.dispute.created':
    // Customer disputed charge - gather evidence
    break;
  case 'charge.dispute.closed':
    // Dispute resolved
    break;
}

App Router vs Pages Router#

App Router (Next.js 13+)#

// app/api/webhooks/stripe/route.ts
export async function POST(request: Request) {
  const body = await request.text();
  const signature = request.headers.get('stripe-signature')!;
  // ...
}

Pages Router#

// pages/api/webhooks/stripe.ts
import { buffer } from 'micro';

export const config = {
  api: { bodyParser: false }
};

export default async function handler(req, res) {
  const body = (await buffer(req)).toString();
  const signature = req.headers['stripe-signature'];
  // ...
}

Stripe Webhooks with Next.js App Router - App Router specific patterns

Debugging Stripe Webhook Failures#

When Stripe webhooks fail, debugging is difficult:

  • Stripe’s dashboard shows delivery status but not your handler’s internal errors
  • Serverless logs may not capture errors that happen during timeouts
  • No easy way to replay events after fixing issues

Stripe’s Webhook Testing Tools#

  • Stripe CLI: stripe listen --forward-to localhost:3000/api/webhooks/stripe
  • Stripe Dashboard: Send test webhooks from the webhook endpoint settings
  • Webhook logs: View delivery attempts and response codes in Stripe Dashboard

When Debugging Becomes Painful#

For applications where webhook reliability matters, consider dedicated webhook infrastructure. HookRelay provides:

  • Complete delivery history: Every attempt, response code, and timing
  • Failure visibility: See exactly why processing failed
  • Replay capability: Re-send webhooks after fixing issues
  • Separate from Stripe retries: Debug without waiting for Stripe’s retry schedule

Production Checklist#

Before going live with Stripe webhooks:

  • Signature verification: Using constructEvent with raw body
  • Idempotency: Handler safely processes duplicate events
  • Timeout handling: Processing completes within platform limits
  • Error responses: Return appropriate status codes (2xx for success, 4xx/5xx for failures)
  • Logging: Track event IDs and processing results
  • Monitoring: Alert on webhook failures
  • Live endpoint: Register production webhook URL in Stripe Dashboard

Summary#

Stripe webhook handling in Next.js involves:

  1. Signature verification: Validate requests come from Stripe using raw body
  2. Idempotency: Handle retried events safely using event.id
  3. Timeouts: Respond quickly, process asynchronously if needed
  4. Event handling: Switch on event.type for business logic
  5. Debugging: Use Stripe CLI locally, consider dedicated infrastructure for production

For production applications where webhook reliability matters, see how HookRelay tracks delivery attempts and makes failures visible.

Reliable Stripe Webhook Delivery

Track delivery attempts, inspect failures, and replay events without rebuilding infrastructure

Prefer to click around first? Open the dashboard.

Learn more