Stripe Webhooks for Indie SaaS 2026: Setup Guide
Production-ready webhook implementation for Stripe. Handle events, verify signatures, retry logic, and common pitfalls indie developers face.
TL;DR
Set up Stripe webhooks by creating an endpoint that verifies signatures using your signing secret, processes events idempotently, and returns 2xx status codes. Use the official Stripe libraries (v14.0.0+ for Node.js, verify in official docs for your language), implement exponential backoff for retries, and test locally with the Stripe CLI before deploying.
---
Why Webhooks Matter for Your SaaS
Webhooks are how Stripe tells your application about events: subscription renewals, payment failures, customer disputes. Unlike polling, webhooks are real-time and cost-efficient. For indie SaaS, this means:
---
Step 1: Create Your Webhook Endpoint
Your endpoint needs to: 1. Accept POST requests from Stripe 2. Verify the signature (critical for security) 3. Process the event idempotently 4. Return 200 quickly
Node.js/Express Example (Stripe SDK v14.0.0+)
```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 required 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(Signature verification failed: ${err.message});
return res.status(400).send(Webhook Error: ${err.message});
}
// Process asynchronously to return 200 immediately
handleWebhookEvent(event).catch((err) => {
console.error(Webhook processing error for ${event.id}:, err);
// Log for manual retry investigation
});
res.status(200).json({ received: true }); } );
async function handleWebhookEvent(event) { // Idempotency check: prevent duplicate processing const existingRecord = await db.webhookLog.findUnique({ where: { stripeEventId: event.id }, });
if (existingRecord) {
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_failed':
await handlePaymentFailure(event.data.object);
break;
case 'charge.dispute.created':
await handleDispute(event.data.object);
break;
default:
console.log(Unhandled event type: ${event.type});
}
// Log successful processing await db.webhookLog.create({ stripeEventId: event.id, eventType: event.type, processedAt: new Date(), }); }
async function handleSubscriptionUpdate(subscription) { const customer = await db.user.findUnique({ where: { stripeCustomerId: subscription.customer }, });
if (!customer) {
console.error(Customer not found: ${subscription.customer});
return;
}
await db.user.update({ where: { id: customer.id }, data: { subscriptionStatus: subscription.status, currentPeriodEnd: new Date(subscription.current_period_end * 1000), }, }); }
app.listen(3000); ```
---
Step 2: Retrieve Your Signing Secret
1. Log into [Stripe Dashboard](https://dashboard.stripe.com)
2. Navigate to Developers > Webhooks
3. Click Add endpoint
4. Enter your URL: https://yourdomain.com/webhooks/stripe
5. Select events: customer.subscription.updated, invoice.payment_failed, charge.dispute.created (minimum for SaaS)
6. Copy the Signing secret (starts with whsec_)
7. Add to .env: STRIPE_WEBHOOK_SECRET=whsec_...
Important: Never commit this to git. Use environment variables.
---
Step 3: Test Locally with Stripe CLI
[Stripe CLI documentation](https://stripe.com/docs/stripe-cli)
Install Stripe CLI (verify latest version in official docs):
```bash
macOS
brew install stripe/stripe-cli/stripeVerify
stripe version ```Authenticate and forward events:
```bash stripe login
Opens browser for authentication
stripe listen --forward-to localhost:3000/webhooks/stripe
Output:
> Ready! Your webhook signing secret is whsec_test_...
```In another terminal, trigger a test event:
```bash stripe trigger customer.subscription.updated ```
Check your console output. You should see the event processed without errors.
---
Common Console Errors & Solutions
Error 1: "No signatures found matching the expected signature"
``` Error: No signatures found matching the expected signature for payload. Webhook signing secret: whsec_test_*** ```
Cause: Webhook secret mismatch or body was modified before signature verification.
Fix: Ensure express.raw() middleware is used and secret is correct.
```javascript // Wrong - won't work app.use(express.json()); app.post('/webhooks/stripe', (req, res) => { /* ... */ });
// Correct - use raw for this route only app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => { /* ... */ }); ```
Error 2: "Timeout: endpoint did not respond within 10 seconds"
Stripe waits max 10 seconds for a 2xx response. Long database operations cause timeouts.
Fix: Return 200 immediately, process asynchronously.
```javascript // Bad res.json(await slowDatabaseOperation());
// Good setImmediate(() => slowDatabaseOperation()); res.status(200).json({ received: true }); ```
Error 3: "Duplicate event processing detected"
Stripe retries failed webhooks. Without idempotency checks, you'll charge customers twice.
Fix: Store event.id and check before processing (see example above).
---
Production Checklist
event.id---
Related Reading
[Handling Failed Payments](/?guide=stripe-retry-logic) | [Setting Up Subscriptions](/?guide=stripe-subscriptions)
---
Resources
---
What am I missing?
This guide covers the core setup. What else should indie developers know about Stripe webhooks?
Please share in comments—this is a living guide.