Stripe Webhooks Setup for Indie SaaS 2026
Production-ready webhook implementation for Stripe. Handle events reliably, verify signatures, retry logic, and avoid common pitfalls.
Stripe Webhooks Setup for Indie SaaS 2026
TL;DR: Webhooks let Stripe notify your server of payment events asynchronously. Always verify signatures with stripe.Webhook.constructEvent(), idempotently process events, implement retry logic, and use [events API v1](https://docs.stripe.com/api/events) for testing. Production requires HTTPS endpoint, proper error handling, and monitoring.
Why Webhooks Matter for Indie SaaS
Your customer completes payment on Stripe's hosted checkout. Now what? Webhooks are Stripe's way of telling your backend: "Hey, this transaction succeeded." Without them, you're polling Stripe's API constantly (wasteful) or relying on client-side callbacks (unreliable).
For indie SaaS, webhooks handle:
Setting Up Your Webhook Endpoint
Step 1: Create an HTTPS Endpoint
Stripe only sends webhooks to HTTPS endpoints (HTTP fails silently in production). If you're testing locally, use stripe listen with the CLI.
Node.js/Express example (v4.x):
```javascript const express = require('express'); const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const app = express(); const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
// 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 {
// Verify the webhook signature
event = stripe.webhooks.constructEvent(
req.body,
sig,
webhookSecret
);
} catch (err) {
console.error(Webhook signature verification failed: ${err.message});
return res.sendStatus(400);
}
// Handle specific events
switch (event.type) {
case 'payment_intent.succeeded':
await handlePaymentSuccess(event.data.object);
break;
case 'customer.subscription.updated':
await handleSubscriptionUpdate(event.data.object);
break;
case 'invoice.payment_failed':
await handlePaymentFailed(event.data.object);
break;
default:
console.log(Unhandled event type: ${event.type});
}
res.json({ received: true }); } ); ```
Common error: Using express.json() instead of express.raw() destroys the raw body, making signature verification fail:
```
Error: No signatures found matching the expected signature for payload
```
Step 2: Verify Webhook Signatures
Always verify signatures. Never trust incoming webhooks without validation.
Why: Attackers can POST fake events to your endpoint. Signature verification uses HMAC-SHA256 with your webhook secret. Stripe signs every webhook with: ``` Sig = HMAC-SHA256(timestamp.payload, webhookSecret) ```
The stripe.webhooks.constructEvent() function verifies this automatically and throws if invalid.
Step 3: Idempotent Event Handling
Stripe may send the same event multiple times (network retries). Your code must handle duplicates gracefully.
Pattern: Use event ID as idempotency key
```javascript
async function handlePaymentSuccess(paymentIntent) {
const idempotencyKey = paymentIntent.id;
// Check if we already processed this event
const existingRecord = await db.webhookProcessed.findUnique({
where: { event_id: idempotencyKey }
});
if (existingRecord) {
console.log(Event ${idempotencyKey} already processed, skipping);
return;
}
// Process payment
const customerId = paymentIntent.metadata.customer_id;
await db.subscription.update({
where: { id: customerId },
data: { status: 'active', activated_at: new Date() }
});
// Record that we processed this
await db.webhookProcessed.create({
event_id: idempotencyKey,
event_type: 'payment_intent.succeeded',
processed_at: new Date()
});
}
```
Step 4: Implement Retry Logic
If your endpoint returns non-2xx status or times out, Stripe retries with exponential backoff over 3 days. Don't return 500 errors unless you want retries.
Bad pattern: ```javascript // DON'T DO THIS try { await handleEvent(); } catch (err) { return res.sendStatus(500); // Stripe will retry forever } ```
Good pattern:
```javascript
// DO THIS
try {
await handleEvent();
return res.json({ received: true });
} catch (err) {
// Log error, alert team, but return 200
console.error('Webhook processing failed:', err);
await notifySlack(Webhook error: ${err.message});
return res.json({ received: true, error: 'processing failed' });
}
```
Registering 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 to listen for (start with payment_intent.succeeded, customer.subscription.updated, invoice.payment_failed)
5. Copy the "Signing Secret" and save to .env as STRIPE_WEBHOOK_SECRET
Testing Locally with Stripe CLI
Verify in official docs: [Stripe CLI v1.x](https://docs.stripe.com/stripe-cli)
```bash
Install (macOS with Homebrew)
brew install stripe/stripe-cli/stripeLogin and authenticate
stripe loginForward webhook events to localhost
stripe listen --forward-to localhost:3000/webhooks/stripeIn another terminal, trigger test events
stripe trigger payment_intent.succeeded ```The CLI outputs a signing secret for local testing. Update your .env.
Production Checklist
webhook_processed tableCommon Pitfalls
Pitfall 1: Parsing body twice
```
Error: No signatures found matching the expected signature
```
This happens if you parse the raw body before signature verification. Parse only after constructEvent().
Pitfall 2: Wrong webhook secret ``` Error: No signatures found matching the expected signature for payload ``` Make sure you're using the endpoint's signing secret, not your API key. They're different values.
Pitfall 3: Not handling network timeouts ``` Error: ECONNRESET from Stripe servers ``` Your endpoint must respond within 20 seconds. Long database operations should be queued async with job workers.
Links and Resources
What am I missing?
I've covered signature verification, idempotency, and local testing. But every SaaS has unique needs:
invoice.payment_failed events for dunning?Drop your questions and hard-won insights in the comments. The indie dev community learns fastest from real battle scars.