osmoto.
Case StudiesBlogBook Consultation

Services

Stripe IntegrationSubscription BillingPayment Automation & AINext.js OptimizationAudit & Fix

Solutions

For FoundersFor SaaS CompaniesFor E-Commerce StoresFor Marketplaces

Resources

Implementation GuideWebhook Best PracticesPCI Compliance GuideStripe vs Alternatives
Case StudiesBlog
Book Consultation
osmoto.

Professional Stripe integration services

Services

  • Stripe Integration
  • Subscription Billing
  • E-Commerce Integration
  • Next.js Optimization
  • Audit & Fix

Solutions

  • For Founders
  • For SaaS
  • For E-Commerce
  • For Marketplaces
  • Integration as a Service

Resources

  • Implementation Guide
  • Webhook Guide
  • PCI Compliance
  • Stripe vs Alternatives

Company

  • About
  • Case Studies
  • Process
  • Pricing
  • Contact
© 2026 Osmoto · Professional Stripe Integration Services
Back to Blog
Stripe Integration13 min read

How to Handle Stripe Webhook Retries Without Duplicate Orders

Picture this: your e-commerce store processes a $500 order, but due to a temporary network glitch, your webhook endpoint times out. Stripe automatically retries...

Osmoto Team

Senior Software Engineer

January 28, 2026
How to Handle Stripe Webhook Retries Without Duplicate Orders

Picture this: your e-commerce store processes a $500 order, but due to a temporary network glitch, your webhook endpoint times out. Stripe automatically retries the webhook, and your system processes the same order again—now you've charged your customer twice and fulfilled duplicate orders. This scenario plays out more often than you'd think, and it can quickly erode customer trust and create operational nightmares.

Stripe's webhook retry mechanism is designed to ensure reliable event delivery, but without proper idempotency handling, these retries can create duplicate orders, double charges, and inconsistent data states. The challenge isn't just technical—it's about building a system that maintains data integrity while gracefully handling the inevitable network failures and service interruptions.

In this guide, we'll dive deep into implementing robust webhook retry handling that prevents duplicate orders while maintaining system reliability. You'll learn how to build idempotent webhook processors, handle edge cases like partial failures, and implement monitoring that catches issues before they impact customers.

Understanding Stripe's Webhook Retry Behavior

Stripe's webhook retry logic follows an exponential backoff pattern with specific timing intervals. When your endpoint returns a non-2xx status code or times out (after 30 seconds), Stripe will retry the webhook up to 3 times over the following schedule:

  • Immediate retry: Within 1 minute
  • Second retry: After approximately 5 minutes
  • Third retry: After approximately 30 minutes
  • Final attempt: After approximately 2 hours

This retry pattern means your webhook handler might receive the same event multiple times, potentially hours apart. Each retry contains identical event data, including the same id field, which becomes crucial for implementing proper deduplication.

Why Default Handling Fails

Most developers initially implement webhook handlers that directly process events without considering retries:

// ❌ Problematic implementation export async function POST(request: Request) { const event = await stripe.webhooks.constructEvent( body, signature, process.env.STRIPE_WEBHOOK_SECRET ); if (event.type === 'payment_intent.succeeded') { const paymentIntent = event.data.object; // This will run multiple times for the same event! await createOrder({ amount: paymentIntent.amount, customerId: paymentIntent.customer, paymentIntentId: paymentIntent.id }); await sendConfirmationEmail(paymentIntent.customer); } return new Response('OK'); }

This approach creates several problems:

  • Duplicate orders when retries occur
  • Multiple confirmation emails sent to customers
  • Inventory miscounts from processing the same sale repeatedly
  • Revenue reporting errors from duplicate transactions

Implementing Event-Based Idempotency

The most reliable approach to handling webhook retries is implementing idempotency at the event level. Each Stripe event has a unique id that remains constant across all retry attempts, making it perfect for deduplication.

Database Schema for Event Tracking

