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:
-
Verify & Acknowledge: Instantly verify the signature and return a
200 OKto Stripe. - Idempotency Check: Use Redis to ensure we aren't already processing this exact event ID.
- 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});
});
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';
}
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
);
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}`);
}
}
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),
};
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_webhookstable. It's slower but guarantees data integrity. -
Problem: Webhook payload is too large for Express
rawbody 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)