- Stripe
- Webhooks
- Next.js
- Idempotency
- Production Engineering
- Payments
Mastering Stripe Webhook Idempotency: Preventing Duplication in Next.js Production
Stripe webhooks are critical for real-time updates in SaaS applications, but handling duplicate events and ensuring idempotency is a common production challenge.

If you're a technical founder or CTO building a Stripe-powered SaaS, you've likely encountered the complexities of processing webhook events. Specifically, the challenge of Stripe webhook duplication and ensuring idempotency in a production Next.js environment is a persistent headache. You've set up your API routes, verified signatures, and processed events, only to find duplicate charges, double-provisioned features, or inconsistent state due to retries or concurrent requests.
This isn't a theoretical problem; it's a production reality that can lead to customer frustration, manual refunds, and data integrity issues. While Stripe itself retries events and provides an idempotency_key for API calls, webhook events require your application to implement its own robust idempotency layer.
TL;DR
- Stripe webhooks can be delivered multiple times; your application must handle this.
- Implement robust signature verification to secure your webhook endpoint.
- Store every received webhook event in your database before processing.
- Use a combination of unique event IDs and transactional logic to ensure each event is processed only once.
- Guard against race conditions with database-level constraints or optimistic locking.
The Inevitable: Why Webhooks Duplicate
Stripe's webhook system is designed for reliability, which means it will retry sending events if your endpoint doesn't respond with a 2xx status code within a reasonable timeframe. This retry mechanism is a double-edged sword: it ensures delivery, but it also means your endpoint might receive the same event multiple times if a network hiccup or temporary processing delay prevents a timely 2xx response.
Beyond retries, other scenarios can lead to duplicates:
- Network Latency: A
2xxresponse might be sent but not received by Stripe, leading to a retry. - Concurrent Processing: If your webhook handler scales horizontally, multiple instances might pick up the same event if not properly coordinated.
- Manual Retries: You or a team member might manually retry a webhook from the Stripe dashboard.
Ignoring these scenarios is not an option for a production-grade application. Every checkout.session.completed, invoice.payment_succeeded, or customer.subscription.updated event must be processed exactly once.
Securing Your Webhook Endpoint: Signature Verification
Before diving into idempotency, securing your webhook endpoint is paramount. Any public endpoint is a target. Stripe signs its webhook events with a secret key, allowing you to verify that the request truly came from Stripe and hasn't been tampered with. This is your first line of defense.
In a Next.js App Router environment, your webhook handler might look something like this:
// app/api/stripe-webhooks/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { buffer } from 'node:stream/consumers';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-04-10',
});
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: NextRequest) {
const rawBody = await buffer(req.body!);
const signature = req.headers.get('stripe-signature');
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
rawBody,
signature!,
webhookSecret
);
} catch (err: any) {
console.error(`⚠️ Webhook signature verification failed.`, err.message);
return new NextResponse(`Webhook Error: ${err.message}`, { status: 400 });
}
// Handle the event (after verification)
try {
switch (event.type) {
case 'checkout.session.completed':
const checkoutSession = event.data.object as Stripe.Checkout.Session;
// Handle successful checkout, e.g., fulfill order, provision access
console.log(`Checkout session completed: ${checkoutSession.id}`);
break;
case 'invoice.payment_succeeded':
const invoice = event.data.object as Stripe.Invoice;
// Handle successful payment, e.g., update subscription status
console.log(`Invoice payment succeeded: ${invoice.id}`);
break;
// ... handle other event types
default:
console.warn(`Unhandled event type: ${event.type}`);
}
return new NextResponse(JSON.stringify({ received: true }), { status: 200 });
} catch (error) {
console.error(`Error processing webhook event ${event.id}:`, error);
// Return 500 to signal Stripe to retry, but be careful
return new NextResponse(`Webhook processing error`, { status: 500 });
}
}
Crucial Detail: buffer(req.body!) is essential for Next.js App Router as req.json() or req.text() consumes the stream, preventing constructEvent from verifying the raw body. This is a common pitfall.
The Idempotency Layer: Storing and Tracking Events
The core of idempotency lies in tracking which events you've already processed. The most robust approach is to store every incoming webhook event in your database before attempting any business logic. This creates a durable record and allows you to check for duplicates.
Database Schema for Webhook Events
You'll need a table to store these events. A minimal schema might look like this (using PostgreSQL syntax):
CREATE TABLE stripe_webhook_events (
id SERIAL PRIMARY KEY,
stripe_event_id VARCHAR(255) UNIQUE NOT NULL, -- Stripe's unique event ID
event_type VARCHAR(255) NOT NULL,
payload JSONB NOT NULL,
received_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
processed_at TIMESTAMP WITH TIME ZONE,
status VARCHAR(50) DEFAULT 'PENDING' NOT NULL, -- PENDING, PROCESSED, FAILED
error_message TEXT
);
CREATE INDEX idx_stripe_event_id ON stripe_webhook_events (stripe_event_id);
CREATE INDEX idx_event_type_status ON stripe_webhook_events (event_type, status);
The stripe_event_id column with a UNIQUE constraint is your idempotency key. If Stripe sends the same event twice, attempting to insert it again will fail at the database level, preventing duplicate processing.
The Processing Flow
- Receive and Verify: Your webhook endpoint receives the request and verifies the signature.
- Store Raw Event: Immediately attempt to insert the
stripe_event_idand the rawpayloadinto yourstripe_webhook_eventstable. If this insert fails due to a unique constraint violation, you know it's a duplicate event you've already seen. Acknowledge it with a200 OKand exit. - Process Event (Asynchronously): If the insert is successful, mark the event as
PENDING. Then, trigger your actual business logic. For complex or time-consuming operations, it's often best to offload this to a background job queue (e.g., using a dedicated worker, a message queue like Redis/Kafka, or a serverless function triggered by a database insert). This allows your webhook endpoint to respond quickly (200 OK), preventing Stripe retries, while ensuring robust processing. - Update Status: Once the business logic completes successfully, update the event's status to
PROCESSEDand setprocessed_at. If it fails, update toFAILEDand log theerror_messagefor later inspection or manual intervention.
Guarding Against Race Conditions and Concurrent Processing
Even with a UNIQUE constraint on stripe_event_id, race conditions can occur. Imagine two concurrent webhook requests for the same stripe_event_id arrive almost simultaneously. Both might try to insert, and one will succeed while the other fails. The successful one proceeds to process. What if the failed one also tries to process before the unique constraint kicks in?
This is where transactional processing becomes critical. When processing the event's business logic, wrap it in a database transaction. If you're using a background job, the job itself should operate within a transaction.
Consider an invoice.payment_succeeded event that grants a user credits:
// Example: Simplified processing function (would likely be in a background job)
import { db } from '@/lib/db'; // Your database client (e.g., Prisma)
async function processStripeInvoicePayment(eventId: string, invoice: Stripe.Invoice) {
return db.$transaction(async (prisma) => {
// 1. Check if event is already processed within this transaction
const existingEvent = await prisma.stripeWebhookEvent.findUnique({
where: { stripeEventId: eventId },
});
if (!existingEvent) {
// This should ideally not happen if event was inserted first
throw new Error(`Webhook event ${eventId} not found for processing.`);
}
if (existingEvent.status === 'PROCESSED') {
console.log(`Event ${eventId} already processed. Skipping.`);
return; // Already processed, idempotent exit
}
// 2. Perform business logic
const userId = invoice.metadata?.userId; // Assuming userId is in metadata
if (!userId) {
throw new Error(`User ID not found in invoice metadata for event ${eventId}`);
}
const amountPaid = invoice.amount_paid !== null ? invoice.amount_paid / 100 : 0; // in cents
const creditsToAdd = Math.floor(amountPaid / 10); // Example: 1 credit per $0.10
await prisma.user.update({
where: { id: userId },
data: {
credits: {
increment: creditsToAdd,
},
},
});
// 3. Mark event as processed
await prisma.stripeWebhookEvent.update({
where: { id: existingEvent.id },
data: {
status: 'PROCESSED',
processedAt: new Date(),
},
});
console.log(`Event ${eventId} processed successfully. User ${userId} granted ${creditsToAdd} credits.`);
});
}
This transactional approach ensures that either all operations within the db.$transaction block succeed, or none do. If a failure occurs mid-transaction, the entire operation is rolled back, and the stripe_webhook_event status remains PENDING (or FAILED if explicitly set), allowing for retry.
Optimistic Locking for Critical Resources
For highly contended resources, or if your database doesn't support immediate UNIQUE constraint checks in a way that prevents race conditions, you might consider optimistic locking. This involves adding a version column to your stripe_webhook_events table (or the target resource, like User credits).
When you read an event, you read its version. When you update it, you include the version in your WHERE clause:
UPDATE stripe_webhook_events SET status = 'PROCESSED', version = version + 1 WHERE id = ? AND version = ?;
If another process updated the row in the interim, the version would no longer match, and the update would fail, signaling a concurrency conflict that needs to be handled (e.g., by retrying the processing).
Error Handling and Retries
Even with robust idempotency, errors will happen. Your system needs to gracefully handle failures during processing:
- Return 500 on Processing Failure: If your business logic fails after signature verification and event storage, your webhook endpoint should return a
500status code. This signals Stripe to retry the event. Your idempotency layer will ensure that when the retry arrives, it's either skipped (if already processed) or re-processed from thePENDINGstate. - Monitor Failed Events: Regularly check your
stripe_webhook_eventstable for events withstatus = 'FAILED'. These require manual inspection and potentially re-triggering their processing. Good logging and alerting are crucial here. - Dead-Letter Queues: For critical systems, consider a dead-letter queue (DLQ) for events that repeatedly fail. This prevents poison messages from endlessly retrying and allows for detailed analysis.
Key Takeaways
- Signature verification is non-negotiable for security and authenticity.
- Store every webhook event with Stripe's
idas aUNIQUEkey to detect and prevent duplicates at the database level. - Process events asynchronously to keep your webhook endpoint fast and prevent Stripe retries due to timeouts.
- Use database transactions for your business logic to ensure atomicity and prevent partial updates.
- Implement a
statusfield (PENDING,PROCESSED,FAILED) in your event log for clear visibility and recovery. - Return
500for processing errors to leverage Stripe's retry mechanism, but ensure your idempotency layer handles these retries correctly.
Mastering Stripe webhook idempotency is a hallmark of a production-ready payments system. It separates robust, reliable applications from those prone to data inconsistencies and operational headaches. By following these principles, you can build a resilient system that confidently handles the ebb and flow of real-world payment events.
Ship this without the trial-and-error
Building out a bulletproof Stripe integration with robust webhook handling, usage-based billing, or a complex marketplace is where many engineering teams get stuck. At PolarSoftBD, we specialize in production payments and AI engineering, helping technical founders and CTOs navigate these exact challenges.
If you're grappling with Stripe Connect, complex subscription models, or need to ensure your payment infrastructure is truly production-grade, let's talk.
Explore our Stripe Integration Services Book a free strategy call
For more insights into advanced Stripe implementations, check out our article on Usage-Based Billing with Stripe and Next.js or dive into Stripe Connect Marketplace in Next.js.
Last updated October 31, 2026
Get thoughtful updates
Join our monthly digest for founders and builders.