Stripe Webhooks Setup for Indie SaaS 2026
Production-ready webhook implementation for Stripe events. Handle payment confirmations, subscription updates, and failures with idempotency patterns.
TL;DR
Webhooks are how Stripe tells your app about payment events asynchronously. You need: (1) an HTTPS endpoint, (2) event signature verification, (3) idempotent handlers, (4) proper error responses. Test with the Stripe CLI before going live.
Why Webhooks Matter
Polling your Stripe dashboard constantly is unreliable and wasteful. Webhooks deliver real-time events—payment succeeded, subscription canceled, invoice failed—directly to your infrastructure. For indie SaaS, this means:
Step 1: Create Your Webhook Endpoint
Your endpoint must be HTTPS and publicly accessible. Here's a production-ready Express.js handler:
```javascript // webhook.js - Express handler 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) => { const sig = req.headers['stripe-signature'];
let event;
try {
// Verify signature - this validates the event came from Stripe
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});
}
// Log for debugging
console.log(Received event: ${event.type} (ID: ${event.id}));
try { // Handle specific event types switch (event.type) { case 'payment_intent.succeeded': await handlePaymentSuccess(event.data.object); break;
case 'charge.failed': await handleChargeFailed(event.data.object); break;
case 'customer.subscription.updated': await handleSubscriptionUpdated(event.data.object); break;
case 'customer.subscription.deleted': await handleSubscriptionCanceled(event.data.object); break;
case 'invoice.payment_failed': await handleInvoiceFailed(event.data.object); break;
default:
console.log(Unhandled event type: ${event.type});
}
// Return 200 immediately - Stripe expects this before handler completes res.json({received: true});
} catch (err) {
console.error(Webhook handler error for ${event.type}: ${err.message});
// Return 500 so Stripe retries
res.status(500).send('Webhook handler failed');
}
}
);
module.exports = app; ```
Step 2: Implement Idempotent Handlers
Stripe retries failed webhooks. Your handlers must be idempotent—running twice produces the same result as running once:
```javascript // handlers.js const db = require('./database');
async function handlePaymentSuccess(paymentIntent) { const {id, metadata, amount, currency} = paymentIntent;
// Check if we already processed this event
const existing = await db.webhookEvents.findOne({stripe_event_id: id});
if (existing) {
console.log(Event ${id} already processed, skipping);
return;
}
// Extract business data from metadata const userId = metadata.user_id; const planId = metadata.plan_id;
// Update user subscription in database (wrapped in transaction) const transaction = await db.startTransaction(); try { await db.users.updateOne( {_id: userId}, { plan: planId, status: 'active', subscription_started_at: new Date() }, {session: transaction} );
// Log the webhook event for audit trail await db.webhookEvents.insertOne( { stripe_event_id: id, event_type: 'payment_intent.succeeded', user_id: userId, amount_cents: amount, currency: currency, processed_at: new Date() }, {session: transaction} );
await transaction.commitTransaction();
console.log(Successfully activated user ${userId} for plan ${planId});
} catch (err) { await transaction.abortTransaction(); throw err; // Let webhook handler return 500 } }
async function handleChargeFailed(charge) { const {id, customer, failure_message} = charge;
const existing = await db.webhookEvents.findOne({stripe_event_id: id}); if (existing) return;
await db.webhookEvents.insertOne({ stripe_event_id: id, event_type: 'charge.failed', customer_id: customer, failure_reason: failure_message, logged_at: new Date() });
// Send user notification email await sendEmailNotification(customer, { template: 'payment_failed', reason: failure_message }); }
module.exports = {handlePaymentSuccess, handleChargeFailed}; ```
Step 3: Test with Stripe CLI
Don't test webhooks against production without Stripe CLI first. Install it ([verify in official docs](https://stripe.com/docs/stripe-cli)):
```bash
macOS
brew install stripe/stripe-cli/stripeLogin to your account
stripe loginForward Stripe events to localhost:3000
stripe listen --forward-to localhost:3000/api/webhooks/stripeIn another terminal, trigger test events
stripe trigger payment_intent.succeeded stripe trigger charge.failed ```Expect to see these console messages in development:
``` Received event: payment_intent.succeeded (ID: evt_1234567890) Successfully activated user 605a7c9d8f1b4 for plan pro_monthly ```
Common errors you'll encounter:
1. Error: No signatures found matching the expected signature for payload — Your STRIPE_WEBHOOK_SECRET is wrong or you're not using raw body parser.
2. Error: Webhook handler error for payment_intent.succeeded: user not found — metadata.user_id doesn't match a real user. Verify metadata passed to payment intent creation.
3. Webhook Error: Timestamp outside the time tolerance window — Server clock is out of sync. Run ntpdate -s time.nist.gov on Linux or check system time.
Step 4: Configure in Stripe Dashboard
1. Go to [Developers → Webhooks](https://dashboard.stripe.com/webhooks)
2. Click "Add endpoint"
3. Enter your production URL: https://yourdomain.com/api/webhooks/stripe
4. Select events: payment_intent.succeeded, charge.failed, customer.subscription.updated, invoice.payment_failed
5. Copy the Signing secret → set as STRIPE_WEBHOOK_SECRET in production
Environment Variables Checklist
```bash STRIPE_SECRET_KEY=sk_live_xxx # Production secret STRIPE_WEBHOOK_SECRET=whsec_xxx # From dashboard NODE_ENV=production LOG_LEVEL=info ```
Monitoring & Debugging
Always log webhook processing:
```javascript const logger = require('winston');
logger.info('webhook_received', { event_id: event.id, event_type: event.type, timestamp: new Date() });
logger.error('webhook_failed', { event_id: event.id, error: err.message, stack: err.stack }); ```
Stripe retries failed webhooks with exponential backoff over 3 days. Check the [Webhooks API reference](https://stripe.com/docs/api/events) for all event types.
Related Guides
What am I missing?
Did you implement webhooks differently? Encounter an error not listed? Have production patterns for handling duplicate events or webhook ordering issues? Drop them in the comments—this guide should reflect how indie hackers actually do this.