Skip to content

Stripe Webhook Failures at Scale: Preventing Duplicate Charges, Replay Attacks, and Out-of-Order Events

A battle-tested guide to architecting idempotent Stripe webhook handlers. Stop duplicate fulfillment, handle out-of-order retries, and lock state in PostgreSQL.

Stripe Webhook Failures at Scale: Preventing Duplicate Charges, Replay Attacks, and Out-of-Order Events

Look, I have audited seven production SaaS codebases over the past two quarters, and five of them handled Stripe webhooks with a single, fragile assumption: they assumed a payment webhook arrives exactly once, in sequential order, over a clean network connection.

That assumption works fine in local development with the Stripe CLI. It falls apart in production the first time an edge serverless function experiences a cold start or an upstream ISP hiccups.

Stripe does not guarantee exactly-once delivery. Stripe guarantees at-least-once delivery. If your handler takes 3,100 milliseconds to respond because of a slow database query, Stripe times out at 3,000 milliseconds, queues a retry, and fires the same payload again. If your code lacks strict database-level idempotency locks, your system provisions two subscriptions, credits double credits, or triggers duplicate fulfillment emails.

Here is what happens under the hood when webhook architectures fail, and how to build an atomic, replay-safe pipeline that scales without race conditions.


1. The Anomaly: At-Least-Once Delivery vs Concurrent Race Conditions

Stripe re-attempts failed webhook deliveries with exponential backoff over a 72-hour window. When an endpoint times out or returns HTTP 500, subsequent retries can arrive concurrently with the original delayed execution.

Here is the deterministic cascade that triggers duplicate fulfillment:

1. Stripe dispatches customer.subscription.created -> 2. Next.js serverless route cold starts (2,800ms) -> 3. DB connection acquired late -> 4. Stripe hits 3,000ms timeout -> 5. Stripe dispatches Retry #1 -> 6. Both requests execute simultaneously -> 7. Customer credited twice

What do developers usually do when users report double fulfillment? They blame Stripe or add a naive in-memory flag. In-memory flags are useless in modern serverless and containerized setups because each incoming HTTP request lands in a separate container, edge isolate, or node worker process.

// ❌ CATASTROPHIC RACE CONDITION: Naive check-then-act pattern
export async function POST(req: Request) {
  const event = await parseStripeEvent(req);

  // <-- This check returns false for BOTH concurrent executions!
  const alreadyProcessed = await db.hasProcessed(event.id);
  if (alreadyProcessed) {
    return new Response('Already handled', { status: 200 });
  }

  // Window of vulnerability: Both requests reach this line simultaneously
  await provisionCustomerCredits(event.data.object.customer);
  await db.markAsProcessed(event.id);

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

Between the moment db.hasProcessed() runs and db.markAsProcessed() commits, a parallel retry executes the exact same query. Both threads see that the event has not been processed yet. Both proceed to credit the user account.


2. The Fix: Atomic PostgreSQL Idempotency Tables

To eliminate duplicate processing under concurrency, leverage relational database constraints. A relational database primary key collision is atomic, immediate, and independent of application server concurrency.

Create a dedicated stripe_processed_events table in PostgreSQL with the Stripe event ID as the primary key:

CREATE TABLE IF NOT EXISTS stripe_processed_events (
  event_id VARCHAR(255) PRIMARY KEY,
  event_type VARCHAR(100) NOT NULL,
  processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  payload JSONB
);

When your webhook handler receives an event, it attempts to insert the event ID inside an explicit database transaction. If the ID already exists, PostgreSQL rejects the operation with an error code 23505 (unique violation).

import { NextResponse } from 'next/server';
import Stripe from 'stripe';
import { pool } from '@/lib/db';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-06-20',
});

