Stripe Webhooks for SaaS: Production Setup Guide 2026

Complete webhook implementation for indie SaaS. Error handling, security, testing patterns. Production-ready code with real console errors.

TL;DR

Stripe webhooks require three things: (1) a publicly accessible HTTPS endpoint, (2) verification using webhook signing secrets, (3) idempotent handlers for retry safety. Most failures happen from skipping verification or not handling timeouts. Test with the Stripe CLI before deploying.

---

Why Webhooks Matter for SaaS

If you're building a SaaS product with Stripe, you need webhooks. Here's why: your customer pays, but their payment confirmation lives on Stripe's servers. You can't just call their API every 5 seconds asking "did they pay yet?" Webhooks are Stripe's way of saying "hey, something happened, here's the proof."

Without webhooks, your user upgrades their plan but stays on the free tier for hours. They churn. You lose.

---

Setting Up Your Webhook Endpoint

1. Create the Endpoint

Your endpoint must be:

  • HTTPS only (localhost exceptions for testing via Stripe CLI)
  • Publicly accessible (not behind auth middleware)
  • Idempotent (same webhook fired twice = same result)
  • Here's a Node.js + Express example (production pattern):

    ```javascript import express from 'express'; import stripe from 'stripe'; import { verifyWebhookSignature } from './stripe-verify.js'; import { handleCheckoutComplete } from './handlers/checkout.js'; import { handleSubscriptionUpdated } from './handlers/subscription.js';

    const app = express(); const stripeClient = stripe(process.env.STRIPE_SECRET_KEY);

    // CRITICAL: Use raw body for webhook signature verification app.post('/api/webhooks/stripe', express.raw({type: 'application/json'}), async (req, res) => { const sig = req.headers['stripe-signature']; const rawBody = req.body;

    try { const event = verifyWebhookSignature( rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET );

    // Log for debugging (strip sensitive data) console.log(Webhook received: ${event.type} | ID: ${event.id});

    switch(event.type) { case 'checkout.session.completed': await handleCheckoutComplete(event.data.object, stripeClient); break; case 'customer.subscription.updated': await handleSubscriptionUpdated(event.data.object); break; case 'invoice.payment_failed': await handlePaymentFailed(event.data.object); break; default: console.log(Unhandled event type: ${event.type}); }

    res.json({received: true}); } catch (error) { console.error(Webhook error: ${error.message}); res.status(400).send(Webhook Error: ${error.message}); } } ); ```

    2. Verify the Signature

    This is non-negotiable. Never skip this. Here's the verify function:

    ```javascript // stripe-verify.js import crypto from 'crypto';

    export function verifyWebhookSignature(body, sig, secret) { if (!sig || !secret) { throw new Error('Missing signature or webhook secret'); }

    const timestamp = sig.split(',')[0].split('=')[1]; const hash = sig.split(',')[1].split('=')[1];

    // Check timestamp within 5 minutes (Stripe default) const currentTime = Math.floor(Date.now() / 1000); if (Math.abs(currentTime - parseInt(timestamp)) > 300) { throw new Error('Webhook timestamp outside tolerance window'); }

    // Verify signature const signedContent = ${timestamp}.${body}; const expectedHash = crypto .createHmac('sha256', secret) .update(signedContent) .digest('hex');

    if (expectedHash !== hash) { throw new Error('Webhook signature verification failed'); }

    return JSON.parse(body); } ```

    Note: Stripe provides official libraries. For Node.js, use stripe.webhooks.constructEvent() from stripe@>=13.0.0 [verify in official docs](https://stripe.com/docs/webhooks). This handles parsing automatically.

    ---

    Real Console Errors You'll Hit

    Error 1: Raw Body Parser Missing

    ``` Webhook signature verification failed Error: No webhook signature found ``` Cause: Using express.json() instead of express.raw(). The middleware parses the body, destroying the raw signature verification.

    Fix: Use express.raw({type: 'application/json'}) as shown above.

    Error 2: Environment Variable Missing

    ``` TypeError: Cannot read property 'split' of undefined at verifyWebhookSignature (stripe-verify.js:8:15) ``` Cause: STRIPE_WEBHOOK_SECRET not set in .env.

    Fix: Add to .env: ``` STRIPE_WEBHOOK_SECRET=whsec_test_... STRIPE_SECRET_KEY=sk_test_... ```

    Error 3: Handler Timeout

    ``` Error: Webhook handler exceeded timeout Stripe will retry this event ``` Cause: Your handler runs >30 seconds (common with database writes).

    Fix: Use queues (Bull, BullMQ, RabbitMQ). Return 200 immediately, process async:

    ```javascript case 'checkout.session.completed': // Queue the work, don't await await webhookQueue.add('checkout', event.data.object); break; ```

    ---

    Idempotency: Handle Retries

    Stripe retries failed webhooks with exponential backoff. Your handler might run twice. Here's how to handle it:

    ```javascript async function handleCheckoutComplete(session, db) { const existingOrder = await db.orders.findOne({ stripeSessionId: session.id });

    if (existingOrder) { console.log(Order ${session.id} already processed); return; // Idempotent - safe to return }

    // Create order only once await db.orders.create({ userId: session.client_reference_id, stripeSessionId: session.id, amount: session.amount_total, createdAt: new Date() }); } ```

    ---

    Testing Before Deploying

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

    ```bash

    Install

    brew install stripe/stripe-cli/stripe

    Login

    stripe login

    Forward webhooks to localhost

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

    In another terminal, trigger events

    stripe trigger payment_intent.succeeded ```

    ---

    Registering in Stripe Dashboard

    1. Go to [Developers > Webhooks](https://dashboard.stripe.com/webhooks) 2. Click "Add endpoint" 3. Enter your URL: https://yourdomain.com/api/webhooks/stripe 4. Select events: - checkout.session.completed - customer.subscription.updated - invoice.payment_failed 5. Copy the signing secret to .env

    ---

    Production Checklist

  • [ ] HTTPS endpoint only (no HTTP)
  • [ ] Signature verification implemented
  • [ ] Raw body parser configured
  • [ ] All handlers are idempotent
  • [ ] Timeout logic with queue system
  • [ ] Error logging (strip PII)
  • [ ] Monitoring/alerting for failed webhooks
  • [ ] Tested with Stripe CLI
  • [ ] Webhook signing secret in environment variables
  • [ ] Rate limiting on endpoint (optional but recommended)
  • ---

    Related Guides

  • [Stripe payment flows explained](/?guide=stripe-payments)
  • [Setting up Stripe checkout for SaaS](/?guide=checkout-integration)
  • ---

    What am I missing?

    Did I overlook webhook idempotency patterns? Different payment flows? Database transaction best practices? Let me know in the commentsβ€”indie hackers catch what I miss.

    πŸ”₯ 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