Stripe Webhooks for SaaS in 2026: Setup & Error Handling
Production-ready webhook implementation for Stripe. Handle events reliably, avoid common pitfalls, and secure your payment infrastructure.
TL;DR
Stripe webhooks notify your SaaS when payments complete, subscriptions change, or disputes occur. Set up a HTTPS endpoint, verify webhook signatures with your endpoint secret, and implement idempotent event handlers. Use the Stripe CLI (v1.18+, verify in official docs) for local testing before deploying.
---
Why Webhooks Matter for Indie SaaS
Polling Stripe's API every minute is wasteful. Webhooks push events to your server instantly—subscription renewed, customer disputed a charge, trial ended. For indie SaaS, this is critical:
---
Step 1: Create Your Webhook Endpoint
Your endpoint must: 1. Accept POST requests 2. Return 200 OK quickly (process async) 3. Verify the signature
Here's a Node.js/Express example (production-ready pattern):
```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 {
// Verify signature before processing
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 return 200 immediately, then process res.status(200).json({ received: true });
// Async processing - retry on failure
try {
await handleStripeEvent(event);
} catch (err) {
console.error(Event processing failed (${event.id}): ${err.message});
// Log to error tracking (Sentry, etc.)
// Stripe will retry within 3 days
}
}
);
async function handleStripeEvent(event) { // Idempotent: safe to call multiple times const eventId = event.id; // Check if already processed const existing = await db.webhookEvent.findUnique({ where: { eventId } }); if (existing) return;
switch (event.type) {
case 'charge.succeeded':
await handleChargeSucceeded(event.data.object);
break;
case 'customer.subscription.updated':
await handleSubscriptionUpdated(event.data.object);
break;
case 'customer.subscription.deleted':
await handleSubscriptionDeleted(event.data.object);
break;
case 'invoice.payment_failed':
await handlePaymentFailed(event.data.object);
break;
default:
console.log(Unhandled event type: ${event.type});
}
// Record processed event await db.webhookEvent.create({ eventId, type: event.type, processedAt: new Date(), }); }
async function handleChargeSucceeded(charge) { // Example: Update user account status const customerId = charge.customer; const amountPaid = charge.amount / 100; // Convert cents to dollars await db.user.update( { stripeCustomerId: customerId }, { accountStatus: 'active', lastPaymentDate: new Date(charge.created * 1000), } ); }
async function handleSubscriptionUpdated(subscription) { const customerId = subscription.customer; const planId = subscription.items.data[0].plan.id; await db.user.update( { stripeCustomerId: customerId }, { plan: planId, renewalDate: new Date(subscription.current_period_end * 1000) } ); }
async function handlePaymentFailed(invoice) { const customerId = invoice.customer; await db.user.update( { stripeCustomerId: customerId }, { accountStatus: 'suspended', failureReason: invoice.last_payment_error?.message } ); // Send email notification await sendPaymentFailedEmail(customerId); }
app.listen(3000, () => console.log('Webhook server running on :3000')); ```
Key patterns:
express.raw() preserves the request body for signature verificationeventId prevents duplicate charges---
Step 2: Configure Webhooks 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/webhooks/stripe
4. Select events:
- charge.succeeded
- customer.subscription.updated
- customer.subscription.deleted
- invoice.payment_failed
- customer.deleted (for cleanup)
5. Copy the Signing Secret (starts with whsec_) → save to .env
---
Step 3: Test Locally with Stripe CLI
Download [Stripe CLI](https://stripe.com/docs/stripe-cli) (verify latest version in official docs):
```bash stripe login stripe listen --forward-to localhost:3000/webhooks/stripe ```
Capture the signing secret from CLI output. In another terminal:
```bash stripe trigger charge.succeeded stripe trigger customer.subscription.updated ```
Watch your server logs for successful processing.
---
Common Console Errors & Solutions
Error 1: "Webhook signature verification failed"
``` Webhook signature verification failed: No signatures found matching the expected signature for payload. ``` Cause: Wrong secret, or request body modified before verification Fix: Ensureexpress.raw() is used; check .env has correct STRIPE_WEBHOOK_SECRETError 2: "Cannot read property 'customer' of undefined"
``` TypeError: Cannot read property 'customer' of undefined at handleChargeSucceeded ``` Cause: Event data structure doesn't match expectation Fix: LogJSON.stringify(event.data.object, null, 2) to inspect actual structureError 3: "Timeout waiting for response"
``` Webhook endpoint returned status 504; will retry later ``` Cause: Your endpoint takes >30 seconds to process Fix: Return 200 immediately; queue processing to background job (Bull, RQ)---
Production Checklist
event.id)---
Related Reading
---
What am I missing?
Did I skip your favorite footgun? Common issues:
Add your experience in comments below. Indie hackers helping indie hackers.