Stripe Webhooks for SaaS: Production Setup 2026
Complete webhook configuration guide for indie SaaS. Handle events reliably, verify signatures, retry logic, and production patterns.
TL;DR
Stripe webhooks are essential for SaaS: listen for customer.subscription.updated, invoice.payment_succeeded, and charge.failed events. Always verify webhook signatures using your endpoint secret. Implement idempotency and retry logic. Most failures come from signature mismatches or missing event handling. Use [Stripe CLI v1.16+](https://stripe.com/docs/stripe-cli) for local testing—verify current version in official docs.
---
Why Webhooks Matter for Indie SaaS
Webhooks are your connection to Stripe's world. When a customer's subscription renews, a payment fails, or they request a refund, Stripe sends your server an HTTP POST. Webhook handling is non-negotiable: without it, you won't know if revenue actually landed.
Common mistakes:
---
Step 1: Create Your Webhook Endpoint
Your endpoint must: 1. Accept POST requests 2. Return HTTP 200 immediately 3. Process asynchronously 4. Verify the signature
Here's a production-ready Node.js/Express pattern using [express v4.18+](https://expressjs.com/):
```javascript const express = require('express'); const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); const app = express();
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
// CRITICAL: Use raw body for signature verification app.post( '/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => { const sig = req.headers['stripe-signature']; let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
endpointSecret
);
} catch (err) {
console.error(Webhook signature verification failed: ${err.message});
return res.sendStatus(400);
}
// Respond immediately res.sendStatus(200);
// Process asynchronously
handleWebhookEvent(event).catch(err => {
console.error(Webhook processing error: ${err.message}, { eventId: event.id });
});
}
);
async function handleWebhookEvent(event) { // Idempotency: Check if we've processed this event const processedEvent = await db.webhookEvents.findOne({ stripeEventId: event.id }); if (processedEvent) return;
switch (event.type) {
case 'customer.subscription.updated':
await handleSubscriptionUpdate(event.data.object);
break;
case 'invoice.payment_succeeded':
await handlePaymentSuccess(event.data.object);
break;
case 'charge.failed':
await handleChargeFailed(event.data.object);
break;
default:
console.warn(Unhandled event type: ${event.type});
}
// Mark as processed await db.webhookEvents.insertOne({ stripeEventId: event.id, type: event.type, processedAt: new Date(), }); }
app.listen(3000, () => console.log('Webhook server running on :3000')); ```
Key points:
express.raw() is mandatory—signature verification requires the original body bytes---
Step 2: Register Webhook in Stripe Dashboard
1. Go to [Stripe Dashboard](https://dashboard.stripe.com/) → Developers → Webhooks
2. Click "Add endpoint"
3. URL: https://yourdomain.com/webhooks/stripe (must be HTTPS in production)
4. Select events:
- customer.subscription.updated
- invoice.payment_succeeded
- invoice.payment_failed
- charge.failed
- charge.dispute.created
5. Copy the signing secret → set as STRIPE_WEBHOOK_SECRET in .env
[Verify exact event names in Stripe docs](https://stripe.com/docs/api/events/types)—some event names changed in recent API versions.
---
Step 3: Local Testing with Stripe CLI
Test without deploying:
```bash
Install (verify current version in official docs)
stripe loginForward Stripe events to your local server
stripe listen --forward-to localhost:3000/webhooks/stripeIn another terminal, trigger test events
stripe trigger customer.subscription.updated stripe trigger invoice.payment_succeeded ```You'll see output: ``` 2026-01-15 10:42:15 --> customer.subscription.updated [evt_...] 2026-01-15 10:42:15 <-- [200] ```
Console errors to watch for:
1. "No such file or directory: dist/index.js"
- Solution: Compile/build your app first (npm run build)
2. "Error: No such webhook endpoint secret"
- Solution: stripe listen outputs your signing secret—copy it to .env
3. "Cannot verify webhook signature: signature does not match"
- Solution: Using req.body instead of raw body. Change to express.raw()
---
Step 4: Handle Key Events
Subscription Updates
```javascript async function handleSubscriptionUpdate(subscription) { const customerId = subscription.customer; await db.subscriptions.updateOne( { stripeCustomerId: customerId }, { status: subscription.status, currentPeriodEnd: new Date(subscription.current_period_end * 1000), plan: subscription.items.data[0].price.id, updatedAt: new Date(), } );
if (subscription.status === 'past_due') { // Send retry email, downgrade features, etc. } } ```
Payment Success
```javascript async function handlePaymentSuccess(invoice) { await db.invoices.updateOne( { stripeInvoiceId: invoice.id }, { status: 'paid', paidAt: new Date(invoice.paid_date * 1000) } );
// Update MRR tracking, send receipt, unlock features await updateCustomerMetrics(invoice.customer); } ```
Charge Failed
```javascript async function handleChargeFailed(charge) { const failureCode = charge.failure_code; // e.g., 'insufficient_funds' await db.chargeFailures.insertOne({ stripeChargeId: charge.id, customerId: charge.customer, failureCode, failureMessage: charge.failure_message, attemptedAt: new Date(charge.created * 1000), });
// Notify user, trigger retry billing, or pause service } ```
---
Production Checklist
---
Related Guides
---
What am I missing?
Webhook handling varies by language, framework, and business logic. Drop a comment:
All feedback welcome—this guide updates with community input.