Fairy
Resources

How to Avoid: Stripe Webhook Without Idempotency

August 7, 2026 · 8-minute read · Fairy

The short answer

To prevent Stripe webhook without idempotency in AI-generated code, store each processed event.id in a database or cache before executing business logic. Check if the event.id exists at the start of your handler—if it does, return early. This prevents duplicate charges and double fulfillment when Stripe retries webhooks on timeouts or 5xx errors.

The Direct Answer: Store and Check event.id Before Processing

To prevent Stripe webhooks without idempotency in AI-generated code, you must store each event.id before executing any business logic and skip processing if that ID already exists. Here's the correct pattern:

app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  const sig = req.headers['stripe-signature'];
  
  let event;
  try {
    event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  // Idempotency check - BEFORE any business logic
  if (await db.webhookEvents.exists(event.id)) {
    return res.status(200).json({ received: true, duplicate: true });
  }
  
  // Mark as processed BEFORE executing to prevent race conditions
  await db.webhookEvents.insert(event.id);

  // Now safe to process
  if (event.type === 'payment_intent.succeeded') {
    await fulfillOrder(event.data.object);
  }

  res.status(200).json({ received: true });
});

This pattern prevents duplicate charges, double fulfillment, and the cascade of customer support issues that follow.

Why AI-Generated Stripe Webhooks Lack Idempotency

AI code generators consistently produce webhook handlers that process events without deduplication. This isn't random—it's a predictable pattern with specific causes.

The Training Data Problem

AI models learn from millions of code examples, many of which are tutorials, documentation snippets, and proof-of-concept implementations. These examples demonstrate the core flow—receive event, process event, return 200—without addressing production concerns like retries.

When you prompt an AI to "create a Stripe webhook handler for subscription payments," it produces code that handles the happy path correctly:

// AI-generated code - MISSING idempotency
app.post('/webhook', async (req, res) => {
  const event = req.body;
  
  if (event.type === 'checkout.session.completed') {
    await grantSubscriptionAccess(event.data.object.customer);
  }
  
  res.status(200).send('OK');
});

This code works perfectly the first time. It fails catastrophically on the second, third, and fourth attempts when Stripe retries due to a momentary network issue.

AI Lacks System Context

The AI doesn't know that Stripe retries webhooks for up to 3 days. It doesn't understand that your server might return a 503 during a deployment, triggering a retry an hour later. It can't anticipate that a slow database query might cause a timeout, leading Stripe to send the same event again while your original handler is still processing.

This missing system context is why Fairy's code verification specifically checks for idempotency patterns in payment handlers—it's a failure mode that appears in nearly every AI-generated payment integration we review.

How Stripe Webhook Retries Create Duplicate Processing

Understanding Stripe's retry behavior explains why idempotency isn't optional.

Retry Triggers

Stripe considers a webhook delivery failed when:

Retry Schedule

Failed webhooks retry with exponential backoff:

  1. First retry: ~1 minute after initial failure
  2. Subsequent retries: increasing intervals
  3. Final attempt: up to 3 days after the original event

The Race Condition Window

Even a 10-second processing time creates problems. If your handler takes 15 seconds to fulfill an order, Stripe times out at 20 seconds and schedules a retry. Meanwhile, your original handler completes successfully. When the retry arrives, you fulfill the order again.

Without idempotency tracking, there's no way to know the event was already processed.

Detecting Missing Idempotency in Your Codebase

Manual Code Review Signals

Look for webhook handlers that:

Automated Detection

Tools like Fairy Scout flag this pattern automatically during PR review:

Webhook handler lacks idempotency check causing duplicate fulfillment: No deduplication on event.id. Stripe retries webhooks on timeout/5xx, and this handler will fulfill the same order multiple times.

The fix recommendation is specific: store processed event IDs before acting on the event.

Production Monitoring Signals

If you're already in production without idempotency, watch for:

The Correct Pattern: Implementing Webhook Idempotency

Pattern 1: Database Storage (PostgreSQL/MySQL)

Best for: Systems where webhook volume is moderate and you need audit trails.

// Create table for tracking
// CREATE TABLE webhook_events (
//   event_id VARCHAR(255) PRIMARY KEY,
//   processed_at TIMESTAMP DEFAULT NOW()
// );

app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  const sig = req.headers['stripe-signature'];
  
  let event;
  try {
    event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
  } catch (err) {
    return res.status(400).send('Invalid signature');
  }

  // Atomic insert-or-ignore prevents race conditions
  const [inserted] = await db.query(
    'INSERT INTO webhook_events (event_id) VALUES ($1) ON CONFLICT DO NOTHING RETURNING event_id',
    [event.id]
  );
  
  if (!inserted) {
    // Event already processed
    return res.status(200).json({ received: true });
  }

  // Process event...
  await handleEvent(event);
  
  res.status(200).json({ received: true });
});

Pattern 2: Redis with TTL

Best for: High-throughput systems where you don't need permanent event history.

app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  const sig = req.headers['stripe-signature'];
  
  let event;
  try {
    event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
  } catch (err) {
    return res.status(400).send('Invalid signature');
  }

  // SET with NX (only if not exists) - atomic operation
  const key = `stripe:webhook:${event.id}`;
  const isNew = await redis.set(key, '1', 'EX', 259200, 'NX'); // 72-hour TTL
  
  if (!isNew) {
    return res.status(200).json({ received: true });
  }

  await handleEvent(event);
  
  res.status(200).json({ received: true });
});

