Stripe Webhooks Setup for Indie SaaS 2026
Production-ready webhook implementation with error handling, retry logic, and real failure patterns indie developers encounter.
TL;DR
Set up Stripe webhooks by creating an endpoint that validates signatures using your signing secret, handles events idempotently, and responds with 200 status within 30 seconds. Use webhook retries for reliability. Test with the Stripe CLI before going live.
---
Why Webhooks Matter for Indie SaaS
Webhooks are your asynchronous lifeline. When customers subscribe, upgrade, or fail payment, Stripe pushes events to your server rather than you polling. For indie SaaS—where every database query costs money—webhooks eliminate wasteful polling and keep your billing logic real-time.
Getting this wrong means:
Step 1: Create Your Webhook Endpoint
Your endpoint must: 1. Accept POST requests 2. Validate Stripe's signature 3. Process events idempotently 4. Respond with 200 within 30 seconds
Here's a production-ready example using Node.js (Express 4.18.x) with the Stripe SDK (v14.0.0+):
```javascript const express = require('express'); const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); const app = express();
// Critical: use raw body parser ONLY for this route app.post( '/api/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,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.error(❌ Webhook signature verification failed: ${err.message});
return res.status(400).send(Webhook Error: ${err.message});
}
// Respond immediately, process asynchronously res.status(200).json({ received: true });
// Handle event in background
handleWebhookEvent(event).catch(err => {
console.error(Webhook processing error for event ${event.id}:, err);
// Log to error tracking (Sentry, LogRocket, etc.)
});
}
);
async function handleWebhookEvent(event) {
// Idempotency: Check if we've processed this event
const processed = await db.webhookEvents.findOne({ stripeId: event.id });
if (processed) {
console.log(Event ${event.id} already processed, skipping);
return;
}
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 handlePaymentFailure(event.data.object);
break;
case 'customer.subscription.deleted':
await handleSubscriptionCancellation(event.data.object);
break;
default:
console.log(Unhandled event type: ${event.type});
}
// Mark as processed await db.webhookEvents.create({ stripeId: event.id, type: event.type, processedAt: new Date(), }); }
app.listen(3000, () => console.log('Webhook server ready')); ```
Step 2: Validate Signature Correctly
This is non-negotiable. Stripe signs every webhook with your endpoint's signing secret. Never skip this.
```javascript // ❌ WRONG: Parsing JSON first const body = JSON.parse(req.body); const event = stripe.webhooks.constructEvent(body, sig, secret); // Fails!
// ✅ CORRECT: Pass raw buffer const event = stripe.webhooks.constructEvent(req.body, sig, secret); ```
Common console errors you'll encounter:
Error 1: "No matching signing secret found"
```
Error: No matching signing secret found for [wh_1234...]
```
This means your STRIPE_WEBHOOK_SECRET doesn't match what Stripe has. Verify in your Stripe Dashboard → Developers → Webhooks → Select endpoint → Signing secret. Copy the exact value (starts with whsec_).
Error 2: "Unexpected end of JSON input"
```
SyntaxError: Unexpected end of JSON input
at JSON.parse (<anonymous>)
```
You're using express.json() instead of express.raw() on this route. Stripe's signature verification requires the raw body.
Error 3: "Request body missing" ``` Error: No POST body detected. (If using express middleware, verify you passed raw body) ``` Same root cause—middleware parsed the body before verification.
Step 3: Register Webhook in Stripe Dashboard
1. Go to [Stripe Dashboard](https://dashboard.stripe.com) → Developers → Webhooks
2. Click "Add endpoint"
3. Enter your endpoint URL: https://yourdomain.com/api/webhooks/stripe
4. Select events you care about (at minimum: customer.subscription.*, invoice.payment_*)
5. Copy the signing secret (starts with whsec_)
6. Store in .env: STRIPE_WEBHOOK_SECRET=whsec_...
Step 4: Test Locally with Stripe CLI
Don't push untested webhooks to production.
```bash
Install Stripe CLI v1.17.0+
brew install stripe/stripe-cli/stripe # macOSLogin
stripe loginForward webhooks to localhost
stripe listen --forward-to localhost:3000/api/webhooks/stripeIn another terminal, trigger a test event
stripe trigger customer.subscription.updated ```You'll see output like: ``` > Ready! Your webhook signing secret is: whsec_test_... ```
Use this test secret in your .env during development.
Step 5: Implement Idempotency
Stripe retries failed webhooks up to 5 times over 3 days. If your handler crashes mid-process, you might process the same event twice.
Store every event ID in a database:
```javascript // Before processing const exists = await db.webhookEvents.findOne({ stripeId: event.id }); if (exists) return; // Already handled
// After successful processing await db.webhookEvents.create({ stripeId: event.id, processedAt: new Date() }); ```
Step 6: Handle These Core Events
invoice.payment_succeeded: Customer paid. Grant access.
invoice.payment_failed: Payment failed. Email customer, disable features if needed.
customer.subscription.updated: Plan change or quantity update. Apply changes immediately.
customer.subscription.deleted: Cancellation. Revoke access after trial period if configured.
For detailed event schema, [check Stripe's event reference](https://stripe.com/docs/api/events).
Production Checklist
For environment variable security patterns, see [managing secrets for indie SaaS](/?guide=env-security).
For handling subscription state, see [billing model patterns](/?guide=subscription-state).
What am I missing?
What gotchas have *you* hit with Stripe webhooks? Common issues:
Comment below with your war stories and I'll fold them into this guide.