Stripe Webhooks for SaaS: Production Setup 2026

Complete webhook configuration guide for indie SaaS. Handle events reliably, verify signatures, retry logic, and production patterns.

TL;DR

Stripe webhooks are essential for SaaS: listen for customer.subscription.updated, invoice.payment_succeeded, and charge.failed events. Always verify webhook signatures using your endpoint secret. Implement idempotency and retry logic. Most failures come from signature mismatches or missing event handling. Use [Stripe CLI v1.16+](https://stripe.com/docs/stripe-cli) for local testing—verify current version in official docs.

---

Why Webhooks Matter for Indie SaaS

Webhooks are your connection to Stripe's world. When a customer's subscription renews, a payment fails, or they request a refund, Stripe sends your server an HTTP POST. Webhook handling is non-negotiable: without it, you won't know if revenue actually landed.

Common mistakes:

  • Skipping signature verification (security nightmare)
  • Assuming events arrive in order (they don't always)
  • No idempotency handling (processing the same event twice)
  • Naive retry logic (timeout = data loss)
  • ---

    Step 1: Create Your Webhook Endpoint

    Your endpoint must: 1. Accept POST requests 2. Return HTTP 200 immediately 3. Process asynchronously 4. Verify the signature

    Here's a production-ready Node.js/Express pattern using [express v4.18+](https://expressjs.com/):

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

    const endpointSecret = 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 { event = stripe.webhooks.constructEvent( req.body, sig, endpointSecret ); } catch (err) { console.error(Webhook signature verification failed: ${err.message}); return res.sendStatus(400); }

    // Respond immediately res.sendStatus(200);

    // Process asynchronously handleWebhookEvent(event).catch(err => { console.error(Webhook processing error: ${err.message}, { eventId: event.id }); }); } );

    async function handleWebhookEvent(event) { // Idempotency: Check if we've processed this event const processedEvent = await db.webhookEvents.findOne({ stripeEventId: event.id }); if (processedEvent) return;

    switch (event.type) { case 'customer.subscription.updated': await handleSubscriptionUpdate(event.data.object); break; case 'invoice.payment_succeeded': await handlePaymentSuccess(event.data.object); break; case 'charge.failed': await handleChargeFailed(event.data.object); break; default: console.warn(Unhandled event type: ${event.type}); }

    // Mark as processed await db.webhookEvents.insertOne({ stripeEventId: event.id, type: event.type, processedAt: new Date(), }); }

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

    Key points:

  • express.raw() is mandatory—signature verification requires the original body bytes
  • Return 200 before processing—Stripe retries failed webhooks
  • Store processed event IDs to prevent double-processing
  • ---

    Step 2: Register Webhook in Stripe Dashboard

    1. Go to [Stripe Dashboard](https://dashboard.stripe.com/) → Developers → Webhooks 2. Click "Add endpoint" 3. URL: https://yourdomain.com/webhooks/stripe (must be HTTPS in production) 4. Select events: - customer.subscription.updated - invoice.payment_succeeded - invoice.payment_failed - charge.failed - charge.dispute.created 5. Copy the signing secret → set as STRIPE_WEBHOOK_SECRET in .env

    [Verify exact event names in Stripe docs](https://stripe.com/docs/api/events/types)—some event names changed in recent API versions.

    ---

    Step 3: Local Testing with Stripe CLI

    Test without deploying:

    ```bash

    Install (verify current version in official docs)

    stripe login

    Forward Stripe events to your local server

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

    In another terminal, trigger test events

    stripe trigger customer.subscription.updated stripe trigger invoice.payment_succeeded ```

    You'll see output: ``` 2026-01-15 10:42:15 --> customer.subscription.updated [evt_...] 2026-01-15 10:42:15 <-- [200] ```

    Console errors to watch for:

    1. "No such file or directory: dist/index.js" - Solution: Compile/build your app first (npm run build)

    2. "Error: No such webhook endpoint secret" - Solution: stripe listen outputs your signing secret—copy it to .env

    3. "Cannot verify webhook signature: signature does not match" - Solution: Using req.body instead of raw body. Change to express.raw()

    ---

    Step 4: Handle Key Events

    Subscription Updates

    ```javascript async function handleSubscriptionUpdate(subscription) { const customerId = subscription.customer; await db.subscriptions.updateOne( { stripeCustomerId: customerId }, { status: subscription.status, currentPeriodEnd: new Date(subscription.current_period_end * 1000), plan: subscription.items.data[0].price.id, updatedAt: new Date(), } );

    if (subscription.status === 'past_due') { // Send retry email, downgrade features, etc. } } ```

    Payment Success

    ```javascript async function handlePaymentSuccess(invoice) { await db.invoices.updateOne( { stripeInvoiceId: invoice.id }, { status: 'paid', paidAt: new Date(invoice.paid_date * 1000) } );

    // Update MRR tracking, send receipt, unlock features await updateCustomerMetrics(invoice.customer); } ```

    Charge Failed

    ```javascript async function handleChargeFailed(charge) { const failureCode = charge.failure_code; // e.g., 'insufficient_funds' await db.chargeFailures.insertOne({ stripeChargeId: charge.id, customerId: charge.customer, failureCode, failureMessage: charge.failure_message, attemptedAt: new Date(charge.created * 1000), });

    // Notify user, trigger retry billing, or pause service } ```

    ---

    Production Checklist

  • [ ] HTTPS endpoint (no HTTP)
  • [ ] Signature verification enabled
  • [ ] Asynchronous processing (return 200 immediately)
  • [ ] Idempotency tracking (check event ID before processing)
  • [ ] Dead-letter queue for failed events (log to external service)
  • [ ] Monitoring/alerting on webhook failures
  • [ ] Secrets in environment variables (never hardcoded)
  • [ ] Rate limiting on your endpoint (prevent abuse)
  • [ ] Exponential backoff for retries (don't hammer external APIs)
  • ---

    Related Guides

  • [Stripe subscription lifecycle explained](/?guide=stripe-subscriptions)
  • [Building SaaS billing dashboards](/?guide=saas-billing-ui)
  • ---

    What am I missing?

    Webhook handling varies by language, framework, and business logic. Drop a comment:

  • Using Python/Django? FastAPI specifics?
  • Running serverless (Lambda, Vercel)? Different gotchas?
  • Event ordering issues in production?
  • Database transaction patterns for idempotency?
  • Monitoring/observability tooling you recommend?
  • All feedback welcome—this guide updates with community input.

    🔥 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