Stripe Webhooks Setup for Indie SaaS in 2026

Production-ready webhook implementation for Stripe events. Handle payment confirmations, subscription changes, and failures with error handling patterns.

TL;DR

Stripe webhooks listen for payment events asynchronously. You need: (1) a public HTTPS endpoint, (2) webhook secret verification, (3) idempotent event handlers, (4) retry logic for failures. Use Stripe CLI v1.18+ (verify in official docs) for local testing. Never trust the request body without signature validation.

---

Why Webhooks Matter for Indie SaaS

Webhooks solve the polling problem. Instead of asking Stripe "did the payment process?" every 30 seconds, Stripe tells you immediately. For SaaS with subscription billing, webhooks handle:

  • Payment confirmations (charge.succeeded)
  • Subscription lifecycle (customer.subscription.created, customer.subscription.deleted)
  • Disputes and failures (charge.failed, charge.dispute.created)
  • Invoice events (invoice.payment_succeeded, invoice.payment_failed)
  • Missing webhook handlers = customers can't activate features, churn tracking breaks, and refunds go unnoticed.

    ---

    Step 1: Create Your Webhook Endpoint

    Your endpoint must be:

  • Public HTTPS (localhost won't work in production)
  • Fast (respond with 200 within 5 seconds, process async)
  • Idempotent (handling the same event twice = same result)
  • Here's a production-ready Node.js/Express example:

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

    // Raw body required for signature verification app.post('/api/webhooks/stripe', express.raw({type: 'application/json'}), async (req, res) => { const sig = req.headers['stripe-signature']; const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; 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}); }

    // Always respond 200 immediately res.status(200).json({received: true});

    // Process event async in background processEvent(event).catch(err => { console.error(Webhook event processing failed: ${event.id} - ${err.message}); // Your alerting/logging here }); } );

    async function processEvent(event) { switch(event.type) { case 'charge.succeeded': await handleChargeSucceeded(event.data.object); break; case 'customer.subscription.updated': await handleSubscriptionUpdate(event.data.object); break; case 'charge.failed': await handleChargeFailed(event.data.object); break; default: console.log(Unhandled event type: ${event.type}); } }

    async function handleChargeSucceeded(charge) { // Check if already processed (idempotency key) const existing = await db.payments.findOne({stripe_charge_id: charge.id}); if (existing) return; // Already processed // Create payment record await db.payments.create({ stripe_charge_id: charge.id, customer_id: charge.customer, amount: charge.amount, currency: charge.currency, status: 'completed', processed_at: new Date(charge.created * 1000) }); // Update subscription status await db.subscriptions.updateOne( {stripe_customer_id: charge.customer}, {status: 'active', last_payment_date: new Date()} ); }

    async function handleChargeFailed(charge) { const existing = await db.payment_failures.findOne({stripe_charge_id: charge.id}); if (existing) return; await db.payment_failures.create({ stripe_charge_id: charge.id, customer_id: charge.customer, error_message: charge.failure_message, error_code: charge.failure_code, created_at: new Date() }); // Notify customer or trigger retry flow await sendPaymentFailureEmail(charge.customer, charge.failure_message); }

    app.listen(3000); ```

    ---

    Step 2: Register Webhook in Stripe Dashboard

    1. Go to [Stripe Dashboard → Developers → Webhooks](https://dashboard.stripe.com/webhooks) 2. Click "Add endpoint" 3. Enter your endpoint URL: https://yourdomain.com/api/webhooks/stripe 4. Select events to listen for (minimum): - charge.succeeded - charge.failed - customer.subscription.created - customer.subscription.updated - customer.subscription.deleted 5. Copy the Signing Secret (starts with whsec_) 6. Add to your .env: STRIPE_WEBHOOK_SECRET=whsec_...

    ---

    Step 3: Test Locally with Stripe CLI

    [Install Stripe CLI](https://stripe.com/docs/stripe-cli) (verify version 1.18+ in official docs):

    ```bash

    Forward events to your local endpoint

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

    This outputs a signing secret. Copy it:

    ```bash export STRIPE_WEBHOOK_SECRET="whsec_test_..." ```

    Trigger test events:

    ```bash

    Test successful charge

    stripe trigger charge.succeeded

    Test failed charge

    stripe trigger charge.failed ```

    Watch your server logs for processing.

    ---

    Common Error Messages You'll See

    Error 1: Signature Verification Failure

    ``` Webhook signature verification failed: No signatures found matching the expected signature for payload. ``` Cause: Wrong webhook secret, or signature header missing Fix: Verify STRIPE_WEBHOOK_SECRET matches dashboard value exactly

    Error 2: Request Body Not Raw

    ``` Webhook signature verification failed: Timestamp outside the tolerance zone ``` Cause: Body was parsed as JSON instead of kept as raw buffer Fix: Use express.raw({type: 'application/json'}) middleware before other parsers

    Error 3: Timeout

    ``` Webhook endpoint timed out after 5 seconds ``` Cause: Processing logic inside the webhook handler Fix: Always respond 200 immediately, process in background queue (Redis, Bull, SQS)

    ---

    Production Patterns

    1. Queue Processing (Critical)

    Never do database writes inside the webhook handler:

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

    // Push to queue instead await queue.add('process-webhook', event, { attempts: 3, backoff: {type: 'exponential', delay: 2000} }); ```

    2. Idempotency (Critical)

    Stripe can resend events. Check event.id hasn't been processed:

    ```javascript const processed = await db.webhook_events.findOne({event_id: event.id}); if (processed) return;

    await db.webhook_events.create({event_id: event.id, type: event.type}); // Then process... ```

    3. Logging (Essential)

    Log every event with context for debugging:

    ```javascript logger.info('Webhook received', { event_id: event.id, event_type: event.type, timestamp: new Date(), customer_id: event.data.object.customer }); ```

    ---

    Related Resources

  • [Official Stripe Webhooks Docs](https://stripe.com/docs/webhooks)
  • [Stripe API Reference - Events](https://stripe.com/docs/api/events)
  • [Stripe Testing](https://stripe.com/docs/testing)
  • See also: [SaaS Billing Setup Guide](/?guide=saas-billing) and [Stripe API Error Handling](/?guide=stripe-errors)

    ---

    What am I missing?

    Have you encountered webhook issues I haven't covered?

  • Missing event types for your use case?
  • Better retry strategies?
  • Monitoring/alerting recommendations?
  • Django/Rails/Go examples?
  • Drop corrections and experiences 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