Stripe Webhooks for SaaS in 2026: Complete Setup Guide

Production-ready webhook setup for indie SaaS. Real error patterns, exact code, and testing strategies developers actually use.

TL;DR

Stripe webhooks require: (1) HTTPS endpoint with proper signature verification, (2) idempotent event handlers, (3) proper error handling with 2xx responses, (4) webhook secret management via environment variables. Test locally with Stripe CLI v1.15.x+, deploy with retry logic, monitor event latency in Stripe Dashboard.

---

Why Webhooks Matter for Your SaaS

Webhooks are Stripe's way of telling your application about events—subscription renewals, payment failures, customer disputes. Without them, your SaaS becomes a glorified payment form. You won't know when:

  • A customer's card declined on renewal
  • They successfully upgraded their plan
  • A refund processed
  • A subscription was cancelled
  • Ignoring webhooks means angry customers with no access and you with no revenue tracking.

    Architecture: The Mental Model

    Stripe sends HTTP POST requests to your endpoint. Your job:

    1. Verify the signature (Stripe signs every webhook) 2. Process the event idempotently (handle duplicates gracefully) 3. Respond with 2xx quickly (Stripe times out after 25 seconds) 4. Log everything (you'll need audit trails) 5. Retry on your end (Stripe retries, but have your own safety net)

    ---

    Step 1: Create Your Webhook Endpoint

    Node.js + Express (production pattern):

    ```javascript // webhooks.js - verify in official docs for latest Stripe SDK version const express = require('express'); const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

    const router = express.Router();

    // Critical: use raw body, not JSON middleware router.post( '/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => { const sig = req.headers['stripe-signature']; let event;

    try { // Verify signature - this is non-negotiable event = stripe.webhooks.constructEvent( req.body, sig, process.env.STRIPE_WEBHOOK_SECRET ); } catch (err) { console.error(⚠️ Webhook signature verification failed., err.message); return res.status(400).send(Webhook Error: ${err.message}); }

    // Process event based on type try { switch (event.type) { case 'customer.subscription.updated': await handleSubscriptionUpdate(event.data.object); break; case 'customer.subscription.deleted': await handleSubscriptionCancelled(event.data.object); break; case 'invoice.payment_succeeded': await handlePaymentSucceeded(event.data.object); break; case 'invoice.payment_failed': await handlePaymentFailed(event.data.object); break; case 'charge.dispute.created': await handleChargeback(event.data.object); break; default: console.log(Unhandled event type ${event.type}); } // Acknowledge receipt immediately res.json({ received: true }); } catch (err) { console.error(Error processing webhook event ${event.id}:, err); // Return 500 so Stripe retries - don't return 200 on failure res.status(500).send('Internal Server Error'); } } );

    module.exports = router; ```

    Step 2: Implement Idempotent Handlers

    Stripe may send the same webhook multiple times. Your handler must be idempotent:

    ```javascript async function handleSubscriptionUpdate(subscription) { // Check if we already processed this event const existingRecord = await db.webhookEvents.findOne({ stripeEventId: event.id, type: 'subscription.updated' }); if (existingRecord) { console.log(Duplicate webhook ${event.id} - skipping); return; // Already processed }

    // Safe to process await db.subscriptions.updateOne( { customerId: subscription.customer }, { status: subscription.status, currentPeriodEnd: new Date(subscription.current_period_end * 1000), plan: subscription.items.data[0].price.id, updatedAt: new Date() } );

    // Record that we handled this await db.webhookEvents.insertOne({ stripeEventId: event.id, type: 'subscription.updated', processedAt: new Date() }); } ```

    Step 3: Local Testing with Stripe CLI

    Install Stripe CLI (verify current version at official docs):

    ```bash

    macOS

    brew install stripe/stripe-cli/stripe

    Verify version

    stripe version

    Output should show v1.15.x or later for 2026

    Login to your account

    stripe login

    Forward webhooks to local endpoint

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

    Output: Ready! Your webhook signing secret is whsec_xxxxxxxxxxxx

    Trigger test events in another terminal

    stripe trigger payment_intent.succeeded stripe trigger customer.subscription.updated ```

    Common errors you'll see:

    ``` Error: Unable to verify webhook signature for: whsec_test1234 → Fix: Webhook secret mismatch. Copy exact secret from stripe listen output

    Error: Cannot read property 'customer' of undefined → Fix: Event data structure changed. Log console.log(event.data.object) to inspect

    Error: 2 UNKNOWN: stream terminated by RST_STREAM → Fix: Endpoint timeout or network issue. Ensure response sent within 25 seconds ```

    Step 4: Environment Variables

    ```bash

    .env.local

    STRIPE_SECRET_KEY=sk_live_xxxxxxxxxxxx # or sk_test_ for development STRIPE_WEBHOOK_SECRET=whsec_live_xxxxx # From Stripe Dashboard → Webhooks STRIPE_PUBLISHABLE_KEY=pk_live_xxxx # For frontend ```

    Step 5: Dashboard Configuration

    1. Go to [Stripe Dashboard](https://dashboard.stripe.com) → Developers → Webhooks 2. Click "Add endpoint" 3. URL: https://yourdomain.com/webhooks/stripe 4. Select events: - customer.subscription.created - customer.subscription.updated - customer.subscription.deleted - invoice.payment_succeeded - invoice.payment_failed - charge.dispute.created 5. Copy the signing secret into your .env

    Production Checklist

  • [ ] HTTPS only (HTTP will fail)
  • [ ] Webhook secret stored in environment variables
  • [ ] Signature verification implemented
  • [ ] Idempotency checks in database
  • [ ] Error logging to monitoring service (Sentry, DataDog, etc.)
  • [ ] Graceful degradation (return 2xx even if handler fails, retry logic)
  • [ ] Database transactions for multi-step operations
  • [ ] Endpoint responds in <5 seconds (async jobs for heavy work)
  • [ ] Tested with Stripe CLI locally
  • [ ] Monitored via Stripe Dashboard webhook event log
  • Monitoring and Debugging

    Stripe Dashboard shows:

  • Event delivery status (Sent, Failed, Pending)
  • Response time and HTTP status codes
  • Raw event payload for inspection
  • Set up alerts when webhook delivery fails >5% in an hour.

    ---

    Related Reading

  • [Handling subscription cancellations](/?guide=subscription-cancellation)
  • [Retrying failed transactions](/?guide=payment-retry-logic)
  • Official Documentation

  • [Stripe Webhooks Overview](https://stripe.com/docs/webhooks)
  • [Webhook Signature Verification](https://stripe.com/docs/webhooks/signatures)
  • [Stripe CLI Reference](https://stripe.com/docs/stripe-cli)
  • [Event Types Reference](https://stripe.com/docs/api/events/types)
  • ---

    What am I missing?

    What gotchas did you hit implementing Stripe webhooks? What should indie hackers know that isn't in the official docs? Drop corrections, experiences with webhook failures, or production patterns in the comments below.

    🔥 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