export async function POST(req: Request) {
  const body = await req.text();
  const signature = req.headers.get('stripe-signature');

  if (!signature) {
    return NextResponse.json({ error: 'Missing signature' }, { status: 400 });
  }

  let event: Stripe.Event;

  try {
    // 1. Cryptographic HMAC validation against unparsed raw body
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err: any) {
    return NextResponse.json({ error: `Signature failed: ${err.message}` }, { status: 400 });
  }

  const client = await pool.connect();

  try {
    await client.query('BEGIN');

    // 2. Atomic idempotency reservation
    const insertResult = await client.query(
      `INSERT INTO stripe_processed_events (event_id, event_type, payload)
       VALUES ($1, $2, $3)
       ON CONFLICT (event_id) DO NOTHING
       RETURNING event_id`,
      [event.id, event.type, JSON.stringify(event.data.object)]
    );

    // If no row was inserted, this event was already processed or is currently committing
    if (insertResult.rowCount === 0) {
      await client.query('ROLLBACK');
      // Always return 200 so Stripe stops retrying duplicate deliveries
      return NextResponse.json({ received: true, duplicate: true }, { status: 200 });
    }

    // 3. Execute business logic within the same atomic transaction
    switch (event.type) {
      case 'checkout.session.completed': {
        const session = event.data.object as Stripe.Checkout.Session;
        await fulfillOrder(client, session);
        break;
      }
      case 'invoice.payment_succeeded': {
        const invoice = event.data.object as Stripe.Invoice;
        await recordInvoicePayment(client, invoice);
        break;
      }
      case 'customer.subscription.deleted': {
        const sub = event.data.object as Stripe.Subscription;
        await revokeSubscriptionAccess(client, sub);
        break;
      }
      default:
        // Ignore unhandled event types
        break;
    }

    await client.query('COMMIT');
    return NextResponse.json({ received: true });
  } catch (error) {
    await client.query('ROLLBACK');
    // Returning 500 tells Stripe to retry if an unexpected internal error occurred
    return NextResponse.json({ error: 'Webhook processing failed' }, { status: 500 });
  } finally {
    client.release();
  }
}

Notice the crucial pattern: if rowCount === 0, we immediately rollback and return an HTTP 200 OK. If you return a 400 or 500, Stripe thinks your endpoint failed and continues hammering your API with retries for up to 72 hours. Returning HTTP 200 tells Stripe: “Received and acknowledged. Do not send this event again.”


3. Webhook Failure Modes vs Architectural Mitigation

Modern payment integrations fail in predictable, reproducible ways. The following benchmark table outlines the most frequent webhook edge cases, their root causes, and the engineering mitigations required for zero-loss billing pipelines:

Failure ModeRoot TriggerObservable SymptomRequired Mitigation Pattern
Duplicate DeliveryNetwork dropped packet or response latency > 3,000msCustomer billed once but credited twice in databasePostgreSQL INSERT ... ON CONFLICT (event_id) DO NOTHING
Out-of-Order EventsMulti-worker concurrent dispatch across distributed queuesinvoice.payment_succeeded fails because subscription record does not exist yetState machine verification & payload upserts using Stripe Object IDs
Payload Deserialization BugParsing body via req.json() before signature verificationCryptographic validation failure (stripe-signature verification failed)Extract raw body as string or buffer using req.text() before passing to SDK
Worker TimeoutSynchronous heavy tasks (PDF generation, third-party emails)HTTP 504 Gateway Timeout followed by a 72-hour retry stormDecouple webhook ingress: acknowledge with HTTP 200 in under 200ms, queue tasks to BullMQ/SQS
Stale Webhook SecretsProduction deployment rotation without dual-secret grace periodAll production payments suddenly report 400 signature errorsMulti-secret signature verification array during credential rollover

4. The Out-of-Order Delivery Dilemma

Stripe webhooks are dispatched asynchronously across distributed systems. Event arrival order cannot be guaranteed.

Consider this frequent scenario when a user subscribes:

  1. customer.subscription.created is emitted.
  2. invoice.payment_succeeded is emitted immediately after.

Because of network routing differences, your webhook server may receive invoice.payment_succeeded three seconds before customer.subscription.created. If your invoice handler executes an SQL query like:

UPDATE subscriptions 
SET status = 'active', current_period_end = $1 
WHERE stripe_subscription_id = $2;

And the row does not exist yet because the subscription creation event is still in transit, the UPDATE query silently affects zero rows (rowCount: 0). Your application reports the payment as succeeded, but the user’s account remains locked in an expired state.

Here is how senior architects solve out-of-order delivery:

Strategy A: Use Upserts with Natural Keys

Never rely on an entity already existing when processing downstream events. Use PostgreSQL INSERT ... ON CONFLICT (stripe_subscription_id) DO UPDATE:

INSERT INTO user_subscriptions (
  user_id,
  stripe_customer_id,
  stripe_subscription_id,
  status,
  current_period_end
) VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (stripe_subscription_id) DO UPDATE SET
  status = EXCLUDED.status,
  current_period_end = EXCLUDED.current_period_end,
  updated_at = NOW();

Strategy B: Timestamp Sequence Guards

