Stripe Webhooks Setup for Indie SaaS 2026
Production-ready Stripe webhook implementation with error handling, security patterns, and real debugging tips for indie developers.
TL;DR
Stripe webhooks require: (1) HTTPS endpoint with signature verification, (2) idempotent event handlers, (3) proper error responses to prevent retries. Use the Stripe CLI for local testing before going live. Always verify webhook secrets and implement database-level idempotency keys.
---
Why Webhooks Matter for SaaS
Webhooks are how Stripe notifies your application about customer events: successful payments, failed renewals, subscription updates, disputes. Without them, your SaaS won't know when to grant access, charge customers, or handle cancellations.
The alternative—polling Stripe's API every minute—wastes resources and creates race conditions. Webhooks are event-driven, reliable, and what production systems use.
Step 1: Create Your Webhook Endpoint
Your endpoint needs three properties:
HTTPS required - Stripe won't send to HTTP endpoints. If testing locally, use [Stripe CLI](https://stripe.com/docs/stripe-cli) instead of ngrok.
Stateless - Process each event independently. Don't assume ordering.
Fast responses - Return 200 OK within 30 seconds, or Stripe retries.
Here's a production-ready Node.js/Express example (using Express v4.x):
```javascript const express = require('express'); const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); const app = express();
// Raw body for signature verification
app.post(
'/webhooks/stripe',
express.raw({ type: 'application/json' }),
async (req, res) => {
const sig = req.headers['stripe-signature'];
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
endpointSecret
);
} catch (err) {
console.error(⚠️ Webhook signature verification failed: ${err.message});
return res.status(400).send(Webhook Error: ${err.message});
}
// Respond immediately to prevent retries
res.status(200).json({ received: true });
// Process async
handleWebhookEvent(event).catch(err => {
console.error(Error processing webhook ${event.id}: ${err.message});
// Log to error tracking (Sentry, etc)
});
}
);
async function handleWebhookEvent(event) {
const { type, data, id } = event;
// Idempotency: check if we've processed this before
const processed = await db.webhookEvents.findOne({ stripe_event_id: id });
if (processed) {
console.log(Event ${id} already processed, skipping);
return;
}
try {
switch (type) {
case 'customer.subscription.created':
await grantSubscriptionAccess(data.object);
break;
case 'invoice.payment_succeeded':
await logPayment(data.object);
break;
case 'customer.subscription.deleted':
await revokeSubscriptionAccess(data.object);
break;
case 'charge.dispute.created':
await alertAdmin(data.object);
break;
default:
console.log(Unhandled event type: ${type});
}
// Mark as processed
await db.webhookEvents.insertOne({
stripe_event_id: id,
type,
processed_at: new Date(),
status: 'success'
});
} catch (error) {
// Don't throw—log and continue
console.error(Failed to handle ${type}: ${error.message});
await db.webhookEvents.insertOne({
stripe_event_id: id,
type,
processed_at: new Date(),
status: 'failed',
error: error.message
});
}
}
```
Common Console Errors (And Fixes)
Error 1: Signature verification failed
```
Webhook Error: No signatures found matching the expected signature for payload.
Possible causes: (1) using wrong endpoint secret, (2) request body modified before verification
```
*Fix:* Use express.raw() middleware. Don't use express.json() before signature check. Verify your endpoint secret in [Stripe Dashboard](https://dashboard.stripe.com/webhooks) matches STRIPE_WEBHOOK_SECRET env var.
Error 2: Request timeout after 30 seconds ``` Stripe::InvalidRequestError: Webhook endpoint did not respond with a 2xx status code. Response status code: 504 Gateway Timeout ``` *Fix:* Return 200 immediately. Move heavy work to async job queue (Bull, RabbitMQ). If your database query takes 20 seconds, that's the problem.
Error 3: Duplicate processing
```
Customer charged twice for same subscription renewal.
Webhook event ID: evt_xxx processed at 14:32:01 and 14:32:04
```
*Fix:* Store processed event IDs with unique constraint on stripe_event_id. Query this table before processing any action.
Step 2: Register the Webhook in Stripe
Dashboard method:
1. Go to [Stripe Dashboard → Developers → Webhooks](https://dashboard.stripe.com/webhooks)
2. Click "Add endpoint"
3. Enter URL: https://yourapp.com/webhooks/stripe
4. Select events: customer.subscription.*, invoice.*, charge.dispute.*
5. Copy the signing secret to STRIPE_WEBHOOK_SECRET
Via API (verify in [official Stripe docs](https://stripe.com/docs/api/webhook_endpoints/create)): ```bash curl https://api.stripe.com/v1/webhook_endpoints \ -u sk_live_xxx: \ -d url='https://yourapp.com/webhooks/stripe' \ -d 'enabled_events[]'='customer.subscription.created' \ -d 'enabled_events[]'='invoice.payment_succeeded' \ -d 'enabled_events[]'='customer.subscription.deleted' ```
Step 3: Local Testing with Stripe CLI
Don't use ngrok. Use Stripe CLI (v1.18.0+, [verify in official docs](https://stripe.com/docs/stripe-cli)):
```bash
Install
brew install stripe/stripe-cli/stripe # macOSor download from https://github.com/stripe/stripe-cli/releases
Authenticate
stripe loginForward events to local endpoint
stripe listen --forward-to localhost:3000/webhooks/stripeIn another terminal, trigger test events
stripe trigger charge.dispute.created stripe trigger customer.subscription.created ```The CLI outputs your webhook signing secret—use this for local development.
Step 4: Production Checklist
express.raw() middleware for signature verificationstripe_event_idSTRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRETTesting the Full Flow
Create a test subscription via Stripe Dashboard, then in your CLI:
```bash stripe trigger customer.subscription.created \ --override customer.id=cus_TestCustomerID ```
Check your database for the processed event. Verify the customer got access.
What About Retries?
Stripe retries failed webhooks exponentially: 5 sec, 30 sec, 2 min, 5 min, 30 min, 2 hours, 5 hours, 10 hours. Your idempotency check prevents duplicate processing even on retries.
Monitor retry rates in the Stripe Dashboard. High retry rates mean your endpoint is slow or buggy.
Related Resources
[How to structure your database for SaaS subscriptions](/?guide=saas-database-schema)
[Handling failed payments and dunning](/?guide=stripe-dunning-flow)
---
What am I missing?
Did this guide miss something critical? Disagree with the patterns? Have a production horror story? Comment below. I update based on reader feedback—please share edge cases, version-specific issues, or alternative approaches you've validated in production.