Stripe Webhooks Setup for Indie SaaS 2026

Production-ready webhook implementation with error handling, retry logic, and real failure patterns indie developers encounter.

TL;DR

Set up Stripe webhooks by creating an endpoint that validates signatures using your signing secret, handles events idempotently, and responds with 200 status within 30 seconds. Use webhook retries for reliability. Test with the Stripe CLI before going live.

---

Why Webhooks Matter for Indie SaaS

Webhooks are your asynchronous lifeline. When customers subscribe, upgrade, or fail payment, Stripe pushes events to your server rather than you polling. For indie SaaS—where every database query costs money—webhooks eliminate wasteful polling and keep your billing logic real-time.

Getting this wrong means:

  • Missing subscription cancellations (revenue leak)
  • Duplicate charge processing (support nightmare)
  • Lost customer data from failed webhook handlers
  • Step 1: Create Your Webhook Endpoint

    Your endpoint must: 1. Accept POST requests 2. Validate Stripe's signature 3. Process events idempotently 4. Respond with 200 within 30 seconds

    Here's a production-ready example using Node.js (Express 4.18.x) with the Stripe SDK (v14.0.0+):

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

    // Critical: use raw body parser ONLY for this route app.post( '/api/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, process.env.STRIPE_WEBHOOK_SECRET ); } catch (err) { console.error(❌ Webhook signature verification failed: ${err.message}); return res.status(400).send(Webhook Error: ${err.message}); }

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

    // Handle event in background handleWebhookEvent(event).catch(err => { console.error(Webhook processing error for event ${event.id}:, err); // Log to error tracking (Sentry, LogRocket, etc.) }); } );

    async function handleWebhookEvent(event) { // Idempotency: Check if we've processed this event const processed = await db.webhookEvents.findOne({ stripeId: event.id }); if (processed) { 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_succeeded': await handlePaymentSuccess(event.data.object); break; case 'invoice.payment_failed': await handlePaymentFailure(event.data.object); break; case 'customer.subscription.deleted': await handleSubscriptionCancellation(event.data.object); break; default: console.log(Unhandled event type: ${event.type}); }

    // Mark as processed await db.webhookEvents.create({ stripeId: event.id, type: event.type, processedAt: new Date(), }); }

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

    Step 2: Validate Signature Correctly

    This is non-negotiable. Stripe signs every webhook with your endpoint's signing secret. Never skip this.

    ```javascript // ❌ WRONG: Parsing JSON first const body = JSON.parse(req.body); const event = stripe.webhooks.constructEvent(body, sig, secret); // Fails!

    // ✅ CORRECT: Pass raw buffer const event = stripe.webhooks.constructEvent(req.body, sig, secret); ```

    Common console errors you'll encounter:

    Error 1: "No matching signing secret found" ``` Error: No matching signing secret found for [wh_1234...] ``` This means your STRIPE_WEBHOOK_SECRET doesn't match what Stripe has. Verify in your Stripe Dashboard → Developers → Webhooks → Select endpoint → Signing secret. Copy the exact value (starts with whsec_).

    Error 2: "Unexpected end of JSON input" ``` SyntaxError: Unexpected end of JSON input at JSON.parse (<anonymous>) ``` You're using express.json() instead of express.raw() on this route. Stripe's signature verification requires the raw body.

    Error 3: "Request body missing" ``` Error: No POST body detected. (If using express middleware, verify you passed raw body) ``` Same root cause—middleware parsed the body before verification.

    Step 3: Register Webhook in Stripe Dashboard

    1. Go to [Stripe Dashboard](https://dashboard.stripe.com) → Developers → Webhooks 2. Click "Add endpoint" 3. Enter your endpoint URL: https://yourdomain.com/api/webhooks/stripe 4. Select events you care about (at minimum: customer.subscription.*, invoice.payment_*) 5. Copy the signing secret (starts with whsec_) 6. Store in .env: STRIPE_WEBHOOK_SECRET=whsec_...

    Step 4: Test Locally with Stripe CLI

    Don't push untested webhooks to production.

    ```bash

    Install Stripe CLI v1.17.0+

    brew install stripe/stripe-cli/stripe # macOS

    Login

    stripe login

    Forward webhooks to localhost

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

    In another terminal, trigger a test event

    stripe trigger customer.subscription.updated ```

    You'll see output like: ``` > Ready! Your webhook signing secret is: whsec_test_... ```

    Use this test secret in your .env during development.

    Step 5: Implement Idempotency

    Stripe retries failed webhooks up to 5 times over 3 days. If your handler crashes mid-process, you might process the same event twice.

    Store every event ID in a database:

    ```javascript // Before processing const exists = await db.webhookEvents.findOne({ stripeId: event.id }); if (exists) return; // Already handled

    // After successful processing await db.webhookEvents.create({ stripeId: event.id, processedAt: new Date() }); ```

    Step 6: Handle These Core Events

    invoice.payment_succeeded: Customer paid. Grant access.

    invoice.payment_failed: Payment failed. Email customer, disable features if needed.

    customer.subscription.updated: Plan change or quantity update. Apply changes immediately.

    customer.subscription.deleted: Cancellation. Revoke access after trial period if configured.

    For detailed event schema, [check Stripe's event reference](https://stripe.com/docs/api/events).

    Production Checklist

  • [ ] Verify in official docs: webhook retry policy and timing
  • [ ] All webhooks respond with 200 status
  • [ ] Signature validation enforced (no exceptions)
  • [ ] Events logged with Stripe event ID for debugging
  • [ ] Database transaction wraps event + idempotency log
  • [ ] Error handling sends alerts to monitoring (Sentry)
  • [ ] Webhook endpoint rate-limited to prevent abuse
  • [ ] Secrets loaded from environment, never hardcoded
  • For environment variable security patterns, see [managing secrets for indie SaaS](/?guide=env-security).

    For handling subscription state, see [billing model patterns](/?guide=subscription-state).

    What am I missing?

    What gotchas have *you* hit with Stripe webhooks? Common issues:

  • Timezone handling in subscription billing dates?
  • Tax calculation webhook flows?
  • Multi-currency webhook considerations?
  • Webhook database cleanup strategies?
  • Comment below with your war stories and I'll fold them into this 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