Stripe Webhooks for Indie SaaS 2026: Setup Guide

Production-ready webhook implementation for Stripe. Handle events, verify signatures, retry logic, and common pitfalls indie developers face.

TL;DR

Set up Stripe webhooks by creating an endpoint that verifies signatures using your signing secret, processes events idempotently, and returns 2xx status codes. Use the official Stripe libraries (v14.0.0+ for Node.js, verify in official docs for your language), implement exponential backoff for retries, and test locally with the Stripe CLI before deploying.

---

Why Webhooks Matter for Your SaaS

Webhooks are how Stripe tells your application about events: subscription renewals, payment failures, customer disputes. Unlike polling, webhooks are real-time and cost-efficient. For indie SaaS, this means:

  • Immediate reaction: Process subscription upgrades/downgrades instantly
  • Revenue protection: Detect failed payments before customers lose access
  • Compliance: Keep audit trails of payment events
  • Scale efficiency: No background jobs polling the Stripe API every second
  • ---

    Step 1: Create Your Webhook Endpoint

    Your endpoint needs to: 1. Accept POST requests from Stripe 2. Verify the signature (critical for security) 3. Process the event idempotently 4. Return 200 quickly

    Node.js/Express Example (Stripe SDK v14.0.0+)

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

    const app = express(); const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;

    // Raw body required 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(Signature verification failed: ${err.message}); return res.status(400).send(Webhook Error: ${err.message}); }

    // Process asynchronously to return 200 immediately handleWebhookEvent(event).catch((err) => { console.error(Webhook processing error for ${event.id}:, err); // Log for manual retry investigation });

    res.status(200).json({ received: true }); } );

    async function handleWebhookEvent(event) { // Idempotency check: prevent duplicate processing const existingRecord = await db.webhookLog.findUnique({ where: { stripeEventId: event.id }, });

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

    switch (event.type) { case 'customer.subscription.updated': await handleSubscriptionUpdate(event.data.object); break; case 'invoice.payment_failed': await handlePaymentFailure(event.data.object); break; case 'charge.dispute.created': await handleDispute(event.data.object); break; default: console.log(Unhandled event type: ${event.type}); }

    // Log successful processing await db.webhookLog.create({ stripeEventId: event.id, eventType: event.type, processedAt: new Date(), }); }

    async function handleSubscriptionUpdate(subscription) { const customer = await db.user.findUnique({ where: { stripeCustomerId: subscription.customer }, });

    if (!customer) { console.error(Customer not found: ${subscription.customer}); return; }

    await db.user.update({ where: { id: customer.id }, data: { subscriptionStatus: subscription.status, currentPeriodEnd: new Date(subscription.current_period_end * 1000), }, }); }

    app.listen(3000); ```

    ---

    Step 2: Retrieve Your Signing Secret

    1. Log into [Stripe Dashboard](https://dashboard.stripe.com) 2. Navigate to Developers > Webhooks 3. Click Add endpoint 4. Enter your URL: https://yourdomain.com/webhooks/stripe 5. Select events: customer.subscription.updated, invoice.payment_failed, charge.dispute.created (minimum for SaaS) 6. Copy the Signing secret (starts with whsec_) 7. Add to .env: STRIPE_WEBHOOK_SECRET=whsec_...

    Important: Never commit this to git. Use environment variables.

    ---

    Step 3: Test Locally with Stripe CLI

    [Stripe CLI documentation](https://stripe.com/docs/stripe-cli)

    Install Stripe CLI (verify latest version in official docs):

    ```bash

    macOS

    brew install stripe/stripe-cli/stripe

    Verify

    stripe version ```

    Authenticate and forward events:

    ```bash stripe login

    Opens browser for authentication

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

    Output:

    > Ready! Your webhook signing secret is whsec_test_...

    ```

    In another terminal, trigger a test event:

    ```bash stripe trigger customer.subscription.updated ```

    Check your console output. You should see the event processed without errors.

    ---

    Common Console Errors & Solutions

    Error 1: "No signatures found matching the expected signature"

    ``` Error: No signatures found matching the expected signature for payload. Webhook signing secret: whsec_test_*** ```

    Cause: Webhook secret mismatch or body was modified before signature verification.

    Fix: Ensure express.raw() middleware is used and secret is correct.

    ```javascript // Wrong - won't work app.use(express.json()); app.post('/webhooks/stripe', (req, res) => { /* ... */ });

    // Correct - use raw for this route only app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => { /* ... */ }); ```

    Error 2: "Timeout: endpoint did not respond within 10 seconds"

    Stripe waits max 10 seconds for a 2xx response. Long database operations cause timeouts.

    Fix: Return 200 immediately, process asynchronously.

    ```javascript // Bad res.json(await slowDatabaseOperation());

    // Good setImmediate(() => slowDatabaseOperation()); res.status(200).json({ received: true }); ```

    Error 3: "Duplicate event processing detected"

    Stripe retries failed webhooks. Without idempotency checks, you'll charge customers twice.

    Fix: Store event.id and check before processing (see example above).

    ---

    Production Checklist

  • ✅ Verify signatures using official SDK
  • ✅ Return 200 status immediately
  • ✅ Process events asynchronously (queue or background job)
  • ✅ Implement idempotency with event.id
  • ✅ Log all webhook events to database for audit trails
  • ✅ Add error handling and monitoring (Sentry, LogRocket)
  • ✅ Test with Stripe CLI before deploying
  • ✅ Use HTTPS in production (required by Stripe)
  • ✅ Implement exponential backoff if you make Stripe API calls within webhooks
  • ✅ Set up monitoring for webhook failures in Stripe Dashboard
  • ---

    Related Reading

    [Handling Failed Payments](/?guide=stripe-retry-logic) | [Setting Up Subscriptions](/?guide=stripe-subscriptions)

    ---

    Resources

  • [Stripe Webhooks Official Docs](https://stripe.com/docs/webhooks)
  • [Stripe API Reference](https://stripe.com/docs/api)
  • [Stripe CLI Reference](https://stripe.com/docs/stripe-cli)
  • [Webhook Event Types](https://stripe.com/docs/api/events/types)
  • ---

    What am I missing?

    This guide covers the core setup. What else should indie developers know about Stripe webhooks?

  • Database schema for webhook logs?
  • Handling Stripe account transfers or connected accounts?
  • Monitoring and alerting strategies?
  • Multi-environment testing (staging vs. production)?
  • Please share in comments—this is a living guide.

    🔥 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