DEV Community

Cover image for The Idempotency Trap: Architecting Resilient Stripe Webhooks in Node.js
Ameer Hamza
Ameer Hamza

Posted on

The Idempotency Trap: Architecting Resilient Stripe Webhooks in Node.js

The Silent Killer of SaaS Revenue

You’ve just launched your new SaaS. Subscriptions are rolling in, and your Stripe integration works flawlessly in testing. But a week into production, a customer emails you: "Why was I charged twice?"

You check your database. The user has two active subscriptions. You check Stripe. They only paid once. What happened?

Welcome to the Idempotency Trap.

Stripe (and almost every major API provider) guarantees at-least-once delivery for webhooks. This means if your server takes too long to respond, experiences a network blip, or crashes mid-process, Stripe will retry the webhook. If your webhook handler isn't idempotent—meaning it can safely process the same event multiple times without side effects—you will eventually provision duplicate resources, send duplicate emails, or grant double credits.

In this deep dive, we'll explore why the naive approach to webhook processing fails at scale and how to architect a resilient, idempotent pipeline in Node.js using Redis and PostgreSQL.

Architecture and Context

To build a bulletproof webhook pipeline, we need to decouple receiving the event from processing it.

What you'll need:

  • Node.js & Express: For our API server.
  • Stripe Node SDK: To verify webhook signatures.
  • Redis: For distributed locking and fast idempotency checks.
  • PostgreSQL: For our source of truth and persistent event logging.

The architecture follows a three-step pattern:

  1. Verify & Acknowledge: Instantly verify the signature and return a 200 OK to Stripe.
  2. Idempotency Check: Use Redis to ensure we aren't already processing this exact event ID.
  3. Process & Record: Execute the business logic and record the event in PostgreSQL to prevent future processing.

Deep-Dive Implementation

1. The Naive Approach (What Not to Do)

Most developers start with something like this:

app.post('/webhook', express.raw({type: 'application/json'}), async (req, res) => {
  const event = stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'], secret);

  if (event.type === 'checkout.session.completed') {
    // ❌ DANGER: What if this takes 10 seconds? Stripe will retry!
    await provisionUserAccount(event.data.object);
    await sendWelcomeEmail(event.data.object);
  }

  res.json({received: true});
});
Enter fullscreen mode Exit fullscreen mode

Why this fails: If provisionUserAccount takes longer than Stripe's timeout window, Stripe assumes failure and sends the event again. Your server is now running two identical processes concurrently.

2. The Redis Lock Pattern

To prevent concurrent processing of the same event, we need a distributed lock. Redis is perfect for this.

import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);

async function acquireLock(eventId) {
  // Try to set the key only if it doesn't exist (NX), with a 5-minute expiry (EX)
  const acquired = await redis.set(`webhook_lock:${eventId}`, 'locked', 'EX', 300, 'NX');
  return acquired === 'OK';
}
Enter fullscreen mode Exit fullscreen mode

When a webhook arrives, we immediately try to acquire the lock. If we can't, it means another process is already handling it, and we can safely ignore the duplicate.

3. Persistent Event Logging in PostgreSQL

Redis handles the immediate concurrency problem, but what if Stripe retries the event an hour later? We need a persistent record of processed events.

CREATE TABLE processed_webhooks (
  id VARCHAR(255) PRIMARY KEY,
  type VARCHAR(255) NOT NULL,
  processed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
Enter fullscreen mode Exit fullscreen mode

4. The Resilient Webhook Handler

Now, let's combine these concepts into a production-ready handler.

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

  // 1. Acknowledge receipt immediately
  res.json({received: true});

  // 2. Background processing
  processWebhook(event).catch(console.error);
});

async function processWebhook(event) {
  const { id, type } = event;

  // Step 1: Check persistent storage
  const alreadyProcessed = await db.query(
    'SELECT id FROM processed_webhooks WHERE id = $1', 
    [id]
  );

  if (alreadyProcessed.rows.length > 0) {
    console.log(`Event ${id} already processed. Skipping.`);
    return;
  }

  // Step 2: Acquire Redis lock for concurrent retries
  const locked = await acquireLock(id);
  if (!locked) {
    console.log(`Event ${id} is currently being processed by another worker.`);
    return;
  }

  try {
    // Step 3: Execute business logic
    if (type === 'checkout.session.completed') {
      await handleCheckoutCompleted(event.data.object);
    }

    // Step 4: Record successful processing
    await db.query(
      'INSERT INTO processed_webhooks (id, type) VALUES ($1, $2)',
      [id, type]
    );
  } finally {
    // Step 5: Release the lock
    await redis.del(`webhook_lock:${id}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Click to expand the full database configuration
const { Pool } = require('pg');
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20,
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 2000,
});

module.exports = {
  query: (text, params) => pool.query(text, params),
};
Enter fullscreen mode Exit fullscreen mode

Common Pitfalls & Edge Cases

  • Problem: The business logic fails halfway through (e.g., account provisioned, but email failed). Fix: Wrap your business logic in a database transaction. If it fails, roll back the transaction and DO NOT insert the event into processed_webhooks. Let Stripe retry it.
  • Problem: Redis goes down. Fix: Fall back to the PostgreSQL unique constraint on the processed_webhooks table. It's slower but guarantees data integrity.
  • Problem: Webhook payload is too large for Express raw body parser. Fix: Ensure your body parser limit is configured correctly: express.raw({type: 'application/json', limit: '5mb'}).

Conclusion

Handling webhooks reliably is a rite of passage for backend engineers. By decoupling receipt from processing and implementing a two-tier idempotency check (Redis for concurrency, PostgreSQL for persistence), you can ensure your system remains consistent, no matter what the network throws at it.

  • Always acknowledge webhooks immediately.
  • Use distributed locks to prevent concurrent processing of the same event.
  • Maintain a persistent log of processed event IDs.
  • Wrap business logic in transactions to handle partial failures.

What's your approach to handling webhook idempotency? Have you hit similar edge cases with other providers like PayPal or Twilio? Drop your thoughts in the comments.


About the Author: Ameer Hamza is a Top-Rated Full-Stack Developer with 7+ years of experience building SaaS platforms, eCommerce solutions, and AI-powered applications. He specializes in Laravel, Vue.js, React, Next.js, and AI integrations — with 50+ projects shipped and a 100% job success rate. Check out his portfolio at ameer.pk to see his latest work, or reach out for your next development project.

Top comments (0)