Stripe Webhooks Setup for Indie SaaS in 2026
Production-ready webhook implementation for Stripe. Handle payments, subscriptions, and refunds reliably with error handling patterns.
TL;DR
Set up Stripe webhooks by creating an endpoint that validates signatures, idempotently processes events, and retries gracefully. Use Stripe CLI v1.19+ (verify in official docs) for local testing. Always verify webhook signatures before processing—this prevents forged requests.
---
Why Webhooks Matter for Indie SaaS
Webhooks are how Stripe tells you what happened: payment succeeded, subscription renewed, refund issued. Without them, you'd poll the API constantly (wasteful and slow). Webhooks are event-driven, real-time, and essential for subscription billing.
Real problem: You sell a SaaS subscription, customer pays, but you never grant access because your webhook endpoint crashed silently.
---
Step 1: Create Your Webhook Endpoint
Your endpoint must: 1. Accept POST requests from Stripe 2. Verify the webhook signature (critical security step) 3. Process the event idempotently 4. Return 200 status immediately
Here's a Node.js/Express production-ready example:
```javascript const express = require('express'); const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const app = express(); const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
// Important: Use raw body for signature verification app.post( '/api/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => { let event;
// Step 1: Verify the signature
try {
const sig = req.headers['stripe-signature'];
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});
}
// Step 2: Return 200 immediately to acknowledge receipt res.status(200).json({ received: true });
// Step 3: Process the event asynchronously
try {
await handleStripeEvent(event);
} catch (err) {
// Log for debugging; Stripe will retry this webhook
console.error(
Error processing event ${event.id}:,
err.message
);
}
}
);
async function handleStripeEvent(event) { switch (event.type) { case 'customer.subscription.created': await onSubscriptionCreated(event.data.object); break;
case 'customer.subscription.updated': await onSubscriptionUpdated(event.data.object); break;
case 'invoice.payment_succeeded': await onInvoicePaid(event.data.object); break;
case 'charge.refunded': await onChargeRefunded(event.data.object); break;
default:
console.log(Unhandled event type: ${event.type});
}
}
app.listen(3000, () => { console.log('Webhook server listening on port 3000'); }); ```
---
Step 2: Implement Idempotency (Stripe Retries Events)
Stripe retries failed webhooks. If your code crashes mid-processing, you might receive the same event twice. Always store the event.id to detect duplicates:
```javascript
async function onSubscriptionCreated(subscription) {
const eventId = subscription.metadata?.event_id;
// Check if we've already processed this event
const existing = await db.webhookEvents.findOne({
stripe_event_id: eventId
});
if (existing) {
console.log(Event ${eventId} already processed. Skipping.);
return;
}
// Process it for the first time await db.users.updateOne( { stripe_customer_id: subscription.customer }, { subscription_active: true, subscription_id: subscription.id } );
// Record that we handled this event await db.webhookEvents.insertOne({ stripe_event_id: eventId, processed_at: new Date(), event_type: 'subscription.created' }); } ```
---
Step 3: Register the 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 (start with: customer.subscription.created, invoice.payment_succeeded, charge.refunded)
5. Copy the Signing Secret → add to .env as STRIPE_WEBHOOK_SECRET
---
Step 4: Local Testing with Stripe CLI
Use [Stripe CLI v1.19+](https://stripe.com/docs/stripe-cli) (verify current version in official docs):
```bash
Install (macOS with Homebrew)
brew install stripe/stripe-cli/stripeLogin to your Stripe account
stripe loginForward webhook events to your local endpoint
stripe listen --forward-to localhost:3000/api/webhooks/stripeIn another terminal, trigger a test event
stripe trigger payment_intent.succeeded ```You'll see: ``` 2026-01-15 10:22:33 ▶ payment_intent.succeeded [evt_test_...] Sent to http://localhost:3000/api/webhooks/stripe Response: 200 OK ```
---
Common Console Errors You'll Hit
Error #1: Webhook signature verification failed
```
Error: No signatures found matching the expected signature for payload.
```
Cause: STRIPE_WEBHOOK_SECRET is wrong or you're using parsed body instead of raw.
Fix: Use express.raw({ type: 'application/json' }) and verify your secret key.
Error #2: Port already in use
```
Error: listen EADDRINUSE: address already in use :::3000
```
Cause: Another process owns port 3000.
Fix: lsof -i :3000 then kill -9 <PID> or use a different port.
Error #3: Webhook endpoint returns non-200 status ``` Stripe → POST https://yourdomain.com/api/webhooks/stripe Response: 500 Internal Server Error Retrying in 5 minutes... ``` Cause: Unhandled exception in your webhook handler. Fix: Add try-catch blocks and return 200 even if processing fails. Log errors for async debugging.
---
Production Checklist
event.id storage---
Related Guides
---
What Am I Missing?
This guide covers the foundation. But indie SaaS is messy:
Drop corrections and real scenarios in the comments. If you've lost revenue because webhooks failed silently, we want to hear it.
---