Stripe Webhooks for SaaS: Production Setup 2026

Complete webhook setup guide for indie SaaS. Real error patterns, idempotency keys, retry logic, and production-ready code examples.

TL;DR

Stripe webhooks require: (1) HTTPS endpoint with signature verification, (2) idempotent event handlers, (3) proper retry/timeout logic. Most failures stem from missing signature validation or non-idempotent handlers processing duplicate events. Use stripe-cli for local testing before production.

---

Why Webhooks Matter for SaaS

Webhooks are Stripe's way of telling your system: "Hey, something happened—subscription renewed, payment failed, customer added a card." Unlike polling, webhooks push events to you in near real-time.

For indie SaaS, this means:

  • Instant user access when payment clears
  • Automated subscription lifecycle (upgrades, cancellations, dunning)
  • Compliance with PCI (you never store card data)
  • The catch? Webhooks are *async and unpredictable*. Events arrive out of order. Networks fail. Your server crashes mid-processing. If you don't handle this, you'll ship broken behavior that's hard to debug.

    ---

    Step 1: Create Your Webhook Endpoint

    Your endpoint must: 1. Accept POST requests 2. Verify Stripe's signature 3. Return 200 within 30 seconds 4. Be publicly accessible over HTTPS

    Here's a production-ready Node.js example (Express, stripe@latest — verify in official docs for current version):

    ```javascript import express from 'express'; import Stripe from 'stripe';

    const app = express(); const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); const webhookSecret = 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, webhookSecret ); } catch (err) { console.error(❌ Webhook signature verification failed: ${err.message}); return res.status(400).send(Webhook Error: ${err.message}); }

    // Respond immediately, process async res.json({ received: true });

    // Handle event asynchronously processWebhookEvent(event).catch((err) => { console.error( Failed to process event ${event.id}: ${err.message} ); // Log to your error tracking (Sentry, etc.) }); } );

    async function processWebhookEvent(event) { switch (event.type) { case 'customer.subscription.updated': await handleSubscriptionUpdate(event.data.object); break; case 'invoice.payment_succeeded': await handlePaymentSuccess(event.data.object); break; case 'invoice.payment_failed': await handlePaymentFailed(event.data.object); break; default: console.log(Unhandled event type: ${event.type}); } }

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

    Critical detail: express.raw() middleware is non-negotiable. Using express.json() before signature verification will corrupt the request body and cause this error:

    ``` Error: No signatures found matching the expected signature for payload. ```

    ---

    Step 2: Implement Idempotent Handlers

    Stripe *will* retry events. Your code must handle processing the same event multiple times without creating duplicates.

    ```javascript async function handlePaymentSuccess(invoice) { const db = getDatabase(); // Stripe's event ID is globally unique and persists across retries const existingRecord = await db.query( 'SELECT id FROM invoice_processed WHERE stripe_event_id = ?', [invoice.id] );

    if (existingRecord) { console.log(Event ${invoice.id} already processed, skipping); return; }

    // Process once await db.query( 'INSERT INTO invoice_processed (stripe_event_id, customer_id, amount) VALUES (?, ?, ?)', [invoice.id, invoice.customer, invoice.amount_paid] );

    // Grant access, send receipt, etc. await grantUserAccess(invoice.customer); } ```

    This prevents the infamous "customer charged twice" nightmare.

    ---

    Step 3: Local Testing with Stripe CLI

    Before deploying, test locally. Install [Stripe CLI](https://stripe.com/docs/stripe-cli) (verify current version in official docs):

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

    This outputs a signing secret. Set it in .env:

    ``` STRIPE_WEBHOOK_SECRET=whsec_test_... ```

    Then trigger test events:

    ```bash stripe trigger payment_intent.succeeded ```

    You'll see your endpoint receive and process the event in real time.

    ---

    Step 4: Configure in Stripe Dashboard

    1. Go to [Developers → Webhooks](https://dashboard.stripe.com/webhooks) 2. Click "Add endpoint" 3. Enter your production URL: https://yourdomain.com/webhooks/stripe 4. Select events (at minimum: customer.subscription.updated, invoice.payment_succeeded, invoice.payment_failed) 5. Copy the signing secret into your .env for production

    ---

    Common Errors & Solutions

    Error 1: Signature Mismatch

    ``` Error: No signatures found matching the expected signature for payload. ``` Cause: Wrong body format (you parsed JSON before signature check) Fix: Use express.raw() or equivalent raw body middleware

    Error 2: Timeout

    ``` Unhandled promise rejection: Request timeout after 30000ms ``` Cause: Your handler takes >30 seconds Fix: Always respond immediately, queue processing asynchronously

    Error 3: Duplicate Processing

    ``` Duplicate invoice charge detected for customer cus_12345 ``` Cause: Missing idempotency check Fix: Store event.id before processing, check it exists first

    ---

    Retry & Timeout Strategy

    Stripe retries failed webhooks with exponential backoff for 3 days. For your handlers:

    ```javascript async function handlePaymentSuccess(invoice) { const maxRetries = 3; let attempt = 0;

    while (attempt < maxRetries) { try { await grantUserAccess(invoice.customer); return; // Success } catch (err) { attempt++; if (attempt >= maxRetries) throw err; await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt))); } } } ```

    ---

    Production Checklist

  • [ ] HTTPS endpoint (not HTTP)
  • [ ] Signature verification enabled
  • [ ] Idempotent event handlers (using event.id)
  • [ ] Async processing (respond before handling)
  • [ ] Database transaction isolation
  • [ ] Error logging to Sentry/datadog
  • [ ] Webhook events logged for audit trail
  • [ ] Tested with stripe-cli
  • [ ] Dead-letter queue for failed events
  • [ ] Monitoring/alerting on webhook failures
  • ---

    Key Differences from REST API

    Webhooks give you *eventual consistency*. The REST API gives you *immediate, synchronous* responses. For subscription state, query the API at critical moments (user login, checkout) rather than trusting webhooks alone.

    [Webhook reliability strategies](/?guide=webhook-reliability) [Subscription lifecycle best practices](/?guide=subscription-patterns)

    ---

    What am I missing?

    Have you hit webhook bugs in production? Specific Stripe event types causing headaches? Multi-tenant isolation gotchas? Drop corrections and war stories in the comments—this guide needs real-world input.

    Official references:

  • [Stripe Webhooks Docs](https://stripe.com/docs/webhooks)
  • [Webhook Event Types](https://stripe.com/docs/api/events/types)
  • [Stripe CLI Reference](https://stripe.com/docs/stripe-cli)
  • 🔥 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