Stripe Webhooks for SaaS: Production Setup Guide 2026
Complete webhook implementation for indie SaaS. Error handling, security, testing patterns. Production-ready code with real console errors.
TL;DR
Stripe webhooks require three things: (1) a publicly accessible HTTPS endpoint, (2) verification using webhook signing secrets, (3) idempotent handlers for retry safety. Most failures happen from skipping verification or not handling timeouts. Test with the Stripe CLI before deploying.
---
Why Webhooks Matter for SaaS
If you're building a SaaS product with Stripe, you need webhooks. Here's why: your customer pays, but their payment confirmation lives on Stripe's servers. You can't just call their API every 5 seconds asking "did they pay yet?" Webhooks are Stripe's way of saying "hey, something happened, here's the proof."
Without webhooks, your user upgrades their plan but stays on the free tier for hours. They churn. You lose.
---
Setting Up Your Webhook Endpoint
1. Create the Endpoint
Your endpoint must be:
Here's a Node.js + Express example (production pattern):
```javascript import express from 'express'; import stripe from 'stripe'; import { verifyWebhookSignature } from './stripe-verify.js'; import { handleCheckoutComplete } from './handlers/checkout.js'; import { handleSubscriptionUpdated } from './handlers/subscription.js';
const app = express(); const stripeClient = stripe(process.env.STRIPE_SECRET_KEY);
// CRITICAL: Use raw body for webhook signature verification app.post('/api/webhooks/stripe', express.raw({type: 'application/json'}), async (req, res) => { const sig = req.headers['stripe-signature']; const rawBody = req.body;
try { const event = verifyWebhookSignature( rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET );
// Log for debugging (strip sensitive data)
console.log(Webhook received: ${event.type} | ID: ${event.id});
switch(event.type) {
case 'checkout.session.completed':
await handleCheckoutComplete(event.data.object, stripeClient);
break;
case 'customer.subscription.updated':
await handleSubscriptionUpdated(event.data.object);
break;
case 'invoice.payment_failed':
await handlePaymentFailed(event.data.object);
break;
default:
console.log(Unhandled event type: ${event.type});
}
res.json({received: true});
} catch (error) {
console.error(Webhook error: ${error.message});
res.status(400).send(Webhook Error: ${error.message});
}
}
);
```
2. Verify the Signature
This is non-negotiable. Never skip this. Here's the verify function:
```javascript // stripe-verify.js import crypto from 'crypto';
export function verifyWebhookSignature(body, sig, secret) { if (!sig || !secret) { throw new Error('Missing signature or webhook secret'); }
const timestamp = sig.split(',')[0].split('=')[1]; const hash = sig.split(',')[1].split('=')[1];
// Check timestamp within 5 minutes (Stripe default) const currentTime = Math.floor(Date.now() / 1000); if (Math.abs(currentTime - parseInt(timestamp)) > 300) { throw new Error('Webhook timestamp outside tolerance window'); }
// Verify signature
const signedContent = ${timestamp}.${body};
const expectedHash = crypto
.createHmac('sha256', secret)
.update(signedContent)
.digest('hex');
if (expectedHash !== hash) { throw new Error('Webhook signature verification failed'); }
return JSON.parse(body); } ```
Note: Stripe provides official libraries. For Node.js, use stripe.webhooks.constructEvent() from stripe@>=13.0.0 [verify in official docs](https://stripe.com/docs/webhooks). This handles parsing automatically.
---
Real Console Errors You'll Hit
Error 1: Raw Body Parser Missing
``` Webhook signature verification failed Error: No webhook signature found ``` Cause: Usingexpress.json() instead of express.raw(). The middleware parses the body, destroying the raw signature verification.Fix: Use express.raw({type: 'application/json'}) as shown above.
Error 2: Environment Variable Missing
``` TypeError: Cannot read property 'split' of undefined at verifyWebhookSignature (stripe-verify.js:8:15) ``` Cause:STRIPE_WEBHOOK_SECRET not set in .env.Fix: Add to .env:
```
STRIPE_WEBHOOK_SECRET=whsec_test_...
STRIPE_SECRET_KEY=sk_test_...
```
Error 3: Handler Timeout
``` Error: Webhook handler exceeded timeout Stripe will retry this event ``` Cause: Your handler runs >30 seconds (common with database writes).Fix: Use queues (Bull, BullMQ, RabbitMQ). Return 200 immediately, process async:
```javascript case 'checkout.session.completed': // Queue the work, don't await await webhookQueue.add('checkout', event.data.object); break; ```
---
Idempotency: Handle Retries
Stripe retries failed webhooks with exponential backoff. Your handler might run twice. Here's how to handle it:
```javascript async function handleCheckoutComplete(session, db) { const existingOrder = await db.orders.findOne({ stripeSessionId: session.id });
if (existingOrder) {
console.log(Order ${session.id} already processed);
return; // Idempotent - safe to return
}
// Create order only once await db.orders.create({ userId: session.client_reference_id, stripeSessionId: session.id, amount: session.amount_total, createdAt: new Date() }); } ```
---
Testing Before Deploying
Use [Stripe CLI v1.20.0+](https://stripe.com/docs/stripe-cli) (verify in official docs for latest):
```bash
Install
brew install stripe/stripe-cli/stripeLogin
stripe loginForward webhooks to localhost
stripe listen --forward-to localhost:3000/api/webhooks/stripeIn another terminal, trigger events
stripe trigger payment_intent.succeeded ```---
Registering in Stripe Dashboard
1. Go to [Developers > Webhooks](https://dashboard.stripe.com/webhooks)
2. Click "Add endpoint"
3. Enter your URL: https://yourdomain.com/api/webhooks/stripe
4. Select events:
- checkout.session.completed
- customer.subscription.updated
- invoice.payment_failed
5. Copy the signing secret to .env
---
Production Checklist
---
Related Guides
---
What am I missing?
Did I overlook webhook idempotency patterns? Different payment flows? Database transaction best practices? Let me know in the commentsβindie hackers catch what I miss.