Stripe Webhooks Setup for Indie SaaS 2026

Production-ready webhook implementation for Stripe. Handle events reliably, verify signatures, retry logic, and avoid common pitfalls.

Stripe Webhooks Setup for Indie SaaS 2026

TL;DR: Webhooks let Stripe notify your server of payment events asynchronously. Always verify signatures with stripe.Webhook.constructEvent(), idempotently process events, implement retry logic, and use [events API v1](https://docs.stripe.com/api/events) for testing. Production requires HTTPS endpoint, proper error handling, and monitoring.

Why Webhooks Matter for Indie SaaS

Your customer completes payment on Stripe's hosted checkout. Now what? Webhooks are Stripe's way of telling your backend: "Hey, this transaction succeeded." Without them, you're polling Stripe's API constantly (wasteful) or relying on client-side callbacks (unreliable).

For indie SaaS, webhooks handle:

  • Activating subscriptions after successful payment
  • Sending invoice emails
  • Triggering dunning workflows for failed payments
  • Revoking access when subscriptions cancel
  • Setting Up Your Webhook Endpoint

    Step 1: Create an HTTPS Endpoint

    Stripe only sends webhooks to HTTPS endpoints (HTTP fails silently in production). If you're testing locally, use stripe listen with the CLI.

    Node.js/Express example (v4.x):

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

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

    // 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']; let event;

    try { // Verify the webhook signature event = stripe.webhooks.constructEvent( req.body, sig, webhookSecret ); } catch (err) { console.error(Webhook signature verification failed: ${err.message}); return res.sendStatus(400); }

    // Handle specific events switch (event.type) { case 'payment_intent.succeeded': await handlePaymentSuccess(event.data.object); break; case 'customer.subscription.updated': await handleSubscriptionUpdate(event.data.object); break; case 'invoice.payment_failed': await handlePaymentFailed(event.data.object); break; default: console.log(Unhandled event type: ${event.type}); }

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

    Common error: Using express.json() instead of express.raw() destroys the raw body, making signature verification fail: ``` Error: No signatures found matching the expected signature for payload ```

    Step 2: Verify Webhook Signatures

    Always verify signatures. Never trust incoming webhooks without validation.

    Why: Attackers can POST fake events to your endpoint. Signature verification uses HMAC-SHA256 with your webhook secret. Stripe signs every webhook with: ``` Sig = HMAC-SHA256(timestamp.payload, webhookSecret) ```

    The stripe.webhooks.constructEvent() function verifies this automatically and throws if invalid.

    Step 3: Idempotent Event Handling

    Stripe may send the same event multiple times (network retries). Your code must handle duplicates gracefully.

    Pattern: Use event ID as idempotency key

    ```javascript async function handlePaymentSuccess(paymentIntent) { const idempotencyKey = paymentIntent.id; // Check if we already processed this event const existingRecord = await db.webhookProcessed.findUnique({ where: { event_id: idempotencyKey } }); if (existingRecord) { console.log(Event ${idempotencyKey} already processed, skipping); return; } // Process payment const customerId = paymentIntent.metadata.customer_id; await db.subscription.update({ where: { id: customerId }, data: { status: 'active', activated_at: new Date() } }); // Record that we processed this await db.webhookProcessed.create({ event_id: idempotencyKey, event_type: 'payment_intent.succeeded', processed_at: new Date() }); } ```

    Step 4: Implement Retry Logic

    If your endpoint returns non-2xx status or times out, Stripe retries with exponential backoff over 3 days. Don't return 500 errors unless you want retries.

    Bad pattern: ```javascript // DON'T DO THIS try { await handleEvent(); } catch (err) { return res.sendStatus(500); // Stripe will retry forever } ```

    Good pattern: ```javascript // DO THIS try { await handleEvent(); return res.json({ received: true }); } catch (err) { // Log error, alert team, but return 200 console.error('Webhook processing failed:', err); await notifySlack(Webhook error: ${err.message}); return res.json({ received: true, error: 'processing failed' }); } ```

    Registering Webhooks 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/webhooks/stripe 4. Select events to listen for (start with payment_intent.succeeded, customer.subscription.updated, invoice.payment_failed) 5. Copy the "Signing Secret" and save to .env as STRIPE_WEBHOOK_SECRET

    Testing Locally with Stripe CLI

    Verify in official docs: [Stripe CLI v1.x](https://docs.stripe.com/stripe-cli)

    ```bash

    Install (macOS with Homebrew)

    brew install stripe/stripe-cli/stripe

    Login and authenticate

    stripe login

    Forward webhook events to localhost

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

    In another terminal, trigger test events

    stripe trigger payment_intent.succeeded ```

    The CLI outputs a signing secret for local testing. Update your .env.

    Production Checklist

  • ✅ HTTPS endpoint only (HTTP rejected)
  • ✅ Signature verification enabled
  • ✅ Idempotency keys stored in database
  • ✅ Error handling returns 200 (not 500)
  • ✅ Timeout set to >10 seconds (Stripe waits 20s max)
  • ✅ Monitoring/alerting on webhook failures
  • ✅ Database schema for webhook_processed table
  • ✅ Tested with Stripe CLI before deploying
  • ✅ Webhook secrets in environment variables (never hardcoded)
  • ✅ Rate limiting on endpoint (optional, prevents abuse)
  • Common Pitfalls

    Pitfall 1: Parsing body twice ``` Error: No signatures found matching the expected signature ``` This happens if you parse the raw body before signature verification. Parse only after constructEvent().

    Pitfall 2: Wrong webhook secret ``` Error: No signatures found matching the expected signature for payload ``` Make sure you're using the endpoint's signing secret, not your API key. They're different values.

    Pitfall 3: Not handling network timeouts ``` Error: ECONNRESET from Stripe servers ``` Your endpoint must respond within 20 seconds. Long database operations should be queued async with job workers.

    Links and Resources

  • [Stripe Webhooks Official Docs](https://docs.stripe.com/webhooks)
  • [Webhook Events Reference](https://docs.stripe.com/api/events)
  • [Signature Verification Guide](https://docs.stripe.com/webhooks/signatures)
  • Related: [SaaS billing fundamentals](/?guide=saas-billing-basics)
  • Related: [Handling failed payments](/?guide=payment-retry-logic)
  • What am I missing?

    I've covered signature verification, idempotency, and local testing. But every SaaS has unique needs:

  • Are you handling invoice.payment_failed events for dunning?
  • How do you monitor webhook queue backlogs?
  • Any production failures you'd warn others about?
  • Using a webhook queue (Bull, RQ, etc.) instead of inline processing?
  • Drop your questions and hard-won insights in the comments. The indie dev community learns fastest from real battle scars.

    🔥 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