Stripe Webhooks Setup for Indie SaaS in 2026
Production-ready webhook implementation for Stripe events. Handle payment confirmations, subscription changes, and failures with error handling patterns.
TL;DR
Stripe webhooks listen for payment events asynchronously. You need: (1) a public HTTPS endpoint, (2) webhook secret verification, (3) idempotent event handlers, (4) retry logic for failures. Use Stripe CLI v1.18+ (verify in official docs) for local testing. Never trust the request body without signature validation.
---
Why Webhooks Matter for Indie SaaS
Webhooks solve the polling problem. Instead of asking Stripe "did the payment process?" every 30 seconds, Stripe tells you immediately. For SaaS with subscription billing, webhooks handle:
Missing webhook handlers = customers can't activate features, churn tracking breaks, and refunds go unnoticed.
---
Step 1: Create Your Webhook Endpoint
Your endpoint must be:
Here's a production-ready Node.js/Express example:
```javascript const express = require('express'); const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); const app = express();
// Raw body required for signature verification
app.post('/api/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});
}
// Always respond 200 immediately res.status(200).json({received: true});
// Process event async in background
processEvent(event).catch(err => {
console.error(Webhook event processing failed: ${event.id} - ${err.message});
// Your alerting/logging here
});
}
);
async function processEvent(event) {
switch(event.type) {
case 'charge.succeeded':
await handleChargeSucceeded(event.data.object);
break;
case 'customer.subscription.updated':
await handleSubscriptionUpdate(event.data.object);
break;
case 'charge.failed':
await handleChargeFailed(event.data.object);
break;
default:
console.log(Unhandled event type: ${event.type});
}
}
async function handleChargeSucceeded(charge) { // Check if already processed (idempotency key) const existing = await db.payments.findOne({stripe_charge_id: charge.id}); if (existing) return; // Already processed // Create payment record await db.payments.create({ stripe_charge_id: charge.id, customer_id: charge.customer, amount: charge.amount, currency: charge.currency, status: 'completed', processed_at: new Date(charge.created * 1000) }); // Update subscription status await db.subscriptions.updateOne( {stripe_customer_id: charge.customer}, {status: 'active', last_payment_date: new Date()} ); }
async function handleChargeFailed(charge) { const existing = await db.payment_failures.findOne({stripe_charge_id: charge.id}); if (existing) return; await db.payment_failures.create({ stripe_charge_id: charge.id, customer_id: charge.customer, error_message: charge.failure_message, error_code: charge.failure_code, created_at: new Date() }); // Notify customer or trigger retry flow await sendPaymentFailureEmail(charge.customer, charge.failure_message); }
app.listen(3000); ```
---
Step 2: Register Webhook in Stripe Dashboard
1. Go to [Stripe Dashboard → Developers → Webhooks](https://dashboard.stripe.com/webhooks)
2. Click "Add endpoint"
3. Enter your endpoint URL: https://yourdomain.com/api/webhooks/stripe
4. Select events to listen for (minimum):
- charge.succeeded
- charge.failed
- customer.subscription.created
- customer.subscription.updated
- customer.subscription.deleted
5. Copy the Signing Secret (starts with whsec_)
6. Add to your .env: STRIPE_WEBHOOK_SECRET=whsec_...
---
Step 3: Test Locally with Stripe CLI
[Install Stripe CLI](https://stripe.com/docs/stripe-cli) (verify version 1.18+ in official docs):
```bash
Forward events to your local endpoint
stripe listen --forward-to localhost:3000/api/webhooks/stripe ```This outputs a signing secret. Copy it:
```bash export STRIPE_WEBHOOK_SECRET="whsec_test_..." ```
Trigger test events:
```bash
Test successful charge
stripe trigger charge.succeededTest failed charge
stripe trigger charge.failed ```Watch your server logs for processing.
---
Common Error Messages You'll See
Error 1: Signature Verification Failure
``` Webhook signature verification failed: No signatures found matching the expected signature for payload. ``` Cause: Wrong webhook secret, or signature header missing Fix: VerifySTRIPE_WEBHOOK_SECRET matches dashboard value exactlyError 2: Request Body Not Raw
``` Webhook signature verification failed: Timestamp outside the tolerance zone ``` Cause: Body was parsed as JSON instead of kept as raw buffer Fix: Useexpress.raw({type: 'application/json'}) middleware before other parsersError 3: Timeout
``` Webhook endpoint timed out after 5 seconds ``` Cause: Processing logic inside the webhook handler Fix: Always respond 200 immediately, process in background queue (Redis, Bull, SQS)---
Production Patterns
1. Queue Processing (Critical)
Never do database writes inside the webhook handler:
```javascript res.status(200).json({received: true});
// Push to queue instead await queue.add('process-webhook', event, { attempts: 3, backoff: {type: 'exponential', delay: 2000} }); ```
2. Idempotency (Critical)
Stripe can resend events. Check event.id hasn't been processed:
```javascript const processed = await db.webhook_events.findOne({event_id: event.id}); if (processed) return;
await db.webhook_events.create({event_id: event.id, type: event.type}); // Then process... ```
3. Logging (Essential)
Log every event with context for debugging:
```javascript logger.info('Webhook received', { event_id: event.id, event_type: event.type, timestamp: new Date(), customer_id: event.data.object.customer }); ```
---
Related Resources
See also: [SaaS Billing Setup Guide](/?guide=saas-billing) and [Stripe API Error Handling](/?guide=stripe-errors)
---
What am I missing?
Have you encountered webhook issues I haven't covered?
Drop corrections and experiences in the comments below.