Stripe payloads include a unix timestamp attribute: event.created. If you update user records based on status events (such as customer.subscription.updated), verify that the incoming event is newer than the last recorded update:

UPDATE user_subscriptions
SET 
  status = $1,
  last_stripe_event_timestamp = $2
WHERE stripe_subscription_id = $3
  AND last_stripe_event_timestamp < $2;

If an older event arrives delayed after a newer update has already committed, the update statement condition fails safely, preserving your latest state.


5. Security & Cryptographic Integrity Rules

Handling payment events requires zero-trust verification. Bypassing cryptographic validation or accepting parsed bodies opens your application to catastrophic replay attacks and forged balance top-ups.

Rule 1: Always Construct from Unparsed Raw Bytes

Never parse the HTTP body as JSON before feeding it to stripe.webhooks.constructEvent(). Most web frameworks (Express, Next.js, Fastify) modify whitespace or re-order object keys during JSON serialization, which corrupts the HMAC SHA-256 signature hash.

In Next.js App Router, always read raw text:

// ✅ CORRECT: Preserves exact byte structure for cryptographic verification
const rawBody = await req.text();
const event = stripe.webhooks.constructEvent(rawBody, signature, secret);

Rule 2: Rotate Secrets with Dual-Signature Support

When updating your Stripe webhook endpoint secret in production, do not overwrite the environment variable instantly. A sudden switch causes in-flight requests to fail. Stripe SDKs allow passing an array or validating against a fallback secret:

function verifyWithFallback(body: string, signature: string, secrets: string[]) {
  for (const secret of secrets) {
    try {
      return stripe.webhooks.constructEvent(body, signature, secret);
    } catch {
      // Continue to next secret
    }
  }
  throw new Error('All signature verification attempts failed.');
}

The 20-Minute Architectural Checklist

Before launching payment features or onboarding paying enterprise accounts, run through these five production requirements:

  1. Verify Raw Body Processing: Confirm your framework passes unparsed raw text to constructEvent().
  2. Database Primary Key Reservation: Create an idempotency_keys or stripe_processed_events table and reserve event.id atomically before mutating state.
  3. HTTP 200 on Duplicate Detection: Return HTTP 200 immediately if an event ID is already reserved, terminating the retry cycle.
  4. Decouple Heavy Workloads: Move email notifications, PDF invoices, and third-party API syncs to background jobs. Keep webhook response latency under 300 milliseconds.
  5. Upserts for State Transitions: Use ON CONFLICT clauses so out-of-order payment and subscription events self-heal without human intervention.

Implementing these constraints takes twenty minutes. It saves you dozens of hours of debugging corrupted accounts, issuing manual refunds, and handling urgent customer billing complaints.

Written By

Marcus Vance

Principal Systems Architect

Systems engineer focusing on distributed backends, database concurrency, and application security. Specializes in high-throughput PostgreSQL concurrency, serverless connection pooling, and infrastructure hardening.

Practice: Vexn • Focus: Deep Engineering, Infrastructure & Security
Frequently Asked Questions
Why does Stripe send duplicate webhook events for the same transaction?
Stripe guarantees at-least-once delivery over untrusted networks. If your server takes longer than 3 seconds to return an HTTP 200, or if an intermediate proxy drops the connection, Stripe treats the request as failed and re-attempts delivery up to 72 hours later with exponential backoff.
How do you prevent duplicate fulfillment when receiving concurrent webhook retries?
Store each Stripe event ID (evt_*) inside a dedicated PostgreSQL processed_events table with a UNIQUE constraint or primary key. Perform event insertion inside a database transaction before updating customer balance or subscription status. If the insert hits a unique constraint violation, abort processing immediately and return HTTP 200.
Why do Stripe webhooks arrive out of order?
Stripe distributes events across distributed queue workers and multiple egress nodes. Network jitter and serverless cold starts mean an invoice.payment_succeeded event can easily hit your webhook endpoint milliseconds before the corresponding customer.subscription.created event finishes execution.
Should you process heavy business logic directly inside the webhook HTTP route?
No. A webhook handler should verify the cryptographic HMAC signature, persist the raw event to an incoming queue or idempotency table, and immediately respond with HTTP 200. Heavy tasks like sending confirmation emails, generating PDF invoices, or provisioning cloud resources must run asynchronously in background workers.
Need an Architectural Audit?

Have our team inspect your AI-generated codebase.

48-hour turnaround with direct pull requests fixing security and scale bottlenecks.

Request Audit