Stripe Webhooks for SaaS in 2026: Complete Setup Guide
Production-ready webhook setup for indie SaaS. Real error patterns, exact code, and testing strategies developers actually use.
TL;DR
Stripe webhooks require: (1) HTTPS endpoint with proper signature verification, (2) idempotent event handlers, (3) proper error handling with 2xx responses, (4) webhook secret management via environment variables. Test locally with Stripe CLI v1.15.x+, deploy with retry logic, monitor event latency in Stripe Dashboard.
---
Why Webhooks Matter for Your SaaS
Webhooks are Stripe's way of telling your application about events—subscription renewals, payment failures, customer disputes. Without them, your SaaS becomes a glorified payment form. You won't know when:
Ignoring webhooks means angry customers with no access and you with no revenue tracking.
Architecture: The Mental Model
Stripe sends HTTP POST requests to your endpoint. Your job:
1. Verify the signature (Stripe signs every webhook) 2. Process the event idempotently (handle duplicates gracefully) 3. Respond with 2xx quickly (Stripe times out after 25 seconds) 4. Log everything (you'll need audit trails) 5. Retry on your end (Stripe retries, but have your own safety net)
---
Step 1: Create Your Webhook Endpoint
Node.js + Express (production pattern):
```javascript // webhooks.js - verify in official docs for latest Stripe SDK version const express = require('express'); const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const router = express.Router();
// Critical: use raw body, not JSON middleware router.post( '/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => { const sig = req.headers['stripe-signature']; let event;
try {
// Verify signature - this is non-negotiable
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.error(⚠️ Webhook signature verification failed., err.message);
return res.status(400).send(Webhook Error: ${err.message});
}
// Process event based on type
try {
switch (event.type) {
case 'customer.subscription.updated':
await handleSubscriptionUpdate(event.data.object);
break;
case 'customer.subscription.deleted':
await handleSubscriptionCancelled(event.data.object);
break;
case 'invoice.payment_succeeded':
await handlePaymentSucceeded(event.data.object);
break;
case 'invoice.payment_failed':
await handlePaymentFailed(event.data.object);
break;
case 'charge.dispute.created':
await handleChargeback(event.data.object);
break;
default:
console.log(Unhandled event type ${event.type});
}
// Acknowledge receipt immediately
res.json({ received: true });
} catch (err) {
console.error(Error processing webhook event ${event.id}:, err);
// Return 500 so Stripe retries - don't return 200 on failure
res.status(500).send('Internal Server Error');
}
}
);
module.exports = router; ```
Step 2: Implement Idempotent Handlers
Stripe may send the same webhook multiple times. Your handler must be idempotent:
```javascript
async function handleSubscriptionUpdate(subscription) {
// Check if we already processed this event
const existingRecord = await db.webhookEvents.findOne({
stripeEventId: event.id,
type: 'subscription.updated'
});
if (existingRecord) {
console.log(Duplicate webhook ${event.id} - skipping);
return; // Already processed
}
// Safe to process await db.subscriptions.updateOne( { customerId: subscription.customer }, { status: subscription.status, currentPeriodEnd: new Date(subscription.current_period_end * 1000), plan: subscription.items.data[0].price.id, updatedAt: new Date() } );
// Record that we handled this await db.webhookEvents.insertOne({ stripeEventId: event.id, type: 'subscription.updated', processedAt: new Date() }); } ```
Step 3: Local Testing with Stripe CLI
Install Stripe CLI (verify current version at official docs):
```bash
macOS
brew install stripe/stripe-cli/stripeVerify version
stripe versionOutput should show v1.15.x or later for 2026
Login to your account
stripe loginForward webhooks to local endpoint
stripe listen --forward-to localhost:3000/webhooks/stripeOutput: Ready! Your webhook signing secret is whsec_xxxxxxxxxxxx
Trigger test events in another terminal
stripe trigger payment_intent.succeeded stripe trigger customer.subscription.updated ```Common errors you'll see:
```
Error: Unable to verify webhook signature for: whsec_test1234
→ Fix: Webhook secret mismatch. Copy exact secret from stripe listen output
Error: Cannot read property 'customer' of undefined
→ Fix: Event data structure changed. Log console.log(event.data.object) to inspect
Error: 2 UNKNOWN: stream terminated by RST_STREAM → Fix: Endpoint timeout or network issue. Ensure response sent within 25 seconds ```
Step 4: Environment Variables
```bash
.env.local
STRIPE_SECRET_KEY=sk_live_xxxxxxxxxxxx # or sk_test_ for development STRIPE_WEBHOOK_SECRET=whsec_live_xxxxx # From Stripe Dashboard → Webhooks STRIPE_PUBLISHABLE_KEY=pk_live_xxxx # For frontend ```Step 5: Dashboard Configuration
1. Go to [Stripe Dashboard](https://dashboard.stripe.com) → Developers → Webhooks
2. Click "Add endpoint"
3. URL: https://yourdomain.com/webhooks/stripe
4. Select events:
- customer.subscription.created
- customer.subscription.updated
- customer.subscription.deleted
- invoice.payment_succeeded
- invoice.payment_failed
- charge.dispute.created
5. Copy the signing secret into your .env
Production Checklist
Monitoring and Debugging
Stripe Dashboard shows:
Set up alerts when webhook delivery fails >5% in an hour.
---
Related Reading
Official Documentation
---
What am I missing?
What gotchas did you hit implementing Stripe webhooks? What should indie hackers know that isn't in the official docs? Drop corrections, experiences with webhook failures, or production patterns in the comments below.