Why duplicate webhook events will destroy your billing logic—and how to make your payment handlers bulletproof with idempotency keys and database constraints.

The Problem: Stripe Sends Duplicate Events

Stripe webhooks are not guaranteed to arrive exactly once. Network timeouts, server crashes, and Stripe’s own retry logic mean you’ll receive the same event multiple times—sometimes seconds apart, sometimes hours later. If your webhook handler isn’t idempotent, a single payment confirmation can trigger multiple charges, create duplicate invoices, or credit a customer’s account twice.

This isn’t theoretical. In production, a webhook timeout followed by a retry can cause your payment processing logic to execute twice before you even notice. The financial impact is immediate and real. The fix is non-negotiable: every webhook handler must be idempotent—safe to run multiple times with identical input and produce the same outcome every time.

Idempotency Keys: The Foundation

Stripe provides a built-in mechanism for idempotency: every webhook event has a unique id field. Use this as your idempotency key. Before processing any webhook, check if you’ve already processed that event ID. If you have, return success immediately without re-executing the business logic.

Store processed event IDs in your database with a simple table: id (primary key), event_id (unique index), processed_at (timestamp), and optionally result (the outcome). This becomes your source of truth.

-- PostgreSQL example
CREATE TABLE stripe_webhook_events (
  id BIGSERIAL PRIMARY KEY,
  event_id VARCHAR(255) UNIQUE NOT NULL,
  event_type VARCHAR(100) NOT NULL,
  processed_at TIMESTAMP DEFAULT NOW(),
  result JSONB,
  created_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_event_id ON stripe_webhook_events(event_id);

On every webhook, first query this table for the event ID. If it exists, you’ve already processed it—return 200 OK and stop. If it doesn’t exist, proceed with your business logic, then insert the event record atomically with your transaction.

Implementation Pattern: Atomic Idempotency

The key is atomicity. Your webhook handler should follow this sequence: (1) check if event was processed, (2) if not, execute business logic and insert event record in a single database transaction. If the transaction fails, the webhook is not marked as processed, and Stripe will retry—which is correct behavior.

Here’s a concrete pattern in pseudocode:

async function handleStripeWebhook(event) {
  // 1. Check if already processed
  const processed = await db.query(
    'SELECT id FROM stripe_webhook_events WHERE event_id = $1',
    [event.id]
  );
  
  if (processed.rows.length > 0) {
    return { statusCode: 200, body: 'Already processed' };
  }
  
  // 2. Start transaction
  const client = await db.connect();
  try {
    await client.query('BEGIN');
    
    // 3. Execute business logic (e.g., update customer balance)
    if (event.type === 'charge.succeeded') {
      await client.query(
        'UPDATE customers SET balance = balance + $1 WHERE id = $2',
        [event.data.object.amount / 100, event.data.object.customer]
      );
    }
    
    // 4. Record event as processed
    await client.query(
      'INSERT INTO stripe_webhook_events (event_id, event_type) VALUES ($1, $2)',
      [event.id, event.type]
    );
    
    await client.query('COMMIT');
    return { statusCode: 200, body: 'Processed' };
  } catch (error) {
    await client.query('ROLLBACK');
    throw error; // Let Stripe retry
  } finally {
    client.release();
  }
}

The critical detail: if anything fails after you’ve started the transaction, you rollback and don’t record the event. Stripe sees a non-2xx response and retries. On retry, you start fresh and try again. This is correct.

Handling Event Type Variations and Timing

Not all webhook events need identical idempotency logic. charge.succeeded and payment_intent.succeeded are different events—don’t confuse them. Build your handler to be explicit about which event types trigger which business logic, and always validate the event structure before acting on it.

Also watch for timing: Stripe may send charge.succeeded before payment_intent.succeeded, or vice versa. If your logic depends on both, don’t assume order. Use the idempotency table to track which events you’ve seen, and only execute downstream actions (like sending a confirmation email) when all expected events have arrived. This is where proper automation patterns matter—your webhook handler should be a state machine, not a linear script.

One more gotcha: Stripe event IDs are unique per event, but the same underlying transaction (e.g., a charge) can generate multiple events. Don’t deduplicate by charge ID alone—always use the event ID.

Monitoring and Debugging Duplicate Events

In production, log every webhook arrival with the event ID, timestamp, and outcome. Query your stripe_webhook_events table regularly to spot duplicates—if you see the same event_id multiple times with different processed_at timestamps, you have a duplicate. This is expected and means your idempotency logic is working.

Set up alerts for webhooks that fail repeatedly (e.g., same event_id, multiple attempts, all failures). This signals a genuine bug in your handler, not a Stripe issue. Also monitor webhook latency—if your handler takes 30+ seconds, Stripe may timeout and retry before you finish, creating unnecessary duplicates.

For teams building complex payment systems or multi-tenant SaaS platforms, consider a dedicated webhook queue (e.g., Bull, RabbitMQ, or AWS SQS) that decouples Stripe’s delivery from your processing. Stripe sends the event, your queue acknowledges immediately, and a worker processes it asynchronously with full idempotency guarantees. This adds complexity but is worth it at scale.

What to Do Next

Start now: (1) add the stripe_webhook_events table to your database, (2) update every webhook handler to check this table before processing, (3) wrap business logic in a transaction that inserts the event record atomically, (4) test by manually replaying a webhook from Stripe’s dashboard and verify your handler returns 200 without side effects.

If you’re building a payment system from scratch, make idempotency non-negotiable from day one. It’s not a nice-to-have—it’s the difference between a system that works and one that loses money. The pattern is simple, the cost is minimal, and the payoff is absolute reliability.