Stripe Webhooks for SaaS: Production Setup Guide 2026

Complete webhook implementation for indie SaaS. Handle subscription events reliably with production-ready patterns, error handling, and exact setup steps.

TL;DR

Stripe webhooks route real-time payment events to your server. Set up an HTTPS endpoint, verify signatures using your webhook secret, idempotently process events, and retry failed deliveries. Test locally with Stripe CLI before production. Most failures come from signature validation bugs or missing idempotency handling.

---

Why Webhooks Matter for SaaS

Webhooks are how Stripe tells your SaaS when subscriptions renew, fail, or cancel. Without them, you're polling the API (wasteful) or missing revenue events entirely. A single missed invoice.payment_succeeded webhook could mean a customer gets service they haven't paid for.

Common indie SaaS webhook events:

  • customer.subscription.created - new signup
  • invoice.payment_succeeded - payment received
  • invoice.payment_failed - retry logic starts
  • customer.subscription.deleted - cancellation
  • charge.dispute.created - chargeback incoming
  • ---

    Step 1: Create Your Webhook Endpoint

    Your endpoint must: 1. Listen on HTTPS only (no HTTP in production) 2. Return 200 immediately before processing 3. Verify Stripe's signature 4. Handle duplicate events

    Here's a production-ready Node.js/Express example using stripe@15.0.0 [verify in official docs](https://stripe.com/docs/libraries/node):

    ```javascript const express = require('express'); const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

    const app = express(); const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;

    // Raw body required for signature verification app.post('/api/webhooks/stripe', express.raw({type: 'application/json'}), async (req, res) => { const signature = req.headers['stripe-signature']; let event;

    // Verify signature try { event = stripe.webhooks.constructEvent( req.body, signature, webhookSecret ); } catch (err) { console.error('Webhook signature verification failed:', err.message); return res.status(400).send(Webhook Error: ${err.message}); }

    // Always return 200 immediately res.status(200).json({received: true});

    // Process async - don't block response processWebhookEvent(event).catch(err => { console.error('Webhook processing error:', err); // Implement alerting here (Sentry, LogRocket, etc) }); } );

    async function processWebhookEvent(event) { const eventType = event.type; const eventId = event.id; // Use for idempotency // Check if we've already processed this event const processed = await db.webhookEvents.findOne({stripeEventId: eventId}); if (processed) { console.log(Event ${eventId} already processed, skipping); return; }

    try { switch (eventType) { case 'invoice.payment_succeeded': await handlePaymentSucceeded(event.data.object); break; case 'invoice.payment_failed': await handlePaymentFailed(event.data.object); break; case 'customer.subscription.deleted': await handleSubscriptionDeleted(event.data.object); break; default: console.log(Unhandled event type: ${eventType}); }

    // Mark as processed await db.webhookEvents.create({ stripeEventId: eventId, type: eventType, processedAt: new Date() }); } catch (err) { console.error(Error processing ${eventType}:, err.message); throw err; // Let outer handler deal with retry } }

    async function handlePaymentSucceeded(invoice) { const customerId = invoice.customer; const amountPaid = invoice.amount_paid; // Update subscription status in your DB await db.subscriptions.updateOne( {stripeCustomerId: customerId}, {status: 'active', lastPaymentAt: new Date()} ); }

    app.listen(3000, () => console.log('Webhook server listening')); ```

    Critical mistakes that break this:

    1. Missing raw body parsing - calling .json() middleware before signature check destroys the signature 2. Not returning 200 immediately - Stripe retries for 3 days if you don't 3. Processing inside the response handler - one slow operation blocks everything

    ---

    Step 2: Get Your Webhook Secret and Endpoint URL

    In Stripe Dashboard:

    1. Go to [Developers → Webhooks](https://dashboard.stripe.com/webhooks) 2. Click "Add endpoint" 3. Enter your HTTPS URL: https://yourdomain.com/api/webhooks/stripe 4. Select events (start with: customer.subscription.created, invoice.payment_succeeded, invoice.payment_failed, customer.subscription.deleted) 5. Copy the Signing Secret (starts with whsec_) → STRIPE_WEBHOOK_SECRET env var

    Console error you'll see if URL is wrong: ``` Webhook endpoint rejected POST request: 404 Not Found Webhook will be retried 5 times over the next 3 days ```

    ---

    Step 3: Test Locally with Stripe CLI

    Don't rely on the dashboard "Send test event" button—it doesn't verify signatures correctly.

    Use [Stripe CLI v1.19.0+](https://stripe.com/docs/stripe-cli) [verify in official docs]:

    ```bash

    Install

    brew install stripe/stripe-cli/stripe # macOS

    or download from https://github.com/stripe/stripe-cli/releases

    Login and forward webhooks

    stripe listen --forward-to localhost:3000/api/webhooks/stripe

    In another terminal, trigger test events

    stripe trigger invoice.payment_succeeded ```

    Stripe CLI automatically sets STRIPE_WEBHOOK_SECRET for your local environment.

    Console error when signature validation fails: ``` Webhook signature verification failed: No signatures found matching the expected signature for payload. Webhook Error: No signatures found matching the expected signature for payload. ```

    This means your webhookSecret doesn't match the secret from the webhook endpoint in the dashboard.

    ---

    Step 4: Production Checklist

    ✅ HTTPS only - Stripe won't send webhooks to HTTP endpoints in production ✅ Environment variables - never hardcode secrets ✅ Database idempotency table - webhook_events with stripeEventId + createdAt index ✅ Error alerting - Sentry/LogRocket integration for processing failures ✅ Monitoring - track webhook latency and failure rates ✅ Retry logic - implement exponential backoff for DB failures ✅ Logs - every webhook event in logs with timestamp + outcome

    ---

    Common Indie SaaS Patterns

    Subscription Status Sync

    After invoice.payment_succeeded, your app should update the user's account tier synchronously. Use a single subscriptionStatus field ("active", "past_due", "canceled") rather than multiple boolean flags.

    Grace Periods

    Handle invoice.payment_failed by: 1. Grace period: 7 days before suspending access 2. Automatic retry: Stripe retries 3 times by default (verify in official docs) 3. After grace: suspend or show upgrade prompt

    Don't immediately lock access—this kills conversion metrics.

    ---

    Related Resources

  • [Stripe Webhooks Official Docs](https://stripe.com/docs/webhooks)
  • [Webhook Retry Policy](https://stripe.com/docs/webhooks/best-practices#retry-logic)
  • [Stripe-Signature Header Format](https://stripe.com/docs/webhooks/signatures)
  • [guide - Handling SaaS subscription cancellations](/guide=cancellation-flows)
  • [guide - Dunning email sequences](/guide=failed-payment-dunning)
  • ---

    What am I missing?

    I'm sure there's a gap here—leave a comment with:

  • Error messages you've hit that I didn't cover
  • Your preferred webhook library/framework
  • Production monitoring strategies that saved your SaaS
  • Edge cases (timezone handling, multi-currency, etc)
  • This guide assumes Node.js/Express, but the patterns apply to Django, Rails, PHP, etc. The core is always: verify signature → return 200 → process async → idempotency.

    🔥 0d
    LIVE
    ↓ PlanetScale rage spiking •⚠ Vercel pricing complaints •↑ Railway gaining fast •↑ Supabase happiness rising •↑ Resend loved by devs •↓ PlanetScale rage spiking •⚠ Vercel pricing complaints •↑ Railway gaining fast •↑ Supabase happiness rising
    DEVELOPER PAIN RADAR // Loading...

    Developers complain.
    Opportunities appear.

    We track what developers are struggling with today — and what opportunities that creates.

    —
    guides today
    —
    avg happiness
    🔥 Pain
    📖 Guides
    🔭 Explore
    👤 Mine
    🔥 Pain Radar — rage scores today
    ↗ share
    💡 Opportunity Feed — pain = market gap
    📈 Tool Momentum
    all scores →
    📖 Latest Guide
    all guides →
    📖 All Guides
    📊 Tool Scores
    + Submit
    📰 Hacker News
    ➕ Submit a Tool
    ← back