Stripe Webhooks Setup for SaaS in 2026
Production-ready webhook implementation for indie SaaS. Real error patterns, exact code, and critical gotchas that will save you hours of debugging.
TL;DR
Set up Stripe webhooks by (1) creating an endpoint in your dashboard, (2) signing requests with your endpoint secret, (3) handling events idempotently, (4) retrying failed processing. Most failures stem from timestamp validation, missing idempotency keys, or race conditions in database writes. Test with stripe listen before production.
---
Why Webhooks Matter for SaaS
Stripe webhooks are your source of truth for subscription changes, payments, and disputes. Unlike polling, webhooks fire immediately when events occur—critical for dunning flows, seat count updates, and cancellation workflows.
Skipping proper webhook setup? You'll lose revenue on failed charges you didn't retry, charge customers twice, or grant access before payment clears.
Step 1: Create Your Endpoint
In [Stripe Dashboard](https://dashboard.stripe.com) → Developers → Webhooks:
1. Click "Add endpoint"
2. Enter your URL: https://yourdomain.com/api/webhooks/stripe
3. Select events:
- charge.failed
- charge.succeeded
- customer.subscription.updated
- customer.subscription.deleted
- invoice.payment_failed
- invoice.payment_succeeded
Stripe assigns you a signing secret (starts with whsec_). Save this in your .env:
```bash STRIPE_WEBHOOK_SECRET=whsec_your_secret_here ```
Do NOT commit this to version control.
Step 2: Verify Request Signatures
This is non-negotiable. Every webhook request includes a Stripe-Signature header. Stripe signs with HMAC-SHA256. Verify it before processing.
Node.js/Express (Stripe SDK v14+):
```javascript import Stripe from 'stripe'; import express from 'express';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); const app = express();
// IMPORTANT: Use raw body for webhook verification // If using body-parser, configure this route BEFORE middleware app.post( '/api/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => { const sig = req.headers['stripe-signature']; const secret = process.env.STRIPE_WEBHOOK_SECRET;
let event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, secret);
} catch (err) {
console.error(Webhook signature verification failed: ${err.message});
return res.status(400).send(Webhook Error: ${err.message});
}
// Process event asynchronously
handleWebhookEvent(event).catch((err) => {
console.error(Event processing failed: ${event.id}, err);
// Log to your error tracking (Sentry, etc.)
});
// Acknowledge receipt immediately res.json({ received: true }); } );
async function handleWebhookEvent(event) {
switch (event.type) {
case 'charge.succeeded':
await handleChargeSucceeded(event.data.object);
break;
case 'charge.failed':
await handleChargeFailed(event.data.object);
break;
case 'customer.subscription.updated':
await handleSubscriptionUpdated(event.data.object);
break;
case 'customer.subscription.deleted':
await handleSubscriptionDeleted(event.data.object);
break;
default:
console.log(Unhandled event type: ${event.type});
}
}
```
Common Error #1: Raw Body Not Used
```
Error: No signatures found matching the expected signature for payload.
```
Fix: Ensure webhook route uses express.raw() BEFORE other body parsers apply.
Step 3: Idempotent Processing
Webhooks can fire multiple times (network retries, Stripe retries). Your code must handle duplicates safely.
Store processed event IDs:
```javascript async function handleChargeSucceeded(charge) { const eventId = charge.id; // or use metadata if needed
// Check if already processed const existing = await db.query( 'SELECT id FROM processed_charges WHERE stripe_charge_id = $1', [charge.id] );
if (existing.rows.length > 0) {
console.log(Charge ${charge.id} already processed, skipping);
return;
}
// Process the charge await db.query('BEGIN'); try { await db.query( 'INSERT INTO processed_charges (stripe_charge_id, amount, processed_at) VALUES ($1, $2, NOW())', [charge.id, charge.amount] ); await updateSubscriptionStatus(charge.customer); await db.query('COMMIT'); } catch (err) { await db.query('ROLLBACK'); throw err; } } ```
Critical Pattern: Use database transactions. Insert the event record atomically with business logic changes.
Step 4: Testing Locally
Use the Stripe CLI ([install here](https://stripe.com/docs/stripe-cli)):
```bash
v1.19+ supports login flow
stripe loginForward events to your local server
stripe listen --events charge.succeeded,charge.failed --forward-to localhost:3000/api/webhooks/stripeIn another terminal, trigger test events
stripe trigger charge.succeeded ```The CLI outputs your signing secret for local testing: ``` Your webhook signing secret is: whsec_test_... ```
Step 5: Handle Edge Cases
Race Condition Example:
Webhook fires before your checkout page redirect completes. Customer sees "pending" but webhook already updated their subscription.
Fix: Query Stripe API for current state if webhook hasn't arrived:
```javascript async function getSubscriptionStatus(customerId) { const subs = await stripe.subscriptions.list( { customer: customerId, limit: 1 }, { apiVersion: '2023-10-16' } // verify in official docs ); return subs.data[0]; } ```
Common Error #2: Timestamp Validation Fails ``` Error: Request timestamp outside the tolerance window. ``` Fix: Ensure server time is NTP-synced. Stripe allows ±5 minutes by default; adjust if needed:
```javascript event = stripe.webhooks.constructEvent(req.body, sig, secret, 300); // 5 min tolerance ```
Common Error #3: Event Handler Timeout ``` Error: Task timed out after 30000ms ``` Fix: Return 200 immediately, process async:
```javascript res.json({ received: true }); await handleWebhookEvent(event); // Don't await before responding ```
Monitoring & Debugging
In your Stripe Dashboard, webhook delivery shows:
Check the "Logs" tab for raw request/response bodies.
For production, log all webhook processing:
```javascript
console.log([${new Date().toISOString()}] Event: ${event.id} Type: ${event.type});
```
Verify with Official Docs
---
Related Guides
---
What am I missing?
This guide covers the critical path for indie SaaS. But webhook handling is deep:
Share your webhook gotchas in the comments. This guide will be updated based on real feedback.