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