Nurture TechnologiesNurture Tech
Integrations13 min read·July 24, 2026

Stripe Processing Duplicate Webhook Events

StripeNode.jsTypeScriptPostgreSQLRedis

Stripe guarantees at-least-once webhook delivery. That means duplicates are not a bug in Stripe they are a fact of life. Your webhook handler must be idempotent. Here is how to build that correctly.

Problem Summary

Stripe's webhook delivery system guarantees that every event will be delivered at least once, but does not guarantee exactly once. When your server returns a non-2xx response, times out, or is temporarily unavailable, Stripe retries the same event. If two delivery attempts arrive in close succession (e.g., one from the initial attempt and one from a retry during a slow database write), your handler can process the same event twice. Without idempotency, this causes duplicate orders, double access grants, duplicate emails, and incorrect accounting.

Symptoms

  • Customers receive duplicate confirmation emails after a single purchase
  • Order created twice for one Stripe payment visible as two rows with the same stripePaymentIntentId
  • User's account credits doubled or access tier granted twice on the same payment
  • invoice.paid processed twice, causing subscription renewal logic to run double
  • Stripe Dashboard shows one successful payment but database shows two fulfillment records
  • Customer service reports users complaining about duplicate charges (caused by double-provisioning showing different billing records)
  • Race condition during deployment: old and new server instances both receive and process the same webhook event
  • Handler returns HTTP 500 due to a transient error, Stripe retries, and both the original and retry succeed

Business Impact

Duplicate processing creates data integrity corruption that is often discovered days after it occurs. A double order means double fulfillment costs. Double access grants can expose premium features to users who have since canceled. Double emails cause users to think something is wrong and open support tickets. Correcting idempotency bugs after the fact requires auditing every webhook delivery for a period of time and manually reversing incorrect side effects.

Root Cause

Duplicate webhook processing is not caused by Stripe sending incorrect events. It is caused by webhook handlers that are not idempotent i.e., processing the same event ID twice produces different outcomes than processing it once. Idempotency must be explicitly implemented by your application: check if an event has already been processed before executing its side effects.

Every Stripe event has a globally unique event.id (e.g., evt_1ABC2DEF3GHI4JKL). Two deliveries of the same event always carry the same event.id. Use this ID as your idempotency key.

Diagnosis

Step 1 Confirm Duplicate Events in Stripe Dashboard

Navigate to Stripe Dashboard → Developers → Webhooks → your endpoint → Recent Deliveries. Filter by event ID and check if the same event.id appears multiple times in your delivery history with different attempt numbers.

$stripe events list --limit 20 --type invoice.paid

Step 2 Implement Database-Level Idempotency

The most robust idempotency mechanism is a processed_events table with a unique constraint on the event ID. Attempt to insert the event ID at the start of handler execution. If the insert fails with a unique constraint violation, the event was already processed.

src/webhooks/idempotency.ts
// Schema: CREATE TABLE processed_events (
//   event_id VARCHAR PRIMARY KEY,
//   event_type VARCHAR NOT NULL,
//   processed_at TIMESTAMPTZ DEFAULT NOW()
// );

export async function withIdempotency<T>(
  eventId: string,
  eventType: string,
  handler: () => Promise<T>
): Promise<{ alreadyProcessed: boolean; result?: T }> {
  try {
    // Attempt to claim this event
    await db.processedEvents.create({
      data: {
        eventId,
        eventType,
      },
    });
  } catch (err: unknown) {
    // Unique constraint violation  event already processed
    if (
      typeof err === "object" &&
      err !== null &&
      "code" in err &&
      (err as { code: string }).code === "P2002"
    ) {
      console.log(`[Idempotency] Event ${eventId} already processed, skipping`);
      return { alreadyProcessed: true };
    }
    throw err; // Rethrow unexpected errors
  }

  // Event is now claimed  run the handler
  const result = await handler();
  return { alreadyProcessed: false, result };
}

Step 3 Apply Idempotency Wrapper to All Event Handlers

src/webhooks/router.ts
import Stripe from "stripe";
import { withIdempotency } from "./idempotency";

