Stripe Webhooks Setup for SaaS in 2026
Production-ready webhook patterns for Stripe events. Error handling, idempotency, and security best practices for indie SaaS.
TL;DR
Stripe webhooks let your SaaS react to payment events in real-time. Set up a HTTPS endpoint, verify signatures using your webhook secret, implement idempotent handlers, and test with the Stripe CLI. Always validate webhook signatures—this is non-negotiable.
---
Why Webhooks Matter for SaaS
Polling Stripe's API every 30 seconds to check payment status doesn't scale. Webhooks push events to your server instantly when customers upgrade, downgrade, or churn. For indie SaaS, reliable webhook handling directly impacts:
Getting this wrong means silent failures, angry customers, and revenue gaps.
---
Step 1: Create a Webhook Endpoint
Your webhook endpoint must: 1. Accept POST requests over HTTPS (never HTTP) 2. Return 200 OK within 300 seconds 3. Verify the Stripe signature 4. Process idempotently
Here's a Node.js example using Express.js (v4.18+) and stripe package (v14.8.0+—verify in official docs):
```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 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,
webhookSecret
);
} catch (err) {
console.error(⚠️ Webhook signature verification failed: ${err.message});
return res.status(400).send(Webhook Error: ${err.message});
}
// Handle event type
switch (event.type) {
case 'customer.subscription.updated':
await handleSubscriptionUpdate(event.data.object);
break;
case 'invoice.payment_succeeded':
await handlePaymentSuccess(event.data.object);
break;
case 'customer.subscription.deleted':
await handleSubscriptionCanceled(event.data.object);
break;
default:
console.log(Unhandled event type: ${event.type});
}
res.json({received: true}); });
app.listen(3000, () => console.log('Webhook server running')); ```
Critical: Use express.raw() for the webhook route. Using express.json() globally will break signature verification because the signature is computed on the raw request body.
---
Step 2: Implement Idempotent Handlers
Webhooks can fire multiple times (network issues, Stripe retries). Your handlers must be idempotent—processing the same event twice produces the same result.
Pattern: Store event IDs in your database:
```javascript
const handlePaymentSuccess = async (invoice) => {
const eventId = invoice.id; // Use Stripe object ID as idempotency key
// Check if already processed
const existing = await db.query(
'SELECT id FROM processed_events WHERE stripe_id = $1',
[eventId]
);
if (existing.rows.length > 0) {
console.log(Event ${eventId} already processed, skipping);
return; // Idempotent exit
}
// Process payment const customerId = invoice.customer; await db.query( 'UPDATE subscriptions SET status = $1, processed_at = NOW() WHERE customer_id = $2', ['active', customerId] );
// Mark event as processed await db.query( 'INSERT INTO processed_events (stripe_id, type) VALUES ($1, $2)', [eventId, 'invoice.payment_succeeded'] ); }; ```
---
Step 3: Register Webhook in Stripe Dashboard
1. Log into [Stripe Dashboard](https://dashboard.stripe.com)
2. Navigate: Developers → Webhooks
3. Click Add endpoint
4. Enter your endpoint URL: https://yourdomain.com/webhooks/stripe
5. Select events:
- customer.subscription.created
- customer.subscription.updated
- customer.subscription.deleted
- invoice.payment_succeeded
- invoice.payment_failed
6. Copy the Signing Secret and set as STRIPE_WEBHOOK_SECRET environment variable
For testing: You'll get a separate webhook secret for test mode vs. live mode.
---
Step 4: Test with Stripe CLI
The Stripe CLI (v1.19.1+—verify in official docs) lets you test webhooks locally:
```bash
Install Stripe CLI
brew install stripe/stripe-cli/stripe # macOSor download from https://github.com/stripe/stripe-cli/releases
Login
stripe loginForward webhooks to localhost
stripe listen --forward-to localhost:3000/webhooks/stripeIn another terminal, trigger test events
stripe trigger customer.subscription.updated ```---
Common Errors You'll See
Error 1: Invalid Signature
``` WebhookSignatureVerificationError: No signatures found matching the expected signature for payload. ```Cause: Wrong webhook secret or raw body not passed to stripe.webhooks.constructEvent(). Fix: Ensure express.raw() is used only on the webhook route, not globally.
Error 2: Timeout (300s exceeded)
``` {"error": {"message": "Webhook endpoint did not return a 2xx or 3xx status code; we will retry sending this webhook."}} ```Cause: Database query or external API call took too long. Fix: Queue heavy work (email notifications, analytics) to a background job processor like Bull or pg-boss. Return 200 immediately.
Error 3: Duplicate Processing
``` Duplicate key value violates unique constraint "subscriptions_customer_id_key" ```Cause: Webhook fired twice, handler processed both. Fix: Implement idempotency check as shown in Step 2.
---
Production Checklist
See [Stripe's official webhook guide](https://stripe.com/docs/webhooks) for the complete reference.
For deeper subscription logic, check [SaaS billing fundamentals](/?guide=saas-billing) and [Handling failed payments](/?guide=failed-payments).
---
What am I missing?
This guide covers the happy path. Leave a comment if you've encountered:
Build reliable, your users depend on it.