Stripe Webhooks for SaaS: Production Setup Guide 2026
Complete webhook implementation for indie SaaS. Handle subscription events reliably with production-ready patterns, error handling, and exact setup steps.
TL;DR
Stripe webhooks route real-time payment events to your server. Set up an HTTPS endpoint, verify signatures using your webhook secret, idempotently process events, and retry failed deliveries. Test locally with Stripe CLI before production. Most failures come from signature validation bugs or missing idempotency handling.
---
Why Webhooks Matter for SaaS
Webhooks are how Stripe tells your SaaS when subscriptions renew, fail, or cancel. Without them, you're polling the API (wasteful) or missing revenue events entirely. A single missed invoice.payment_succeeded webhook could mean a customer gets service they haven't paid for.
Common indie SaaS webhook events:
customer.subscription.created - new signupinvoice.payment_succeeded - payment receivedinvoice.payment_failed - retry logic startscustomer.subscription.deleted - cancellationcharge.dispute.created - chargeback incoming---
Step 1: Create Your Webhook Endpoint
Your endpoint must: 1. Listen on HTTPS only (no HTTP in production) 2. Return 200 immediately before processing 3. Verify Stripe's signature 4. Handle duplicate events
Here's a production-ready Node.js/Express example using stripe@15.0.0 [verify in official docs](https://stripe.com/docs/libraries/node):
```javascript const express = require('express'); const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const app = express(); const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
// Raw body required for signature verification app.post('/api/webhooks/stripe', express.raw({type: 'application/json'}), async (req, res) => { const signature = req.headers['stripe-signature']; let event;
// Verify signature
try {
event = stripe.webhooks.constructEvent(
req.body,
signature,
webhookSecret
);
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
return res.status(400).send(Webhook Error: ${err.message});
}
// Always return 200 immediately res.status(200).json({received: true});
// Process async - don't block response processWebhookEvent(event).catch(err => { console.error('Webhook processing error:', err); // Implement alerting here (Sentry, LogRocket, etc) }); } );
async function processWebhookEvent(event) {
const eventType = event.type;
const eventId = event.id; // Use for idempotency
// Check if we've already processed this event
const processed = await db.webhookEvents.findOne({stripeEventId: eventId});
if (processed) {
console.log(Event ${eventId} already processed, skipping);
return;
}
try {
switch (eventType) {
case 'invoice.payment_succeeded':
await handlePaymentSucceeded(event.data.object);
break;
case 'invoice.payment_failed':
await handlePaymentFailed(event.data.object);
break;
case 'customer.subscription.deleted':
await handleSubscriptionDeleted(event.data.object);
break;
default:
console.log(Unhandled event type: ${eventType});
}
// Mark as processed
await db.webhookEvents.create({
stripeEventId: eventId,
type: eventType,
processedAt: new Date()
});
} catch (err) {
console.error(Error processing ${eventType}:, err.message);
throw err; // Let outer handler deal with retry
}
}
async function handlePaymentSucceeded(invoice) { const customerId = invoice.customer; const amountPaid = invoice.amount_paid; // Update subscription status in your DB await db.subscriptions.updateOne( {stripeCustomerId: customerId}, {status: 'active', lastPaymentAt: new Date()} ); }
app.listen(3000, () => console.log('Webhook server listening')); ```
Critical mistakes that break this:
1. Missing raw body parsing - calling .json() middleware before signature check destroys the signature
2. Not returning 200 immediately - Stripe retries for 3 days if you don't
3. Processing inside the response handler - one slow operation blocks everything
---
Step 2: Get Your Webhook Secret and Endpoint URL
In Stripe Dashboard:
1. Go to [Developers → Webhooks](https://dashboard.stripe.com/webhooks)
2. Click "Add endpoint"
3. Enter your HTTPS URL: https://yourdomain.com/api/webhooks/stripe
4. Select events (start with: customer.subscription.created, invoice.payment_succeeded, invoice.payment_failed, customer.subscription.deleted)
5. Copy the Signing Secret (starts with whsec_) → STRIPE_WEBHOOK_SECRET env var
Console error you'll see if URL is wrong: ``` Webhook endpoint rejected POST request: 404 Not Found Webhook will be retried 5 times over the next 3 days ```
---
Step 3: Test Locally with Stripe CLI
Don't rely on the dashboard "Send test event" button—it doesn't verify signatures correctly.
Use [Stripe CLI v1.19.0+](https://stripe.com/docs/stripe-cli) [verify in official docs]:
```bash
Install
brew install stripe/stripe-cli/stripe # macOSor download from https://github.com/stripe/stripe-cli/releases
Login and forward webhooks
stripe listen --forward-to localhost:3000/api/webhooks/stripeIn another terminal, trigger test events
stripe trigger invoice.payment_succeeded ```Stripe CLI automatically sets STRIPE_WEBHOOK_SECRET for your local environment.
Console error when signature validation fails: ``` Webhook signature verification failed: No signatures found matching the expected signature for payload. Webhook Error: No signatures found matching the expected signature for payload. ```
This means your webhookSecret doesn't match the secret from the webhook endpoint in the dashboard.
---
Step 4: Production Checklist
✅ HTTPS only - Stripe won't send webhooks to HTTP endpoints in production ✅ Environment variables - never hardcode secrets ✅ Database idempotency table - webhook_events with stripeEventId + createdAt index ✅ Error alerting - Sentry/LogRocket integration for processing failures ✅ Monitoring - track webhook latency and failure rates ✅ Retry logic - implement exponential backoff for DB failures ✅ Logs - every webhook event in logs with timestamp + outcome
---
Common Indie SaaS Patterns
Subscription Status Sync
After invoice.payment_succeeded, your app should update the user's account tier synchronously. Use a single subscriptionStatus field ("active", "past_due", "canceled") rather than multiple boolean flags.
Grace Periods
Handle invoice.payment_failed by:
1. Grace period: 7 days before suspending access
2. Automatic retry: Stripe retries 3 times by default (verify in official docs)
3. After grace: suspend or show upgrade prompt
Don't immediately lock access—this kills conversion metrics.
---
Related Resources
---
What am I missing?
I'm sure there's a gap here—leave a comment with:
This guide assumes Node.js/Express, but the patterns apply to Django, Rails, PHP, etc. The core is always: verify signature → return 200 → process async → idempotency.