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
- Using parsed JSON instead of raw body: Signature verification fails because the reconstructed JSON differs from the original
- Middleware consuming the body: Other middleware parses the request before your handler sees it
- 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:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 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:
- Duplicate processing: The same event may be processed multiple times
- Out-of-order delivery: Retries may arrive after later events
- 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.idas 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:
| Platform | Default Timeout |
|---|---|
| Vercel Hobby | 10 seconds |
| Vercel Pro | 60 seconds |
| AWS Lambda | 15 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
constructEventwith 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:
- Signature verification: Validate requests come from Stripe using raw body
- Idempotency: Handle retried events safely using
event.id - Timeouts: Respond quickly, process asynchronously if needed
- Event handling: Switch on
event.typefor business logic - 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.