Stripe Webhooks Setup for Indie SaaS 2026

Production-ready webhook patterns, real error messages, and security best practices for Stripe integration in your indie SaaS app.

TL;DR

Set up Stripe webhooks with signed event verification, retry logic, and idempotency keys. Use [Stripe CLI v1.19.0+](https://stripe.com/docs/stripe-cli) for local testing. Handle webhook_endpoint.failed_call errors by validating signatures via crypto.timingSafeEqual(). Always store event IDs to prevent duplicate processing.

---

Why Webhooks Matter for Indie SaaS

Webhooks are how Stripe tells your app about events—subscription created, payment failed, customer deleted. Unlike polling (inefficient), webhooks fire immediately. For indie hackers, this means:

  • Real-time business logic: Activate features when payment succeeds
  • Compliance: Retry failed charges according to Stripe's schedule
  • Cost savings: No background jobs hammering the API
  • But webhooks are asynchronous and untrusted by default. Miss the security part, and someone crafts a fake event to unlock premium features.

    ---

    Step 1: Create a Webhook Endpoint

    First, add a route to receive POST requests from Stripe:

    ```javascript // Node.js + Express example (production-ready) const express = require('express'); const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); const crypto = require('crypto');

    const app = express();

    // 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']; 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 400 so Stripe knows verification failed return res.status(400).send(Webhook Error: ${err.message}); }

    // Idempotency: store processed event IDs const isProcessed = await checkEventProcessed(event.id); if (isProcessed) { console.log(Event ${event.id} already processed, skipping); return res.json({ received: true }); }

    // Route to handler try { await handleStripeEvent(event); await markEventProcessed(event.id); res.json({ received: true }); } catch (err) { console.error('Event handler failed:', err); // Return 500 so Stripe retries res.status(500).send('Event processing failed'); } } );

    async function handleStripeEvent(event) { switch (event.type) { case 'customer.subscription.updated': await onSubscriptionUpdated(event.data.object); break; case 'invoice.payment_succeeded': await onPaymentSucceeded(event.data.object); break; case 'customer.subscription.deleted': await onSubscriptionCanceled(event.data.object); break; default: console.log(Unhandled event type: ${event.type}); } } ```

    Why this pattern?

  • express.raw(): Stripe signs the raw bytes. JSON parsing breaks the signature.
  • stripe.webhooks.constructEvent(): Verifies signature automatically using crypto.timingSafeEqual() internally.
  • Idempotency check: Stripe may retry failed events. Process each event ID only once.
  • Return 500 on handler failure: Stripe retries with exponential backoff.
  • ---

    Step 2: Register Endpoint in Stripe Dashboard

    1. Go to [Stripe Dashboard → Developers → Webhooks](https://dashboard.stripe.com/webhooks) 2. Click "Add endpoint" 3. Enter your public URL: https://yourdomain.com/webhooks/stripe 4. Select events: - customer.subscription.updated - customer.subscription.deleted - invoice.payment_succeeded - invoice.payment_failed 5. Copy the Signing Secret to your .env: STRIPE_WEBHOOK_SECRET=whsec_live_...

    Verify in [official docs](https://stripe.com/docs/webhooks/setup) for current event types if building beyond 2026.

    ---

    Step 3: Local Testing with Stripe CLI

    Don't test webhooks against production. Use Stripe CLI (v1.19.0+):

    ```bash

    Install (verify current version in official docs)

    brew install stripe/stripe-cli/stripe

    Login and link your account

    stripe login

    Start listening (creates secure tunnel to localhost:3000)

    stripe listen --events customer.subscription.updated,invoice.payment_succeeded --forward-to localhost:3000/webhooks/stripe

    In another terminal, trigger test events

    stripe trigger customer.subscription.updated stripe trigger invoice.payment_succeeded ```

    Expected console output: ``` 2024-01-15 10:23:45 → customer.subscription.updated [evt_1234] 2024-01-15 10:23:45 ← [200] Webhook processed successfully ```

    ---

    Common Error Messages & Fixes

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

    ``` Webhook signature verification failed: No signatures found matching the expected signature for payload. Webhook id: evt_.... ```

    Cause: Using JSON-parsed body instead of raw bytes.

    Fix: Ensure express.raw() middleware and verify STRIPE_WEBHOOK_SECRET matches dashboard.

    Error 2: "Webhook endpoint returned HTTP 500"

    Stripe retries with delays: 5s, 5m, 30m, 2h, 5h, 10h.

    ``` Webhook endpoint failed: returned status 500 (Internal Server Error) Event evt_123 queued for retry ```

    Cause: Handler threw error (likely database unavailable).

    Fix: Wrap handler in try-catch, return 500 only for transient errors. Log to external service (Sentry, DataDog).

    Error 3: "Idempotency error: Event processed twice"

    ``` Duplicate event processing detected for evt_123 ```

    Cause: No idempotency check; processed same event twice.

    Fix: Store event.id in database before handler runs. Check before processing.

    ---

    Idempotency Pattern (Database Example)

    ```javascript // Supabase/PostgreSQL example async function checkEventProcessed(eventId) { const { data } = await supabase .from('stripe_events') .select('id') .eq('event_id', eventId) .single(); return !!data; }

    async function markEventProcessed(eventId) { await supabase .from('stripe_events') .insert({ event_id: eventId, processed_at: new Date() }); } ```

    ---

    Security Checklist

  • ✅ Use stripe.webhooks.constructEvent() for signature verification
  • ✅ Store STRIPE_WEBHOOK_SECRET in .env, never commit
  • ✅ Return 500 on errors so Stripe retries
  • ✅ Return 200 only after processing succeeds
  • ✅ Implement idempotency (store processed event IDs)
  • ✅ Log all webhook events for debugging
  • ✅ Use HTTPS only (Stripe won't send to HTTP URLs)
  • ✅ Rotate webhook secrets periodically
  • ---

    Related Guides

  • [Stripe Payments Setup for Indie Hackers](/?guide=stripe-setup)
  • [Building Subscription Billing Logic](/?guide=subscription-logic)
  • ---

    What am I missing?

    Stripe updates webhook event types quarterly. Did I miss a critical error you've hit? Are there v2 patterns for async handlers? Comment below with your production webhook gotchas—let's build the definitive guide together.

    Verify current Stripe SDK versions in [official docs](https://github.com/stripe/stripe-node/releases) before deploying to 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