Stripe Webhooks Setup for Indie SaaS 2026
Production-ready webhook implementation for Stripe events. Handle payments, subscriptions, and failures with error handling patterns.
TL;DR
Webhooks are how Stripe tells your SaaS about payment events asynchronously. Set up a webhook endpoint, verify signatures with your signing secret, and handle subscription/payment events. Use idempotency keys to prevent duplicate processing.
Why Webhooks Matter for Indie SaaS
If you're polling Stripe's API every 30 seconds to check payment status, stop. Webhooks are event-driven—Stripe pushes notifications to your endpoint when charges succeed, subscriptions renew, or customers churn. This saves API calls, reduces latency, and ensures you don't miss critical events.
For indie SaaS, webhooks are essential for:
Step 1: Create Your Webhook Endpoint
Your endpoint must: 1. Accept POST requests 2. Return 200 OK immediately (process async) 3. Verify the Stripe signature 4. Handle duplicate events (Stripe retries)
Here's a production-ready example using Node.js with Express and Stripe SDK v14.x (verify current version in [official Stripe docs](https://github.com/stripe/stripe-node)):
```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']; const body = req.body; let event;
try {
event = stripe.webhooks.constructEvent(
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.json({ received: true });
// Handle events asynchronously
try {
await handleWebhookEvent(event);
} catch (error) {
console.error(❌ Webhook processing error: ${error.message}, { event_id: event.id });
// Log to error tracking (Sentry, etc.)
}
}
);
async function handleWebhookEvent(event) {
switch (event.type) {
case 'customer.subscription.created':
await activateSubscription(event.data.object);
break;
case 'customer.subscription.updated':
await updateSubscription(event.data.object);
break;
case 'customer.subscription.deleted':
await deactivateSubscription(event.data.object);
break;
case 'invoice.payment_succeeded':
await handlePaymentSuccess(event.data.object);
break;
case 'invoice.payment_failed':
await handlePaymentFailure(event.data.object);
break;
case 'charge.dispute.created':
await logDispute(event.data.object);
break;
default:
console.log(Unhandled event type: ${event.type});
}
}
app.listen(3000, () => console.log('Webhook server running on port 3000')); ```
Step 2: Handle Duplicate Events (Idempotency)
Stripe retries failed webhooks up to 5 times. Your code must be idempotent—processing the same event twice shouldn't cause issues.
```javascript async function activateSubscription(subscription) { // Check if already processed const existing = await db.subscriptions.findOne({ stripe_subscription_id: subscription.id, processed_event_id: subscription.id // Store the event ID });
if (existing) {
console.log(⚠️ Duplicate webhook detected for subscription ${subscription.id}. Skipping.);
return;
}
// Create subscription with idempotent key await db.subscriptions.create({ user_id: subscription.metadata.user_id, stripe_subscription_id: subscription.id, stripe_customer_id: subscription.customer, status: subscription.status, processed_event_id: subscription.id, created_at: new Date() });
// Send activation email await sendActivationEmail(subscription.metadata.user_id); } ```
Step 3: Verify Environment Variables
Store these securely (not in git):
```bash STRIPE_SECRET_KEY=sk_test_... # Never share this STRIPE_WEBHOOK_SECRET=whsec_test_... # From Dashboard → Webhooks STRIPE_PUBLISHABLE_KEY=pk_test_... # Safe to expose in frontend ```
Retrieve webhook secret from [Stripe Dashboard](https://dashboard.stripe.com/webhooks) → Select endpoint → Signing secret.
Step 4: Deploy and Register Webhook
After deploying your endpoint:
1. Go to [Stripe Dashboard → Webhooks](https://dashboard.stripe.com/webhooks)
2. Click "Add endpoint"
3. Enter your production URL: https://yourdomain.com/webhooks/stripe
4. Select events:
- customer.subscription.created
- customer.subscription.updated
- customer.subscription.deleted
- invoice.payment_succeeded
- invoice.payment_failed
5. Copy the signing secret and save to environment variables
Common Console Errors You'll See
Error 1: Signature Verification Failed
``` ❌ Webhook signature verification failed: No signatures found matching the expected signature for payload. Possible reasons: the webhook signing secret is incorrect, the payload was not received as sent by Stripe, or the payload was altered ```Fix: Ensure you're using raw body, not parsed JSON, and the signing secret matches exactly.
Error 2: Body Parser Conflict
``` Error: req.body was already parsed. Cannot verify Stripe signature. ```Fix: Use express.raw() middleware *only* for the webhook route, not globally.
Error 3: Timeout on Async Processing
``` ❌ Webhook processing error: Task timed out after 30000ms ```Fix: Return 200 immediately; move heavy work (emails, DB writes) to a job queue (Bull, RabbitMQ, Temporal).
Production Checklist
stripe trigger CLI command (verify in [official docs](https://stripe.com/docs/stripe-cli))Testing Locally
Use Stripe CLI to forward webhooks to localhost:
```bash stripe listen --forward-to localhost:3000/webhooks/stripe
Copy the signing secret from output
stripe trigger customer.subscription.created ```
Related Reading
What am I missing?
Comments below: Did I miss handling charge.refunded? Should I add webhook signature caching? Stripe API version gotchas in 2026? Corrections and additions welcome.