Most Stripe webhook tutorials show you the happy path. Here's the production version that handles the edge cases that will bite you.
The naive implementation everyone starts with
app.post('/webhooks/stripe', express.raw({type: 'application/json'}), (req, res) => {
const event = stripe.webhooks.constructEvent(
req.body, req.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET
);
if (event.type === 'checkout.session.completed') {
// provision user
}
res.json({received: true});
});
This breaks in production. Here's why.
Problem 1: Duplicate events
Stripe will deliver the same event multiple times if your endpoint is slow or fails. Make handlers idempotent.
async function handleWebhook(event: Stripe.Event) {
const alreadyProcessed = await redis.get(`stripe:event:${event.id}`);
if (alreadyProcessed) return { skipped: true };
await processEvent(event);
await redis.setex(`stripe:event:${event.id}`, 86400, '1'); // 24hr TTL
}
Problem 2: Async processing timing out
Stripe expects a 200 response within 30 seconds. Complex provisioning takes longer.
app.post('/webhooks/stripe', async (req, res) => {
const event = stripe.webhooks.constructEvent(...);
// Respond immediately
res.json({ received: true });
// Process asynchronously via queue
await queue.add('stripe-webhook', { event });
});
Problem 3: Wrong events for the wrong use case
// checkout.session.completed — checkout UI finished
// invoice.payment_succeeded — money actually moved (use for subscriptions)
// payment_intent.succeeded — one-time payments
const handlers: Partial<Record<Stripe.Event.Type, Handler>> = {
'checkout.session.completed': handleNewSubscription,
'invoice.payment_succeeded': handleRenewal,
'invoice.payment_failed': handleFailedPayment,
'customer.subscription.deleted': handleCancellation,
'customer.subscription.updated': handlePlanChange,
};
Problem 4: Subscription state transitions
async function handleSubscriptionUpdated(event: Stripe.Event) {
const sub = event.data.object as Stripe.Subscription;
const prev = event.data.previous_attributes as Partial<Stripe.Subscription>;
if (prev?.items) {
const oldPrice = prev.items.data[0].price.id;
const newPrice = sub.items.data[0].price.id;
if (oldPrice !== newPrice) await handlePlanChange(sub.customer as string, oldPrice, newPrice);
}
if (prev?.status && prev.status !== sub.status) {
await handleStatusChange(sub.customer as string, sub.status);
}
}
Problem 5: Raw body parsing behind proxies
// Webhook route MUST use raw body — register before express.json()
app.use('/webhooks/stripe', express.raw({ type: 'application/json' }));
app.use(express.json()); // all other routes
Production-ready handler with BullMQ
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers['stripe-signature'] as string,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
return res.status(400).send('Webhook Error');
}
res.json({ received: true }); // Respond immediately
await webhookQueue.add(event.type, event, {
jobId: event.id, // BullMQ deduplicates by jobId — no duplicate processing
attempts: 3,
backoff: { type: 'exponential', delay: 1000 },
});
});
jobId: event.id is the key insight — BullMQ rejects duplicate jobs, so even if Stripe sends the same event 5 times, it processes once.
The complete Stripe integration (with webhook handler, customer portal, and plan upgrade logic) is in our AI SaaS starter kit at whoffagents.com.
Top comments (0)