Mastering Stripe Webhook Duplication: Idempotency and Event Ordering in Production Applications
Stripe
Webhooks
Idempotency
Payments
Production Engineering
API Integration

Mastering Stripe Webhook Duplication: Idempotency and Event Ordering in Production Applications

Stripe webhooks are critical for real-time payment processing, but their at-least-once delivery guarantee means you must handle duplicates and out-of-order events. This guide pr...

September 26, 202612 min read

You're a technical founder or CTO building a payment-enabled application, likely using Stripe for its robust API. You've set up webhooks to react to critical events like checkout.session.completed, invoice.payment_succeeded, or customer.subscription.updated. But then you hit it: duplicate events, or worse, events arriving out of order. Your database reflects an incorrect state, users are double-charged (or not charged at all), or subscriptions are mismanaged. This isn't theoretical; it's a production reality for anyone relying on distributed systems with 'at-least-once' delivery guarantees, and it's precisely the kind of problem that keeps senior engineers up at night.

TL;DR

  • Verify Signatures: Always validate Stripe webhook signatures to prevent spoofing.
  • Idempotency with event.id: Store and check event.id to prevent processing the same event multiple times.
  • Handle Event Ordering: Use event.created timestamps and object-specific versioning to correctly process events that might arrive out of sequence.
  • Design for Retries: Your handler must be idempotent and resilient to Stripe's automatic webhook retries.
  • Robust Error Handling: Implement logging, monitoring, and potentially Dead Letter Queues (DLQs) for unprocessable events.

The Core Challenge: Why Webhooks Are Tricky

Stripe webhooks operate on an 'at-least-once' delivery model. This means Stripe guarantees that an event will be sent at least once, but it might be sent multiple times due to network issues, timeouts, or other transient problems. Additionally, while Stripe generally tries to deliver events in order, there's no strict guarantee, especially across different event types or during periods of high load. An invoice.payment_succeeded event might arrive before a related customer.subscription.updated event, or vice-versa, if they were generated close together.

This inherent uncertainty demands a defensive, robust approach to webhook processing. Simply reacting to an event without considering its potential duplication or ordering relative to other events is a recipe for data inconsistencies and operational headaches.

Foundation: Webhook Signature Verification

Before processing any webhook, you must verify its signature. This ensures that the event genuinely originated from Stripe and hasn't been tampered with. Ignoring this step is a severe security vulnerability, allowing malicious actors to send fake events to your system.

Stripe includes a signature in the Stripe-Signature header. You compare this signature with one you compute using your webhook secret and the raw request body. If they don't match, you reject the request.

import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';

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 req.text();
  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 });
  }

  // Process the event here
  console.log(`Received Stripe event: ${event.type} (ID: ${event.id})`);

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

This constructEvent method handles the cryptographic verification and parsing of the event object. If it throws an error, the signature is invalid, and you should immediately return a 400 status.

Idempotency: The First Line of Defense

Idempotency means that an operation can be applied multiple times without changing the result beyond the initial application. For webhooks, this is paramount. If Stripe sends the same invoice.payment_succeeded event twice, your system should only credit the user or update their subscription once.

The simplest and most effective way to achieve idempotency for individual events is to track the event.id provided by Stripe. Each Stripe event has a unique ID. You should store this ID in your database and check it before processing any event.

Database Schema Considerations

Consider a table, say processed_stripe_events, with a VARCHAR column for event_id and a UNIQUE constraint on it. This ensures that your database will prevent duplicate insertions at the schema level, providing a strong guarantee.

