Stripe Webhooks for SaaS 2026: Setup & Error Handling
Production-ready webhook implementation for Stripe in indie SaaS. Real error patterns, exact code, and gotchas developers miss.
TL;DR
Set up Stripe webhooks with signed verification, idempotency keys, and exponential backoff retry logic. Use Stripe CLI for local testing. Handle the three most common webhook failures: signature verification, duplicate processing, and timestamp validation. Always verify against [official Stripe docs](https://stripe.com/docs/webhooks) for current endpoints and payload schemas.
---
Why Webhooks Matter for SaaS
Webhooks are how Stripe notifies your application about customer payments, subscription changes, disputes, and refunds. Unlike polling the API every 30 seconds (wasteful), webhooks are event-driven and near-instantaneous.
For indie SaaS, this means:
---
Step 1: Create Your Webhook Endpoint
Your endpoint must: 1. Verify the signature using Stripe's secret 2. Return 200 immediately (process async) 3. Be idempotent (same event processed twice = same result)
Here's a Node.js/Express example using stripe@14.15.0 (verify in [npm](https://www.npmjs.com/package/stripe)):
```javascript const express = require('express'); const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); const app = express();
// Critical: use raw body 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,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.error(Webhook signature verification failed: ${err.message});
return res.status(400).send(Webhook Error: ${err.message});
}
// Return 200 immediately, process async res.status(200).json({ received: true });
// Queue event for processing await processWebhookEvent(event); } );
async function processWebhookEvent(event) {
const idempotencyKey = ${event.id}-${event.api_version};
// Check if already processed
const existing = await db.webhookLog.findOne({ idempotencyKey });
if (existing) {
console.log(Webhook ${idempotencyKey} already processed. Skipping.);
return;
}
try {
switch (event.type) {
case 'payment_intent.succeeded':
await handlePaymentSuccess(event.data.object);
break;
case 'customer.subscription.updated':
await handleSubscriptionUpdate(event.data.object);
break;
case 'customer.subscription.deleted':
await handleSubscriptionCancel(event.data.object);
break;
case 'charge.refunded':
await handleRefund(event.data.object);
break;
default:
console.log(Unhandled event type: ${event.type});
}
// Mark as processed
await db.webhookLog.create({ idempotencyKey, eventId: event.id });
} catch (err) {
console.error(Webhook processing error: ${err.message}, { eventId: event.id });
// Don't throw—log to error tracking (Sentry, etc)
await db.webhookFailure.create({ idempotencyKey, error: err.message });
}
}
```
---
Step 2: Local Testing with Stripe CLI
Don't test webhooks in production. Use [Stripe CLI](https://stripe.com/docs/stripe-cli):
```bash
Install (verify current version at official docs)
stripe loginForward webhooks to localhost:3000
stripe listen --forward-to localhost:3000/webhooks/stripeIn another terminal, trigger test events
stripe trigger payment_intent.succeeded stripe trigger customer.subscription.updated ```The CLI outputs a webhook signing secret—copy it to your .env as STRIPE_WEBHOOK_SECRET.
---
Step 3: Handle Common Webhook Failures
Error 1: Signature Mismatch
``` Error: No signatures found matching the expected signature for payload. Received signature(s): t=...,v1=... ```
Cause: Using parsed JSON instead of raw body, or wrong secret.
Fix: Ensure you're using express.raw() middleware and correct STRIPE_WEBHOOK_SECRET.
Error 2: Duplicate Processing
``` Error: Duplicate key error collection: webhooks index: { idempotencyKey: 1 } ```
Cause: Processing the same event twice (Stripe retries failed webhooks).
Fix: Always store processed event IDs and check before processing (shown in code above).
Error 3: Timestamp Too Old
``` Error: Webhook timestamp outside tolerance window (300s) ```
Cause: System clock skew or very old replay attempts.
Fix: Stripe's constructEvent() validates timestamps by default (5-minute window). Keep server time synchronized with NTP.
---
Step 4: Production Deployment
Environment Variables: ```bash STRIPE_SECRET_KEY=sk_live_... # from Stripe Dashboard STRIPE_WEBHOOK_SECRET=whsec_... # from Webhook Endpoints settings NODE_ENV=production ```
Webhook Endpoint Registration:
1. Go to [Stripe Dashboard](https://dashboard.stripe.com/) → Developers → Webhooks
2. Add endpoint: https://yourdomain.com/webhooks/stripe
3. Select events: payment_intent.succeeded, customer.subscription.updated, customer.subscription.deleted, charge.refunded
4. Copy signing secret to .env
Retry Logic: Stripe automatically retries failed webhooks (exponential backoff: 5s, 5m, 30m, 2h, 5h, 10h). Your endpoint must handle retries idempotently.
---
Monitoring & Debugging
Check webhook delivery status: ```bash stripe logs list # Via CLI ```
Store all webhook attempts: ```javascript await db.webhookLog.create({ idempotencyKey, eventId: event.id, eventType: event.type, processedAt: new Date(), status: 'success' | 'failed', error: err?.message }); ```
This enables debugging failed events and replaying if needed.
---
Best Practices Checklist
✅ Verify signatures with stripe.webhooks.constructEvent()
✅ Return 200 before async processing
✅ Implement idempotency with event ID + API version
✅ Use [database transactions](/?guide=database-transactions) for consistency
✅ Log all webhook attempts with timestamps
✅ Set up [error tracking](/?guide=error-monitoring) (Sentry, etc)
✅ Test with Stripe CLI before production
✅ Use HTTPS only for webhook endpoints
✅ Rotate webhook secrets monthly
✅ Monitor delivery in Stripe Dashboard
---
What am I missing?
Did this guide miss edge cases you've hit? Common gotchas with specific frameworks? Stripe API changes since 2026?
Drop corrections and additions in comments. Indie hackers building real SaaS will thank you.