export async function routeStripeEvent(event: Stripe.Event): Promise<void> {
  const { alreadyProcessed } = await withIdempotency(
    event.id,
    event.type,
    async () => {
      switch (event.type) {
        case "payment_intent.succeeded":
          await handlePaymentSucceeded(event);
          break;

        case "invoice.paid":
          await handleInvoicePaid(event);
          break;

        case "customer.subscription.updated":
          await handleSubscriptionUpdated(event);
          break;

        case "checkout.session.completed":
          await handleCheckoutCompleted(event);
          break;

        default:
          console.log(`[Webhook] Unhandled event type: ${event.type}`);
      }
    }
  );

  if (alreadyProcessed) {
    console.log(`[Webhook] Skipped duplicate event: ${event.id}`);
  }
}

Step 4 Use Redis for High-Throughput Idempotency

For high-volume webhooks where database writes are a bottleneck, use Redis SET NX (set if not exists) with an expiry as the first idempotency check. Fall through to the database only if Redis confirms the event is new.

src/webhooks/redis-idempotency.ts
import { redis } from "../lib/redis";

const IDEMPOTENCY_TTL_SECONDS = 60 * 60 * 24 * 3; // 3 days

export async function checkEventIdempotency(
  eventId: string
): Promise<"new" | "duplicate"> {
  const key = `stripe:event:${eventId}`;

  // SET key value NX EX ttl  returns OK if set, null if key exists
  const result = await redis.set(key, "1", {
    NX: true,
    EX: IDEMPOTENCY_TTL_SECONDS,
  });

  if (result === null) {
    // Key already existed  duplicate event
    return "duplicate";
  }

  return "new";
}

// Usage in webhook handler:
// const status = await checkEventIdempotency(event.id);
// if (status === "duplicate") return; // acknowledge without processing

Step 5 Handle Race Conditions with Database Locking

Two concurrent webhook deliveries can both pass the idempotency check simultaneously if processed in parallel (both check before either inserts). Use SELECT FOR UPDATE SKIP LOCKED or advisory locks to serialize processing of the same event.

src/webhooks/locked-handler.ts
import { db } from "../lib/db";

export async function processEventWithLock(
  eventId: string,
  handler: () => Promise<void>
): Promise<void> {
  await db.$transaction(async (tx) => {
    // Advisory lock on a hash of the event ID
    const lockId = hashEventId(eventId);
    await tx.$executeRaw`SELECT pg_advisory_xact_lock(${lockId})`;

    // Within the lock, check if already processed
    const existing = await tx.processedEvents.findUnique({
      where: { eventId },
    });

    if (existing) {
      console.log(`[Lock] Event ${eventId} already processed`);
      return;
    }

    // Mark as processed
    await tx.processedEvents.create({
      data: { eventId, processedAt: new Date() },
    });

    // Run handler inside the lock
    await handler();
  });
}

function hashEventId(eventId: string): number {
  let hash = 0;
  for (let i = 0; i < eventId.length; i++) {
    hash = (Math.imul(31, hash) + eventId.charCodeAt(i)) | 0;
  }
  return Math.abs(hash);
}

Production Failure Scenarios

Scenario 1 Slow Database Write Causes Timeout and Retry

Webhook handler starts processing and triggers a slow database transaction. Stripe's 30-second timeout passes before the handler responds. Stripe marks the delivery as failed and immediately queues a retry. The retry arrives while the original transaction is still running. Without locking, both transactions succeed and the event is processed twice.

Scenario 2 Rolling Deployment Causing Dual Processing

During a zero-downtime deployment, two server instances are briefly running simultaneously. Stripe's load balancer delivers the webhook to one instance and a retry (due to the old instance shutting down) arrives at the new instance. Both process the same event.id without idempotency.

Scenario 3 Unhandled Exception After Partial Processing

Handler creates the order successfully, then throws when attempting to send a confirmation email. HTTP 500 is returned. Stripe retries. The retry finds no order (idempotency was not implemented) and creates a second order. Email is sent for the second order. Customer receives one email but has two orders in the system.

Scenario 4 Manual Event Replay Creates Duplicates

A developer uses the Stripe CLI or Dashboard to replay a failed event to fix a production issue. The original event had already been processed (just with a non-2xx response due to a bug that has since been fixed). Without idempotency, the replay creates a second fulfillment.

Prevention Checklist

  • Implement idempotency using event.id as the key in a processed_events table with a PRIMARY KEY constraint
  • For high volume, add a Redis SET NX check before the database check
  • Use database advisory locks or SELECT FOR UPDATE to prevent concurrent duplicate processing
  • Wrap order/grant creation in a database transaction that includes the idempotency record insert
  • Return HTTP 200 for already-processed events this tells Stripe the event was handled (even if it was a duplicate)
  • Return HTTP 5xx (not 200) for transient errors you want Stripe to retry
  • Set a TTL on Redis idempotency keys that exceeds Stripe's 72-hour retry window
  • Log skipped duplicates explicitly so you can audit processing history
  • Test idempotency by manually calling the webhook handler twice with the same event
  • Review event replay procedures to ensure manual replays hit idempotency checks