CREATE TABLE processed_stripe_events (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    event_id VARCHAR(255) UNIQUE NOT NULL,
    event_type VARCHAR(255) NOT NULL,
    received_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

Idempotent Processing Logic

Your webhook handler should wrap its core logic in a transaction that first attempts to record the event.id. If this insertion fails due to a unique constraint violation, you know the event has already been processed (or is currently being processed by a concurrent request), and you can safely exit.

// Inside your webhook POST handler, after signature verification

// Use a database transaction for atomicity
await db.transaction(async (trx) => {
  try {
    // Attempt to record the event ID. If it already exists, this will throw.
    await trx('processed_stripe_events').insert({
      event_id: event.id,
      event_type: event.type,
    });

    // If insertion succeeds, proceed to process the event
    switch (event.type) {
      case 'checkout.session.completed': {
        const session = event.data.object as Stripe.Checkout.Session;
        // Logic to fulfill purchase, create subscription, etc.
        console.log(`Checkout session ${session.id} completed.`);
        break;
      }
      case 'invoice.payment_succeeded': {
        const invoice = event.data.object as Stripe.Invoice;
        // Logic to update subscription status, credit user, etc.
        console.log(`Invoice ${invoice.id} payment succeeded.`);
        break;
      }
      // ... handle other event types
      default:
        console.warn(`Unhandled event type: ${event.type}`);
    }

    await trx.commit(); // Commit the transaction if all is well
  } catch (error: any) {
    if (error.code === '23505' && error.constraint === 'processed_stripe_events_event_id_key') {
      // Unique constraint violation, event already processed or is being processed
      console.log(`Stripe event ${event.id} (${event.type}) already processed. Skipping.`);
      await trx.rollback(); // Rollback any partial changes
    } else {
      // Other error, re-throw to trigger 500 and Stripe retry
      console.error(`Error processing Stripe event ${event.id}: ${error.message}`);
      await trx.rollback(); // Rollback on error
      throw error; // Re-throw to inform Stripe of failure
    }
  }
});

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

This pattern ensures that the core business logic for an event.id is executed exactly once. The database's unique constraint is your ultimate guardrail against duplicates.

Event Ordering: When event.id Isn't Enough

While event.id handles duplicates, it doesn't solve out-of-order events. Consider a scenario where a customer.updated event (e.g., changing an email) is sent before a customer.created event for the same customer, but due to network delays, the updated event arrives first. If you simply process them in arrival order, you might try to update a customer that doesn't exist yet, or overwrite correct data with stale data.

For events related to the same object, you need a more sophisticated approach, often involving timestamps or versioning. Stripe events include an event.created timestamp, which can be useful, but it represents when the event was created, not necessarily when the object was last updated. A more robust solution involves tracking a last_synced_at timestamp or a version number on your local representation of the Stripe object.

Object-Level Versioning and Timestamps

When processing an event for a specific Stripe object (e.g., a Customer, Subscription, or Invoice), compare the updated or created timestamp of the object within the event data with the last_synced_at timestamp you maintain for that object in your local database. Only process the event if the event's object timestamp is newer than your stored timestamp.

case 'customer.updated': {
  const customer = event.data.object as Stripe.Customer;
  const currentCustomer = await trx('customers').where({ stripe_customer_id: customer.id }).first();

  if (currentCustomer && new Date(customer.updated * 1000) <= currentCustomer.last_synced_at) {
    console.log(`Stale customer.updated event for ${customer.id}. Skipping.`);
    break; // Skip processing if event data is older or same as current
  }

  // Process update: e.g., update customer email, metadata, etc.
  await trx('customers')
    .where({ stripe_customer_id: customer.id })
    .update({
      email: customer.email,
      metadata: customer.metadata,
      last_synced_at: new Date(customer.updated * 1000), // Update local timestamp
    });
  console.log(`Customer ${customer.id} updated.`);
  break;
}
case 'customer.created': {
  const customer = event.data.object as Stripe.Customer;
  const existingCustomer = await trx('customers').where({ stripe_customer_id: customer.id }).first();

  if (existingCustomer) {
    // If a 'customer.updated' for this customer arrived first, we might already have it.
    // Compare timestamps to ensure we don't overwrite newer data with older 'created' data.
    if (new Date(customer.created * 1000) <= existingCustomer.last_synced_at) {
      console.log(`Stale customer.created event for ${customer.id}. Skipping.`);
      break;
    }
    // If the existing customer was created by an 'updated' event that arrived early, update it.
    await trx('customers')
      .where({ stripe_customer_id: customer.id })
      .update({
        email: customer.email,
        metadata: customer.metadata,
        last_synced_at: new Date(customer.created * 1000), // Update local timestamp
      });
    console.log(`Customer ${customer.id} updated from created event.`);
  } else {
    // Insert new customer
    await trx('customers').insert({
      stripe_customer_id: customer.id,
      email: customer.email,
      metadata: customer.metadata,
      last_synced_at: new Date(customer.created * 1000),
    });
    console.log(`Customer ${customer.id} created.`);
  }
  break;
}

This approach helps ensure that your local data reflects the latest state of the Stripe object, regardless of the webhook arrival order. Remember that customer.updated and customer.created events use the updated and created timestamps from the Customer object itself, which are Unix timestamps, so convert them to JavaScript Date objects (multiply by 1000).

Handling Retries and Exponential Backoff

Stripe automatically retries webhook deliveries if your endpoint doesn't respond with a 2xx status code within a reasonable timeframe (typically 15 seconds). It uses an exponential backoff strategy, retrying over several hours or even days. This is a crucial feature for resilience, but it means your webhook handler must be designed to accept and correctly process these retries.

Your idempotency logic (tracking event.id) is your primary defense here. If a retry occurs, your system will receive the same event.id. If it's already processed, your idempotency check will gracefully skip the processing. If the previous attempt failed before the event.id was recorded (e.g., a database connection error), the retry will allow the event to be processed correctly.

Ensure your webhook handler responds quickly. If your processing logic is complex or involves external APIs, consider offloading the heavy lifting to an asynchronous job queue (e.g., a message queue like RabbitMQ, SQS, or a simple background job processor). Your webhook endpoint would then primarily be responsible for:

  1. Verifying the signature.
  2. Recording the event.id (idempotency check).
  3. Publishing the event data to a job queue.
  4. Returning 200 OK.

The actual business logic would then run in a separate worker process, which can handle retries and failures independently.

Race Conditions and Concurrency

Even with event.id idempotency, you can encounter race conditions. Imagine two invoice.payment_succeeded events for the same invoice arriving almost simultaneously (perhaps due to an edge case or a manual retry from Stripe's dashboard). Both requests might pass the event.id check if the database insertion hasn't committed for the first one yet.

To mitigate this, especially when updating a single record based on an event, database-level transactions with appropriate isolation levels or explicit locking are essential. For instance, when updating a user's credit balance based on a payment:

case 'invoice.payment_succeeded': {
  const invoice = event.data.object as Stripe.Invoice;
  const amount = invoice.amount_paid;
  const userId = invoice.metadata?.userId; // Assuming you store userId in metadata

  if (!userId) {
    console.error(`Invoice ${invoice.id} missing userId metadata. Cannot credit user.`);
    throw new Error('Missing userId for credit operation');
  }

  // Acquire a lock on the user's record or use a transaction with a high isolation level
  await trx.raw(`SELECT * FROM users WHERE id = ? FOR UPDATE`, [userId]); // Pessimistic lock

  const user = await trx('users').where({ id: userId }).first();
  if (user) {
    await trx('users')
      .where({ id: userId })
      .update({ balance: user.balance + amount });
    console.log(`Credited user ${userId} with ${amount} from invoice ${invoice.id}.`);
  } else {
    console.error(`User ${userId} not found for crediting invoice ${invoice.id}.`);
    throw new Error('User not found');
  }
  break;
}

The FOR UPDATE clause in PostgreSQL (and similar constructs in other databases) ensures that only one transaction can modify or read the user record until the transaction commits, preventing concurrent updates from overwriting each other. This is crucial for maintaining data integrity in high-concurrency scenarios.

Error Paths and Observability

Even with the best defensive strategies, things can go wrong. Your webhook handler must be designed with robust error handling and observability in mind.

  • Logging: Log every incoming event (after signature verification), its processing status, and any errors. Include event.id and event.type in all logs for easy correlation.
  • Monitoring & Alerting: Set up monitoring for your webhook endpoint's latency, error rates (5xx responses), and processing queue length. Configure alerts for sustained error rates or queue backlogs.
  • Dead Letter Queues (DLQs): For events that consistently fail processing after multiple retries (e.g., due to malformed data, external API issues, or persistent application errors), consider sending them to a DLQ. This allows you to inspect them manually, fix underlying issues, and potentially reprocess them without blocking new events.
  • Manual Intervention: Have a clear process for manually reprocessing events from your DLQ or for handling edge cases that require human review. Stripe's dashboard allows you to manually resend webhooks, which can be useful for testing or recovery.

By combining these practices, you move beyond simply reacting to webhooks and build a truly resilient, production-grade payment processing system.

Key Takeaways

  • Always verify Stripe webhook signatures to prevent security breaches and ensure authenticity.
  • Implement strict idempotency checks using event.id stored in your database to avoid duplicate processing.
  • Manage event ordering by comparing object-level timestamps or versions to ensure data consistency for related events.
  • Design your webhook handler to be resilient to Stripe's automatic retries, leveraging idempotency.
  • Protect against race conditions with database transactions and locking mechanisms for critical updates.
  • Prioritize observability with comprehensive logging, monitoring, alerting, and Dead Letter Queues for error recovery.

Ship this without the trial-and-error

Implementing production-grade Stripe integrations, especially with complex marketplace or usage-based billing models, involves navigating a minefield of edge cases like webhook duplication, race conditions, and event ordering. At PolarSoftBD, we specialize in building and hardening these systems for technical founders and CTOs. Don't spend cycles debugging payment inconsistencies or security vulnerabilities.

We can help you architect and implement robust payment solutions, from Stripe Connect marketplaces to sophisticated usage-based billing systems. Explore our expertise:

Ready to build your next payment-powered product with confidence? Let's discuss your specific challenges and how we can accelerate your roadmap.

Further reading on related topics:

Last updated September 26, 2026

Get thoughtful updates

Join our monthly digest for founders and builders.

Work with PolarSoftBD

Need help shipping your next product? Let's talk.

Start a project →