Stripe Webhooks Setup for Indie SaaS 2026

Production-ready Stripe webhook implementation with error handling, security patterns, and real debugging tips for indie developers.

TL;DR

Stripe webhooks require: (1) HTTPS endpoint with signature verification, (2) idempotent event handlers, (3) proper error responses to prevent retries. Use the Stripe CLI for local testing before going live. Always verify webhook secrets and implement database-level idempotency keys.

---

Why Webhooks Matter for SaaS

Webhooks are how Stripe notifies your application about customer events: successful payments, failed renewals, subscription updates, disputes. Without them, your SaaS won't know when to grant access, charge customers, or handle cancellations.

The alternative—polling Stripe's API every minute—wastes resources and creates race conditions. Webhooks are event-driven, reliable, and what production systems use.

Step 1: Create Your Webhook Endpoint

Your endpoint needs three properties:

HTTPS required - Stripe won't send to HTTP endpoints. If testing locally, use [Stripe CLI](https://stripe.com/docs/stripe-cli) instead of ngrok.

Stateless - Process each event independently. Don't assume ordering.

Fast responses - Return 200 OK within 30 seconds, or Stripe retries.

Here's a production-ready Node.js/Express example (using Express v4.x):

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

// Raw body for signature verification app.post( '/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => { const sig = req.headers['stripe-signature']; const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET; let event; try { event = stripe.webhooks.constructEvent( req.body, sig, endpointSecret ); } catch (err) { console.error(⚠️ Webhook signature verification failed: ${err.message}); return res.status(400).send(Webhook Error: ${err.message}); } // Respond immediately to prevent retries res.status(200).json({ received: true }); // Process async handleWebhookEvent(event).catch(err => { console.error(Error processing webhook ${event.id}: ${err.message}); // Log to error tracking (Sentry, etc) }); } );

async function handleWebhookEvent(event) { const { type, data, id } = event; // Idempotency: check if we've processed this before const processed = await db.webhookEvents.findOne({ stripe_event_id: id }); if (processed) { console.log(Event ${id} already processed, skipping); return; } try { switch (type) { case 'customer.subscription.created': await grantSubscriptionAccess(data.object); break; case 'invoice.payment_succeeded': await logPayment(data.object); break; case 'customer.subscription.deleted': await revokeSubscriptionAccess(data.object); break; case 'charge.dispute.created': await alertAdmin(data.object); break; default: console.log(Unhandled event type: ${type}); } // Mark as processed await db.webhookEvents.insertOne({ stripe_event_id: id, type, processed_at: new Date(), status: 'success' }); } catch (error) { // Don't throw—log and continue console.error(Failed to handle ${type}: ${error.message}); await db.webhookEvents.insertOne({ stripe_event_id: id, type, processed_at: new Date(), status: 'failed', error: error.message }); } } ```

Common Console Errors (And Fixes)

Error 1: Signature verification failed ``` Webhook Error: No signatures found matching the expected signature for payload. Possible causes: (1) using wrong endpoint secret, (2) request body modified before verification ``` *Fix:* Use express.raw() middleware. Don't use express.json() before signature check. Verify your endpoint secret in [Stripe Dashboard](https://dashboard.stripe.com/webhooks) matches STRIPE_WEBHOOK_SECRET env var.

Error 2: Request timeout after 30 seconds ``` Stripe::InvalidRequestError: Webhook endpoint did not respond with a 2xx status code. Response status code: 504 Gateway Timeout ``` *Fix:* Return 200 immediately. Move heavy work to async job queue (Bull, RabbitMQ). If your database query takes 20 seconds, that's the problem.

Error 3: Duplicate processing ``` Customer charged twice for same subscription renewal. Webhook event ID: evt_xxx processed at 14:32:01 and 14:32:04 ``` *Fix:* Store processed event IDs with unique constraint on stripe_event_id. Query this table before processing any action.

Step 2: Register the Webhook in Stripe

Dashboard method: 1. Go to [Stripe Dashboard → Developers → Webhooks](https://dashboard.stripe.com/webhooks) 2. Click "Add endpoint" 3. Enter URL: https://yourapp.com/webhooks/stripe 4. Select events: customer.subscription.*, invoice.*, charge.dispute.* 5. Copy the signing secret to STRIPE_WEBHOOK_SECRET

Via API (verify in [official Stripe docs](https://stripe.com/docs/api/webhook_endpoints/create)): ```bash curl https://api.stripe.com/v1/webhook_endpoints \ -u sk_live_xxx: \ -d url='https://yourapp.com/webhooks/stripe' \ -d 'enabled_events[]'='customer.subscription.created' \ -d 'enabled_events[]'='invoice.payment_succeeded' \ -d 'enabled_events[]'='customer.subscription.deleted' ```

Step 3: Local Testing with Stripe CLI

Don't use ngrok. Use Stripe CLI (v1.18.0+, [verify in official docs](https://stripe.com/docs/stripe-cli)):

```bash

Install

brew install stripe/stripe-cli/stripe # macOS

or download from https://github.com/stripe/stripe-cli/releases

Authenticate

stripe login

Forward events to local endpoint

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

In another terminal, trigger test events

stripe trigger charge.dispute.created stripe trigger customer.subscription.created ```

The CLI outputs your webhook signing secret—use this for local development.

Step 4: Production Checklist

  • [ ] HTTPS enabled and valid certificate
  • [ ] express.raw() middleware for signature verification
  • [ ] 200 response within 30 seconds
  • [ ] Idempotency database table with unique index on stripe_event_id
  • [ ] Environment variables: STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET
  • [ ] Error tracking integration (Sentry, Datadog, etc)
  • [ ] Database logging of all webhook events
  • [ ] Monitoring: alert if webhook processing fails for 5+ events
  • [ ] Webhook dashboard displays recent events and retry status
  • Testing the Full Flow

    Create a test subscription via Stripe Dashboard, then in your CLI:

    ```bash stripe trigger customer.subscription.created \ --override customer.id=cus_TestCustomerID ```

    Check your database for the processed event. Verify the customer got access.

    What About Retries?

    Stripe retries failed webhooks exponentially: 5 sec, 30 sec, 2 min, 5 min, 30 min, 2 hours, 5 hours, 10 hours. Your idempotency check prevents duplicate processing even on retries.

    Monitor retry rates in the Stripe Dashboard. High retry rates mean your endpoint is slow or buggy.

    Related Resources

    [How to structure your database for SaaS subscriptions](/?guide=saas-database-schema)

    [Handling failed payments and dunning](/?guide=stripe-dunning-flow)

    ---

    What am I missing?

    Did this guide miss something critical? Disagree with the patterns? Have a production horror story? Comment below. I update based on reader feedback—please share edge cases, version-specific issues, or alternative approaches you've validated in production.

    🔥 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