Stripe Webhooks Setup for Indie SaaS 2026
Production-ready webhook implementation guide for indie SaaS. Real error patterns, exact code, security best practices.
TL;DR
Webhooks let Stripe notify your SaaS when payments succeed, fail, or refund. Set up a HTTPS endpoint, verify signatures, handle retries, and test with the CLI. Most failures stem from missing signature verification or timeout handling.
Why Webhooks Matter for Your SaaS
Polling Stripe's API every minute is wasteful. Webhooks push events to your server instantly—payment succeeded, subscription renewed, dispute filed. For indie SaaS, this means:
Step 1: Create Your Webhook Endpoint
Your endpoint must be HTTPS, publicly accessible, and respond within 30 seconds. Here's a production pattern using Node.js/Express:
```javascript const express = require('express'); const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); const app = express();
// Raw body for signature verification app.post( '/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => { const sig = req.headers['stripe-signature']; const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
let event;
try {
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});
}
// Acknowledge immediately res.status(200).json({ received: true });
// Handle async (don't block response)
handleWebhookEvent(event).catch((err) => {
console.error(Failed to process event ${event.id}: ${err.message});
// Your alerting system here
});
}
);
async function handleWebhookEvent(event) {
switch (event.type) {
case 'payment_intent.succeeded':
await handlePaymentSucceeded(event.data.object);
break;
case 'customer.subscription.updated':
await handleSubscriptionUpdated(event.data.object);
break;
case 'charge.refunded':
await handleRefund(event.data.object);
break;
default:
console.log(Unhandled event type: ${event.type});
}
}
app.listen(3000, () => console.log('Webhook server running')); ```
Critical detail: Use express.raw() for the webhook route. Using express.json() globally breaks signature verification because Stripe signs the raw byte string.
Step 2: Configure Webhook Endpoints in Stripe Dashboard
1. Go to [Developers > Webhooks](https://dashboard.stripe.com/webhooks) in your Stripe dashboard
2. Click "Add endpoint"
3. Enter your endpoint URL: https://yourdomain.com/webhooks/stripe
4. Select events you need (minimum: payment_intent.succeeded, customer.subscription.updated, charge.refunded)
5. Copy the Signing Secret (starts with whsec_)
6. Store it in .env as STRIPE_WEBHOOK_SECRET
For testing before production, use Stripe CLI (covered below).
Step 3: Verify Webhook Signatures (Non-Negotiable)
Never trust unsigned webhooks. The signature proves Stripe sent it:
```javascript const crypto = require('crypto');
function verifyStripeSignature(payload, signature, secret) { const computedSignature = crypto .createHmac('sha256', secret) .update(payload, 'utf8') .digest('hex');
return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(computedSignature) ); } ```
Stripe's library handles this, but understanding the mechanism prevents signature-bypass bugs. verify in official docs for your SDK version—Stripe PHP SDK v15.0+ and Node.js SDK v14.0+ use constructEvent() correctly.
Real Error Messages You'll See
Error 1: Signature verification failed
```
Error: No signatures found matching the expected signature for payload.
```
Cause: Wrong webhook secret, or using express.json() instead of express.raw().
Error 2: Timeout ``` Error: Request timeout after 30000ms ``` Cause: Your handler takes >30 seconds. Always respond immediately, process asynchronously.
Error 3: Database constraint violation ``` Error: Duplicate key value violates unique constraint ``` Cause: Stripe retries events. Process webhooks idempotently—check if the event ID exists before updating.
Handle Retries & Idempotency
Stripe retries failed webhooks for 3 days with exponential backoff. Process each event ID once:
```javascript async function handlePaymentSucceeded(paymentIntent) { const eventId = paymentIntent.id; // Use payment intent ID as idempotency key
// Check if already processed const existing = await db.query( 'SELECT id FROM processed_events WHERE stripe_event_id = ?', [eventId] );
if (existing.length > 0) {
console.log(Event ${eventId} already processed, skipping);
return;
}
// Process payment await db.query('UPDATE subscriptions SET status = ? WHERE customer_id = ?', ['active', paymentIntent.customer] );
// Log event await db.query( 'INSERT INTO processed_events (stripe_event_id, processed_at) VALUES (?, NOW())', [eventId] ); } ```
Test Locally with Stripe CLI
1. [Download Stripe CLI](https://stripe.com/docs/stripe-cli) (verify latest version)
2. Authenticate: stripe login
3. Forward webhook events: stripe listen --forward-to localhost:3000/webhooks/stripe
4. Get your signing secret from the output
5. In another terminal, trigger a test event:
```bash stripe trigger payment_intent.succeeded ```
Check your server logs for successful processing.
Production Checklist
.env (never hardcode secrets)Related Resources
Further Reading
What am I missing?
Did I gloss over payment intent idempotency? Webhook testing in staging? Django/Rails patterns? Leave corrections and missing patterns in the comments. This guide will evolve with your feedback.