Critical Implementation Details

Use atomic operations. A check-then-insert pattern has a race condition:

// WRONG - race condition between check and insert
if (await db.exists(event.id)) return;
await db.insert(event.id);
await processEvent(event);

Two concurrent requests can both pass the exists check before either inserts.

Insert before processing. If you insert after processing and the process fails, the retry won't be flagged as a duplicate—which is actually correct behavior. But if processing succeeds and the insert fails, you'll reprocess on retry.

Choose appropriate TTL. Stripe's retry window is 3 days (72 hours). Set your Redis TTL to at least 72 hours, preferably longer to handle edge cases.

Common Mistakes When Adding Idempotency

Mistake 1: Checking Signature After Idempotency

Always verify the webhook signature first. An attacker could flood your database with fake event IDs, causing you to ignore real events.

// WRONG ORDER
if (await isDuplicate(req.body.id)) return; // Attacker can inject fake IDs
const event = stripe.webhooks.constructEvent(...);

// CORRECT ORDER
const event = stripe.webhooks.constructEvent(...); // Verify authenticity first
if (await isDuplicate(event.id)) return;

Mistake 2: Using Parsed JSON for Signature Verification

Stripe's signature verification requires the raw request body. Using express.json() middleware parses the body, breaking signature verification.

// WRONG - express.json() parses the body
app.use(express.json());
app.post('/webhook', (req, res) => {
  const event = stripe.webhooks.constructEvent(req.body, sig, secret); // Fails
});

// CORRECT - use express.raw() for this route
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const event = stripe.webhooks.constructEvent(req.body, sig, secret); // Works
});

Mistake 3: Only Checking event.type

Event type isn't unique—you'll receive many payment_intent.succeeded events. Idempotency must be based on event.id, which is unique per event.

Beyond Idempotency: The Full Webhook Security Checklist

Idempotency is critical, but it's one of several requirements for production-ready Stripe webhooks. Our reviews consistently find these additional patterns:

Signature verification using stripe.webhooks.constructEvent() ensures events actually came from Stripe. Without it, attackers can forge webhook events to grant themselves subscriptions or trigger refunds.

Event timing validation prevents replay attacks where attackers capture legitimate webhooks and replay them later. Check that event.created is recent.

Correct event triggers ensure you don't grant access prematurely. We frequently see code that fulfills orders on payment_intent.created instead of payment_intent.succeeded—users get access before payment is confirmed.

These patterns interact. Missing any one creates a critical vulnerability or reliability gap. Fairy's code reviews check for all of them together because they form a complete security model.

Testing Your Idempotency Implementation

Local Testing with Stripe CLI

# Forward webhooks to local server
stripe listen --forward-to localhost:3000/webhook

# Trigger a test event
stripe trigger payment_intent.succeeded

# Trigger the same event again (simulating retry)
stripe trigger payment_intent.succeeded

Check that your fulfillment logic only runs once.

Integration Test Pattern

describe('Webhook idempotency', () => {
  it('processes event once despite multiple deliveries', async () => {
    const event = createTestEvent('payment_intent.succeeded');
    
    await request(app).post('/webhook').send(event);
    await request(app).post('/webhook').send(event);
    await request(app).post('/webhook').send(event);
    
    const fulfillments = await db.fulfillments.findAll({
      where: { paymentIntentId: event.data.object.id }
    });
    
    expect(fulfillments).toHaveLength(1);
  });
});

Why This Matters: The Real Cost of Missing Idempotency

The consequences of duplicate webhook processing compound quickly:

These aren't edge cases. Stripe's retry behavior is designed for reliability, and your infrastructure will occasionally return errors. Every production Stripe integration will experience retries.

The pattern is simple to implement but easy to miss, especially in AI-generated code where the training data optimizes for initial correctness over operational resilience. Using Fairy Intelligence to query your codebase for webhook handlers without idempotency checks can surface these issues before they reach production.

Frequently asked questions

Why does Stripe retry webhooks?

Stripe retries webhooks when your endpoint returns a 5xx error, times out, or doesn't respond within 20 seconds. Retries continue for up to 3 days with exponential backoff. This retry behavior is essential for reliability but requires your handler to be idempotent.

Where should I store processed Stripe event IDs?

Store event IDs in a persistent data store like PostgreSQL, Redis, or DynamoDB. Redis with TTL expiration (24-72 hours) is common for high-throughput systems. The store must be checked atomically before processing to prevent race conditions.

What happens if I process the same Stripe webhook twice?

Without idempotency, duplicate webhook processing causes duplicate charges, double order fulfillment, multiple subscription grants, or repeated email notifications. These issues create customer support burdens and potential financial liability.

Should I verify webhook signatures AND implement idempotency?

Yes, both are required. Signature verification (using constructEvent) confirms the webhook came from Stripe. Idempotency (checking event.id) prevents duplicate processing. Missing either one is a critical security or reliability gap.

How do AI code generators miss webhook idempotency?

AI models optimize for the happy path—receiving an event and processing it. They lack context about Stripe's retry behavior and often produce handlers that work correctly once but fail catastrophically on retries. This is a training data pattern issue.


Have AI-generated work you’d want verified? Connect with a Fairy → or run a free check with Scout.

More resources