Stripe Webhooks for SaaS: Production Setup 2026
Complete webhook setup guide for indie SaaS. Real error patterns, idempotency keys, retry logic, and production-ready code examples.
TL;DR
Stripe webhooks require: (1) HTTPS endpoint with signature verification, (2) idempotent event handlers, (3) proper retry/timeout logic. Most failures stem from missing signature validation or non-idempotent handlers processing duplicate events. Use stripe-cli for local testing before production.
---
Why Webhooks Matter for SaaS
Webhooks are Stripe's way of telling your system: "Hey, something happened—subscription renewed, payment failed, customer added a card." Unlike polling, webhooks push events to you in near real-time.
For indie SaaS, this means:
The catch? Webhooks are *async and unpredictable*. Events arrive out of order. Networks fail. Your server crashes mid-processing. If you don't handle this, you'll ship broken behavior that's hard to debug.
---
Step 1: Create Your Webhook Endpoint
Your endpoint must: 1. Accept POST requests 2. Verify Stripe's signature 3. Return 200 within 30 seconds 4. Be publicly accessible over HTTPS
Here's a production-ready Node.js example (Express, stripe@latest — verify in official docs for current version):
```javascript import express from 'express'; import Stripe from 'stripe';
const app = express(); const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); const webhookSecret = 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,
webhookSecret
);
} catch (err) {
console.error(❌ Webhook signature verification failed: ${err.message});
return res.status(400).send(Webhook Error: ${err.message});
}
// Respond immediately, process async res.json({ received: true });
// Handle event asynchronously
processWebhookEvent(event).catch((err) => {
console.error(
Failed to process event ${event.id}: ${err.message}
);
// Log to your error tracking (Sentry, etc.)
});
}
);
async function processWebhookEvent(event) {
switch (event.type) {
case 'customer.subscription.updated':
await handleSubscriptionUpdate(event.data.object);
break;
case 'invoice.payment_succeeded':
await handlePaymentSuccess(event.data.object);
break;
case 'invoice.payment_failed':
await handlePaymentFailed(event.data.object);
break;
default:
console.log(Unhandled event type: ${event.type});
}
}
app.listen(3000, () => console.log('Webhook server running')); ```
Critical detail: express.raw() middleware is non-negotiable. Using express.json() before signature verification will corrupt the request body and cause this error:
``` Error: No signatures found matching the expected signature for payload. ```
---
Step 2: Implement Idempotent Handlers
Stripe *will* retry events. Your code must handle processing the same event multiple times without creating duplicates.
```javascript async function handlePaymentSuccess(invoice) { const db = getDatabase(); // Stripe's event ID is globally unique and persists across retries const existingRecord = await db.query( 'SELECT id FROM invoice_processed WHERE stripe_event_id = ?', [invoice.id] );
if (existingRecord) {
console.log(Event ${invoice.id} already processed, skipping);
return;
}
// Process once await db.query( 'INSERT INTO invoice_processed (stripe_event_id, customer_id, amount) VALUES (?, ?, ?)', [invoice.id, invoice.customer, invoice.amount_paid] );
// Grant access, send receipt, etc. await grantUserAccess(invoice.customer); } ```
This prevents the infamous "customer charged twice" nightmare.
---
Step 3: Local Testing with Stripe CLI
Before deploying, test locally. Install [Stripe CLI](https://stripe.com/docs/stripe-cli) (verify current version in official docs):
```bash stripe listen --forward-to localhost:3000/webhooks/stripe ```
This outputs a signing secret. Set it in .env:
``` STRIPE_WEBHOOK_SECRET=whsec_test_... ```
Then trigger test events:
```bash stripe trigger payment_intent.succeeded ```
You'll see your endpoint receive and process the event in real time.
---
Step 4: Configure in Stripe Dashboard
1. Go to [Developers → Webhooks](https://dashboard.stripe.com/webhooks)
2. Click "Add endpoint"
3. Enter your production URL: https://yourdomain.com/webhooks/stripe
4. Select events (at minimum: customer.subscription.updated, invoice.payment_succeeded, invoice.payment_failed)
5. Copy the signing secret into your .env for production
---
Common Errors & Solutions
Error 1: Signature Mismatch
``` Error: No signatures found matching the expected signature for payload. ``` Cause: Wrong body format (you parsed JSON before signature check) Fix: Useexpress.raw() or equivalent raw body middlewareError 2: Timeout
``` Unhandled promise rejection: Request timeout after 30000ms ``` Cause: Your handler takes >30 seconds Fix: Always respond immediately, queue processing asynchronouslyError 3: Duplicate Processing
``` Duplicate invoice charge detected for customer cus_12345 ``` Cause: Missing idempotency check Fix: Storeevent.id before processing, check it exists first---
Retry & Timeout Strategy
Stripe retries failed webhooks with exponential backoff for 3 days. For your handlers:
```javascript async function handlePaymentSuccess(invoice) { const maxRetries = 3; let attempt = 0;
while (attempt < maxRetries) { try { await grantUserAccess(invoice.customer); return; // Success } catch (err) { attempt++; if (attempt >= maxRetries) throw err; await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt))); } } } ```
---
Production Checklist
event.id)stripe-cli---
Key Differences from REST API
Webhooks give you *eventual consistency*. The REST API gives you *immediate, synchronous* responses. For subscription state, query the API at critical moments (user login, checkout) rather than trusting webhooks alone.
[Webhook reliability strategies](/?guide=webhook-reliability) [Subscription lifecycle best practices](/?guide=subscription-patterns)
---
What am I missing?
Have you hit webhook bugs in production? Specific Stripe event types causing headaches? Multi-tenant isolation gotchas? Drop corrections and war stories in the comments—this guide needs real-world input.
Official references: