Stripe Webhooks Setup for Indie SaaS 2026
Production-ready webhook patterns, real error messages, and security best practices for Stripe integration in your indie SaaS app.
TL;DR
Set up Stripe webhooks with signed event verification, retry logic, and idempotency keys. Use [Stripe CLI v1.19.0+](https://stripe.com/docs/stripe-cli) for local testing. Handle webhook_endpoint.failed_call errors by validating signatures via crypto.timingSafeEqual(). Always store event IDs to prevent duplicate processing.
---
Why Webhooks Matter for Indie SaaS
Webhooks are how Stripe tells your app about events—subscription created, payment failed, customer deleted. Unlike polling (inefficient), webhooks fire immediately. For indie hackers, this means:
But webhooks are asynchronous and untrusted by default. Miss the security part, and someone crafts a fake event to unlock premium features.
---
Step 1: Create a Webhook Endpoint
First, add a route to receive POST requests from Stripe:
```javascript // Node.js + Express example (production-ready) const express = require('express'); const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); const crypto = require('crypto');
const app = express();
// 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']; 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 400 so Stripe knows verification failed
return res.status(400).send(Webhook Error: ${err.message});
}
// Idempotency: store processed event IDs
const isProcessed = await checkEventProcessed(event.id);
if (isProcessed) {
console.log(Event ${event.id} already processed, skipping);
return res.json({ received: true });
}
// Route to handler try { await handleStripeEvent(event); await markEventProcessed(event.id); res.json({ received: true }); } catch (err) { console.error('Event handler failed:', err); // Return 500 so Stripe retries res.status(500).send('Event processing failed'); } } );
async function handleStripeEvent(event) {
switch (event.type) {
case 'customer.subscription.updated':
await onSubscriptionUpdated(event.data.object);
break;
case 'invoice.payment_succeeded':
await onPaymentSucceeded(event.data.object);
break;
case 'customer.subscription.deleted':
await onSubscriptionCanceled(event.data.object);
break;
default:
console.log(Unhandled event type: ${event.type});
}
}
```
Why this pattern?
express.raw(): Stripe signs the raw bytes. JSON parsing breaks the signature.stripe.webhooks.constructEvent(): Verifies signature automatically using crypto.timingSafeEqual() internally.500 on handler failure: Stripe retries with exponential backoff.---
Step 2: Register Endpoint in Stripe Dashboard
1. Go to [Stripe Dashboard → Developers → Webhooks](https://dashboard.stripe.com/webhooks)
2. Click "Add endpoint"
3. Enter your public URL: https://yourdomain.com/webhooks/stripe
4. Select events:
- customer.subscription.updated
- customer.subscription.deleted
- invoice.payment_succeeded
- invoice.payment_failed
5. Copy the Signing Secret to your .env: STRIPE_WEBHOOK_SECRET=whsec_live_...
Verify in [official docs](https://stripe.com/docs/webhooks/setup) for current event types if building beyond 2026.
---
Step 3: Local Testing with Stripe CLI
Don't test webhooks against production. Use Stripe CLI (v1.19.0+):
```bash
Install (verify current version in official docs)
brew install stripe/stripe-cli/stripeLogin and link your account
stripe loginStart listening (creates secure tunnel to localhost:3000)
stripe listen --events customer.subscription.updated,invoice.payment_succeeded --forward-to localhost:3000/webhooks/stripeIn another terminal, trigger test events
stripe trigger customer.subscription.updated stripe trigger invoice.payment_succeeded ```Expected console output: ``` 2024-01-15 10:23:45 → customer.subscription.updated [evt_1234] 2024-01-15 10:23:45 ← [200] Webhook processed successfully ```
---
Common Error Messages & Fixes
Error 1: "No signatures found matching the expected signature"
``` Webhook signature verification failed: No signatures found matching the expected signature for payload. Webhook id: evt_.... ```
Cause: Using JSON-parsed body instead of raw bytes.
Fix: Ensure express.raw() middleware and verify STRIPE_WEBHOOK_SECRET matches dashboard.
Error 2: "Webhook endpoint returned HTTP 500"
Stripe retries with delays: 5s, 5m, 30m, 2h, 5h, 10h.
``` Webhook endpoint failed: returned status 500 (Internal Server Error) Event evt_123 queued for retry ```
Cause: Handler threw error (likely database unavailable).
Fix: Wrap handler in try-catch, return 500 only for transient errors. Log to external service (Sentry, DataDog).
Error 3: "Idempotency error: Event processed twice"
``` Duplicate event processing detected for evt_123 ```
Cause: No idempotency check; processed same event twice.
Fix: Store event.id in database before handler runs. Check before processing.
---
Idempotency Pattern (Database Example)
```javascript // Supabase/PostgreSQL example async function checkEventProcessed(eventId) { const { data } = await supabase .from('stripe_events') .select('id') .eq('event_id', eventId) .single(); return !!data; }
async function markEventProcessed(eventId) { await supabase .from('stripe_events') .insert({ event_id: eventId, processed_at: new Date() }); } ```
---
Security Checklist
stripe.webhooks.constructEvent() for signature verificationSTRIPE_WEBHOOK_SECRET in .env, never commit500 on errors so Stripe retries200 only after processing succeeds---
Related Guides
---
What am I missing?
Stripe updates webhook event types quarterly. Did I miss a critical error you've hit? Are there v2 patterns for async handlers? Comment below with your production webhook gotchas—let's build the definitive guide together.
Verify current Stripe SDK versions in [official docs](https://github.com/stripe/stripe-node/releases) before deploying to production.