Resolution Summary

Implement idempotency at the database level using event.id as a unique key. Wrap all side effects inside the idempotency transaction. Use Redis for fast pre-checks at high throughput. Use advisory locks to handle concurrent deliveries. Return HTTP 200 for duplicates (they are not errors they are expected). Return HTTP 5xx for retryable transient failures.

Lessons Learned

  • Stripe's at-least-once delivery is a guarantee, not a bug your handler must be idempotent by design
  • The event.id is globally unique across all Stripe accounts and all time it is the perfect idempotency key
  • A slow handler is functionally equivalent to a failed handler from Stripe's perspective optimize for sub-5s processing
  • Redis SET NX + database insert is a two-layer idempotency approach that handles both speed and durability
  • Return HTTP 200 for duplicates: returning 500 on a duplicate tells Stripe the event still needs to be delivered
  • Manual event replays must also go through idempotency checks do not create replay-specific bypass paths

Conclusion

Idempotency is not optional for Stripe webhook handlers it is a hard requirement imposed by the at-least-once delivery model. With a processed_events table, a unique constraint on event.id, advisory locking for concurrent deliveries, and correct HTTP status semantics, your webhook pipeline achieves exactly-once processing semantics even when Stripe delivers the same event multiple times.

Nurture Technologies

NEED HELP WITH YOUR STACK?

Nurture Technologies builds and maintains production-quality software for startups and businesses. If engineering problems are slowing you down, our team can help.

Talk to our team →
FAQ

FREQUENTLY ASKED QUESTIONS

What does 'at-least-once delivery' mean for Stripe webhooks?+

It means Stripe guarantees your endpoint will receive every event at least one time, but may send the same event more than once. This happens when your server returns an error or times out, triggering a retry. Exactly-once delivery must be implemented by your application using idempotency.

How long does Stripe retry failed webhook deliveries?+

Stripe retries for up to 72 hours with exponential backoff. The schedule includes attempts at 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, and multiple times per day after that. Your idempotency TTL must be at least 72 hours.

What is the correct HTTP response to return for a duplicate webhook event?+

Return HTTP 200. A duplicate event is not an error from Stripe's perspective. Returning HTTP 500 or 400 for a duplicate tells Stripe the event failed and needs to be retried, which will cause more duplicates.

Is it safe to use Redis alone for idempotency (without a database record)?+

No. Redis data can be lost if the server restarts or if you do not configure persistence correctly. Use Redis as a fast first-layer check, but always back it up with a durable database record. The combination gives you both speed and correctness.

Can two concurrent webhook deliveries both pass an idempotency check simultaneously?+

Yes, in a race condition. Both requests check the database, find no record, and both attempt to insert. If you only use an application-level check (not a database unique constraint), both inserts succeed. Always enforce idempotency at the database level with a UNIQUE or PRIMARY KEY constraint.

How do I make sure a manual event replay does not create duplicate records?+

It will not, as long as your idempotency check uses event.id. A replayed event carries the same event.id as the original. The idempotency check finds the existing record and skips processing. This is the correct behavior use it as a feature, not a problem.

What if I need to replay an event because the first processing was wrong?+

Delete the processed_events record for that event.id first, then replay. This deliberately resets the idempotency state, allowing the event to be processed again. Only do this after fixing the bug that caused the incorrect initial processing.

How do I handle idempotency for events that update (not create) records?+

Updates are naturally more idempotent than creates because writing the same value twice produces the same result. However, you still need to prevent running update logic multiple times if it has side effects like sending emails or charging downstream APIs. Use the same processed_events check.

Should each event type have its own idempotency table?+

No. Use a single processed_events table with event_id as the primary key and an event_type column. Since event IDs are globally unique across all event types, a single table handles all event types correctly.

How do I test that my idempotency implementation works correctly?+

In your test suite, call your webhook handler twice with the same event object (same event.id). Assert that the side effects (database records, emails, etc.) occurred exactly once. Also test concurrent execution by calling the handler in parallel and asserting exactly-once outcomes.