First, create a table to track processed webhook events:

CREATE TABLE stripe_webhook_events ( id VARCHAR(255) PRIMARY KEY, event_type VARCHAR(100) NOT NULL, processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, processing_result TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_event_type (event_type), INDEX idx_processed_at (processed_at) );

This schema captures essential information for debugging and monitoring while keeping the primary key as Stripe's event ID for efficient lookups.

Idempotent Webhook Handler Implementation

Here's a robust webhook handler that prevents duplicate processing:

import { headers } from 'next/headers'; import { NextRequest } from 'next/server'; import Stripe from 'stripe'; import { db } from '@/lib/database'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); export async function POST(request: NextRequest) { try { const body = await request.text(); const signature = headers().get('stripe-signature')!; // Verify webhook signature const event = stripe.webhooks.constructEvent( body, signature, process.env.STRIPE_WEBHOOK_SECRET! ); // Check if we've already processed this event const existingEvent = await db.stripeWebhookEvent.findUnique({ where: { id: event.id } }); if (existingEvent) { console.log(`Event ${event.id} already processed, skipping`); return new Response('OK - Already processed'); } // Process the event within a transaction await db.$transaction(async (tx) => { // Record that we're processing this event await tx.stripeWebhookEvent.create({ data: { id: event.id, eventType: event.type, processingResult: 'processing' } }); // Process the actual event await processStripeEvent(event, tx); // Update the processing result await tx.stripeWebhookEvent.update({ where: { id: event.id }, data: { processingResult: 'completed' } }); }); return new Response('OK'); } catch (error) { console.error('Webhook processing failed:', error); // Update processing result if event was recorded if (error.eventId) { await db.stripeWebhookEvent.update({ where: { id: error.eventId }, data: { processingResult: `failed: ${error.message}` } }); } return new Response('Internal Server Error', { status: 500 }); } }

Event Processing Logic

The processStripeEvent function handles the actual business logic while maintaining transactional integrity:

async function processStripeEvent(event: Stripe.Event, tx: any) { switch (event.type) { case 'payment_intent.succeeded': await handlePaymentSuccess(event.data.object as Stripe.PaymentIntent, tx); break; case 'invoice.payment_succeeded': await handleInvoicePayment(event.data.object as Stripe.Invoice, tx); break; case 'customer.subscription.updated': await handleSubscriptionUpdate(event.data.object as Stripe.Subscription, tx); break; default: console.log(`Unhandled event type: ${event.type}`); } } async function handlePaymentSuccess(paymentIntent: Stripe.PaymentIntent, tx: any) { // Check if order already exists (additional safety check) const existingOrder = await tx.order.findUnique({ where: { paymentIntentId: paymentIntent.id } }); if (existingOrder) { console.log(`Order for payment intent ${paymentIntent.id} already exists`); return; } // Create the order const order = await tx.order.create({ data: { paymentIntentId: paymentIntent.id, customerId: paymentIntent.customer as string, amount: paymentIntent.amount, currency: paymentIntent.currency, status: 'confirmed' } }); // Handle inventory updates await updateInventoryForOrder(order, tx); // Queue email notification (don't send immediately to avoid blocking) await tx.emailQueue.create({ data: { type: 'order_confirmation', recipient: paymentIntent.receipt_email, orderId: order.id } }); }

Handling Partial Failures and Recovery

Even with idempotent event tracking, you need to handle scenarios where processing partially succeeds. For example, the order might be created successfully, but the inventory update fails.

Implementing Processing Stages

Track processing stages to enable partial recovery:

interface ProcessingStage { stage: string; completed: boolean; error?: string; } async function handlePaymentSuccessWithStages( paymentIntent: Stripe.PaymentIntent, tx: any, eventId: string ) { const stages: ProcessingStage[] = []; try { // Stage 1: Create order stages.push({ stage: 'create_order', completed: false }); const order = await createOrderSafely(paymentIntent, tx); stages[0].completed = true; // Stage 2: Update inventory stages.push({ stage: 'update_inventory', completed: false }); await updateInventoryForOrder(order, tx); stages[1].completed = true; // Stage 3: Queue notifications stages.push({ stage: 'queue_notifications', completed: false }); await queueOrderNotifications(order, tx); stages[2].completed = true; // Record successful completion await tx.stripeWebhookEvent.update({ where: { id: eventId }, data: { processingResult: JSON.stringify({ status: 'completed', stages }) } }); } catch (error) { // Record partial completion state const failedStage = stages.find(s => !s.completed); if (failedStage) { failedStage.error = error.message; } await tx.stripeWebhookEvent.update({ where: { id: eventId }, data: { processingResult: JSON.stringify({ status: 'partial_failure', stages, error: error.message }) } }); throw error; } }

Recovery Job Implementation

Create a background job to retry partially failed events:

// Background job to retry failed webhook processing export async function retryFailedWebhooks() { const failedEvents = await db.stripeWebhookEvent.findMany({ where: { processingResult: { contains: 'partial_failure' }, createdAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) // Last 24 hours } } }); for (const eventRecord of failedEvents) { try { const processingData = JSON.parse(eventRecord.processingResult); const incompletedStages = processingData.stages.filter(s => !s.completed); if (incompletedStages.length > 0) { // Fetch the original event from Stripe const event = await stripe.events.retrieve(eventRecord.id); // Retry only the failed stages await retryEventStages(event, incompletedStages); // Mark as completed await db.stripeWebhookEvent.update({ where: { id: eventRecord.id }, data: { processingResult: JSON.stringify({ status: 'completed_on_retry', originalFailure: processingData, retriedAt: new Date() }) } }); } } catch (error) { console.error(`Failed to retry event ${eventRecord.id}:`, error); } } }

Advanced Idempotency Patterns

For complex business logic, you might need more sophisticated idempotency patterns beyond simple event deduplication.

Operation-Level Idempotency Keys

Some operations benefit from custom idempotency keys that combine multiple factors:

function generateIdempotencyKey( eventId: string, operation: string, entityId: string ): string { return `${eventId}:${operation}:${entityId}`; } async function processSubscriptionUpdate( subscription: Stripe.Subscription, tx: any, eventId: string ) { const idempotencyKey = generateIdempotencyKey( eventId, 'subscription_update', subscription.id ); // Check if this specific operation was already completed const existingOperation = await tx.idempotentOperation.findUnique({ where: { key: idempotencyKey } }); if (existingOperation) { console.log(`Operation ${idempotencyKey} already completed`); return; } // Record the operation await tx.idempotentOperation.create({ data: { key: idempotencyKey, eventId: eventId, operation: 'subscription_update', entityId: subscription.id, status: 'processing' } }); // Perform the actual update await updateSubscriptionInDatabase(subscription, tx); // Mark as completed await tx.idempotentOperation.update({ where: { key: idempotencyKey }, data: { status: 'completed' } }); }

Time-Based Deduplication Windows

For high-frequency events, implement time-based deduplication to prevent processing the same logical operation multiple times within a short window:

async function processWithTimeWindow( event: Stripe.Event, windowMinutes: number = 5 ) { const windowStart = new Date(Date.now() - windowMinutes * 60 * 1000); const recentEvents = await db.stripeWebhookEvent.findMany({ where: { eventType: event.type, createdAt: { gte: windowStart }, processingResult: 'completed' } }); // Check for duplicate operations within the time window const isDuplicate = recentEvents.some(e => isSameLogicalOperation(e, event) ); if (isDuplicate) { console.log(`Duplicate operation detected within ${windowMinutes} minute window`); return; } // Process normally await processStripeEvent(event); } function isSameLogicalOperation( existingEvent: any, newEvent: Stripe.Event ): boolean { // Define your logic for detecting duplicate operations // This could compare customer IDs, amounts, product IDs, etc. if (newEvent.type === 'payment_intent.succeeded') { const newPI = newEvent.data.object as Stripe.PaymentIntent; const existingData = JSON.parse(existingEvent.eventData || '{}'); return existingData.customer === newPI.customer && existingData.amount === newPI.amount; } return false; }

Common Pitfalls and Edge Cases

Race Conditions in High-Traffic Scenarios

When processing high volumes of webhooks, you might encounter race conditions where multiple webhook instances try to process the same event simultaneously:

// Use database-level constraints to prevent race conditions async function processEventSafely(event: Stripe.Event) { try { await db.stripeWebhookEvent.create({ data: { id: event.id, eventType: event.type, processingResult: 'processing' } }); // If we reach here, we won the race to process this event await processStripeEvent(event); } catch (error) { if (error.code === 'P2002') { // Unique constraint violation console.log(`Event ${event.id} is already being processed by another instance`); return new Response('OK - Processing by another instance'); } throw error; } }

Handling Clock Skew and Event Ordering

Stripe events might arrive out of order due to network conditions or clock skew. Implement logic to handle this gracefully:

async function handleSubscriptionEvents(event: Stripe.Event) { const subscription = event.data.object as Stripe.Subscription; // Get the latest known state of this subscription const latestEvent = await db.stripeWebhookEvent.findFirst({ where: { eventType: { startsWith: 'customer.subscription' }, // Extract subscription ID from event data processingResult: { contains: subscription.id } }, orderBy: { createdAt: 'desc' } }); if (latestEvent) { const latestEventTime = new Date(latestEvent.createdAt); const currentEventTime = new Date(event.created * 1000); if (currentEventTime < latestEventTime) { console.log(`Received out-of-order event ${event.id}, skipping`); return; } } // Process the event normally await processSubscriptionUpdate(subscription); }

Memory and Storage Considerations

Event tracking tables can grow large over time. Implement cleanup strategies:

// Clean up old webhook events (run as a scheduled job) export async function cleanupOldWebhookEvents() { const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); const result = await db.stripeWebhookEvent.deleteMany({ where: { createdAt: { lt: thirtyDaysAgo }, processingResult: 'completed' } }); console.log(`Cleaned up ${result.count} old webhook events`); }

Monitoring and Debugging Webhook Processing

Effective monitoring is crucial for maintaining reliable webhook processing. Implement comprehensive logging and alerting:

Processing Metrics Dashboard

Track key metrics to identify issues before they impact customers:

// Metrics collection for webhook processing export class WebhookMetrics { static async recordProcessingTime(eventType: string, duration: number) { await db.webhookMetric.create({ data: { eventType, metric: 'processing_duration', value: duration, timestamp: new Date() } }); } static async recordFailure(eventType: string, error: string) { await db.webhookMetric.create({ data: { eventType, metric: 'failure', value: 1, metadata: { error }, timestamp: new Date() } }); } static async getProcessingStats(hours: number = 24) { const since = new Date(Date.now() - hours * 60 * 60 * 1000); return await db.webhookMetric.groupBy({ by: ['eventType', 'metric'], where: { timestamp: { gte: since } }, _avg: { value: true }, _count: { value: true } }); } }

Alerting for Processing Issues

Set up alerts for common problems:

export async function checkWebhookHealth() { const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); // Check for high failure rates const recentFailures = await db.stripeWebhookEvent.count({ where: { createdAt: { gte: oneHourAgo }, processingResult: { contains: 'failed' } } }); const totalRecent = await db.stripeWebhookEvent.count({ where: { createdAt: { gte: oneHourAgo } } }); const failureRate = totalRecent > 0 ? recentFailures / totalRecent : 0; if (failureRate > 0.1) { // 10% failure rate threshold await sendAlert({ type: 'high_webhook_failure_rate', message: `Webhook failure rate is ${(failureRate * 100).toFixed(1)}% over the last hour`, failures: recentFailures, total: totalRecent }); } // Check for processing delays const oldestUnprocessed = await db.stripeWebhookEvent.findFirst({ where: { processingResult: 'processing' }, orderBy: { createdAt: 'asc' } }); if (oldestUnprocessed) { const age = Date.now() - oldestUnprocessed.createdAt.getTime(); if (age > 10 * 60 * 1000) { // 10 minutes await sendAlert({ type: 'webhook_processing_delay', message: `Webhook ${oldestUnprocessed.id} has been processing for ${Math.round(age / 60000)} minutes`, eventId: oldestUnprocessed.id }); } } }

Best Practices Summary

Here's a comprehensive checklist for implementing robust webhook retry handling:

Event Deduplication:

  • ✅ Store webhook event IDs in your database before processing
  • ✅ Use database transactions to ensure atomic event recording and processing
  • ✅ Implement unique constraints to prevent race conditions
  • ✅ Handle partial failures with stage-based recovery

Error Handling:

  • ✅ Return appropriate HTTP status codes (2xx for success, 5xx for retries)
  • ✅ Log detailed error information for debugging
  • ✅ Implement exponential backoff for your own retry logic
  • ✅ Set reasonable timeout limits for webhook processing

Monitoring and Maintenance:

  • ✅ Track processing metrics and failure rates
  • ✅ Set up alerts for high failure rates or processing delays
  • ✅ Implement cleanup jobs for old webhook event records
  • ✅ Monitor database performance as event tables grow

Security and Reliability:

  • ✅ Always verify webhook signatures before processing
  • ✅ Use HTTPS endpoints with valid SSL certificates
  • ✅ Implement rate limiting to prevent abuse
  • ✅ Keep webhook endpoints simple and focused

Testing:

  • ✅ Test webhook handlers with duplicate events
  • ✅ Simulate network failures and timeouts
  • ✅ Verify idempotency across different failure scenarios
  • ✅ Test recovery procedures for partial failures

Conclusion

Implementing robust webhook retry handling is essential for maintaining data integrity and customer trust in payment systems. The key is building idempotent processors that can safely handle duplicate events while providing visibility into processing status and failures.

The patterns we've covered—event-based idempotency, transactional processing, stage-based recovery, and comprehensive monitoring—form the foundation of a reliable webhook system. Remember that webhook processing is often the most critical part of your payment flow, as it's where you convert successful payments into actual business value.

For complex Stripe integrations that require bulletproof webhook handling, our Stripe Integration service includes implementing these patterns along with comprehensive testing and monitoring setup. We've helped dozens of companies eliminate duplicate orders and build confidence in their payment processing reliability.

The investment in proper webhook handling pays dividends in reduced support tickets, improved customer satisfaction, and the peace of mind that comes from knowing your payment system handles edge cases gracefully.

Related Articles

Why Stripe Integration Is More Expensive Than Developers Quote (Hidden Complexity Explained)
Stripe Integration
Why Stripe Integration Is More Expensive Than Developers Quote (Hidden Complexity Explained)
Your developer quotes you two weeks to "hook up Stripe" for your SaaS product. Six weeks later, you're still not live. Payments work in testing but fail randoml...
Stripe Payment Intent vs Charge API: When to Use Each
Stripe Integration
Stripe Payment Intent vs Charge API: When to Use Each
You're building a payment integration and staring at Stripe's documentation, wondering: should you use Payment Intents or the Charges API? This isn't just an ac...
How to Test Stripe Webhooks Locally with ngrok and the Stripe CLI
Stripe Integration
How to Test Stripe Webhooks Locally with ngrok and the Stripe CLI
Testing Stripe webhooks during development can be frustrating. You make a change to your webhook handler, deploy to a staging environment, trigger a test paymen...

Need Expert Implementation?

I provide professional Stripe integration and Next.js optimization services with fixed pricing and fast delivery.