Stripe Webhooks for SaaS: Production Setup Guide 2026
Complete webhook implementation for indie SaaS. Real error messages, production patterns, exact configs. Verify pricing in official docs.
TL;DR
Stripe webhooks require HTTPS endpoints, signature verification, and idempotent handlers. This guide covers production-ready setup with real error patterns you'll encounter, using Stripe API v2024-12-15 (verify in official docs for current version).
Why Webhooks Matter for Indie SaaS
If you're handling payments, you need webhooks. They're how Stripe tells you when:
Without them, you're polling the API (expensive) or missing critical events (dangerous).
Core Setup: The Three-Step Process
1. Create Your HTTPS Endpoint
Webhooks require HTTPS. If you're testing locally, use [ngrok](https://ngrok.com) or similar tunneling.
Express.js example (production-ready):
```javascript const express = require('express'); const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const app = express();
// Important: Raw body needed for signature verification app.post( '/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => { const sig = req.headers['stripe-signature']; const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
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});
}
// Process event
try {
await handleWebhookEvent(event);
res.json({ received: true });
} catch (err) {
console.error(Event processing error for ${event.type}:, err);
res.status(500).json({ error: 'Event processing failed' });
}
}
);
app.listen(3000, () => console.log('Webhook listener running')); ```
Critical detail: The express.raw() middleware is mandatory—without it, constructEvent() will fail silently or throw cryptic errors.
2. Register Endpoint in Stripe Dashboard
1. Go to [Stripe Dashboard](https://dashboard.stripe.com) → Developers → Webhooks
2. Click "Add endpoint"
3. Enter your URL: https://yourdomain.com/webhooks/stripe
4. Select events to receive (start with: charge.succeeded, charge.failed, customer.subscription.updated, customer.subscription.deleted)
5. Copy the signing secret to your .env: STRIPE_WEBHOOK_SECRET=whsec_...
Verify in official docs for current event types and their schemas at [Stripe Webhook Events](https://docs.stripe.com/api/events).
Real Error Messages You'll See
Error #1: Signature Verification Fails
``` Error: No signatures found matching the expected signature for payload. Received signature(s): t=1704067200,v1=abc123... ```
Cause: Missing express.raw() middleware or wrong webhook secret.
Fix: ```javascript // Wrong ❌ app.post('/webhooks/stripe', express.json(), (req, res) => { // req.body is parsed JSON, signature verification fails });
// Correct ✅ app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => { // req.body is Buffer, signature verifies }); ```
Error #2: Timeout on Stripe's Retry
``` "Webhook endpoint did not respond with a 2xx status code. Received status: 504" ```
Cause: Your handler is taking too long (Stripe times out after ~30 seconds).
Fix: Make webhook handlers async but don't await long operations:
```javascript app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => { const event = stripe.webhooks.constructEvent(...); // Respond immediately res.json({ received: true }); // Process in background handleWebhookEvent(event).catch(err => { console.error('Background processing failed:', err); // Send alert to Sentry/PagerDuty }); }); ```
Error #3: Duplicate Event Processing
``` Customer charged twice: charge_id=ch_1Abc... processed at 14:23:01 and 14:23:17 ```
Cause: Stripe retries failed webhooks; you processed the same event twice.
Fix: Make handlers idempotent:
```javascript
async function handleChargeSucceeded(event) {
const chargeId = event.data.object.id;
// Check if already processed
const existing = await db.payments.findOne({ stripe_charge_id: chargeId });
if (existing) {
console.log(Charge ${chargeId} already processed, skipping);
return;
}
// Create payment record
await db.payments.create({
stripe_charge_id: chargeId,
amount: event.data.object.amount,
customer_id: event.data.object.customer,
processed_at: new Date(),
});
}
```
Event Handler Pattern (Production-Ready)
```javascript const eventHandlers = { 'charge.succeeded': async (event) => { const { id, customer, amount } = event.data.object; await db.payments.create({ stripe_id: id, customer_id: customer, amount, status: 'succeeded', }); }, 'charge.failed': async (event) => { const { id, customer, failure_message } = event.data.object; await db.payments.create({ stripe_id: id, customer_id: customer, status: 'failed', error: failure_message, }); await notifyUser(customer, 'Payment failed'); }, 'customer.subscription.deleted': async (event) => { const { id, customer } = event.data.object; await db.subscriptions.updateOne( { stripe_subscription_id: id }, { status: 'cancelled', cancelled_at: new Date() } ); }, };
async function handleWebhookEvent(event) {
const handler = eventHandlers[event.type];
if (!handler) {
console.warn(No handler for event type: ${event.type});
return;
}
await handler(event);
}
```
Testing Locally
Use Stripe CLI to test without ngrok:
```bash
Install: verify installation in [Stripe CLI docs](https://docs.stripe.com/stripe-cli)
stripe listen --forward-to localhost:3000/webhooks/stripeIn another terminal
stripe trigger charge.succeeded ```You'll see the test event hit your handler.
Monitoring & Reliability
1. Log every event: Include event.id and timestamp for debugging
2. Use retry logic: Stripe retries failed webhooks up to 5 times over 3 days
3. Alert on failures: Send to Sentry, PagerDuty, or Slack
4. Test in dashboard: Stripe provides test event resend in the webhook details page
See [webhook reliability guide](/?guide=error-handling) and [monitoring SaaS payments](/?guide=payment-monitoring) for deeper dives.
Pricing & Rate Limits
Webhooks themselves are free. Verify current Stripe pricing and API limits in [official pricing docs](https://stripe.com/pricing)—pricing models change and we don't want outdated info.
Production Checklist
What am I missing?
Webhook setups vary wildly—database choices, async patterns, monitoring tools. What did I overlook? Are there error messages from your production logs that should be here? Drop corrections and additions in the comments. Especially flag outdated API versions or pricing info.