Stripe Webhooks for SaaS: Production Setup Guide 2026

Complete webhook implementation for indie SaaS. Real error messages, production patterns, exact configs. Verify pricing in official docs.

TL;DR

Stripe webhooks require HTTPS endpoints, signature verification, and idempotent handlers. This guide covers production-ready setup with real error patterns you'll encounter, using Stripe API v2024-12-15 (verify in official docs for current version).

Why Webhooks Matter for Indie SaaS

If you're handling payments, you need webhooks. They're how Stripe tells you when:

  • Charges succeed or fail
  • Subscriptions renew or cancel
  • Invoices are created
  • Disputes are filed
  • Without them, you're polling the API (expensive) or missing critical events (dangerous).

    Core Setup: The Three-Step Process

    1. Create Your HTTPS Endpoint

    Webhooks require HTTPS. If you're testing locally, use [ngrok](https://ngrok.com) or similar tunneling.

    Express.js example (production-ready):

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

    const app = express();

    // Important: Raw body needed 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 res.status(400).send(Webhook Error: ${err.message}); }

    // Process event try { await handleWebhookEvent(event); res.json({ received: true }); } catch (err) { console.error(Event processing error for ${event.type}:, err); res.status(500).json({ error: 'Event processing failed' }); } } );

    app.listen(3000, () => console.log('Webhook listener running')); ```

    Critical detail: The express.raw() middleware is mandatory—without it, constructEvent() will fail silently or throw cryptic errors.

    2. Register Endpoint in Stripe Dashboard

    1. Go to [Stripe Dashboard](https://dashboard.stripe.com) → Developers → Webhooks 2. Click "Add endpoint" 3. Enter your URL: https://yourdomain.com/webhooks/stripe 4. Select events to receive (start with: charge.succeeded, charge.failed, customer.subscription.updated, customer.subscription.deleted) 5. Copy the signing secret to your .env: STRIPE_WEBHOOK_SECRET=whsec_...

    Verify in official docs for current event types and their schemas at [Stripe Webhook Events](https://docs.stripe.com/api/events).

    Real Error Messages You'll See

    Error #1: Signature Verification Fails

    ``` Error: No signatures found matching the expected signature for payload. Received signature(s): t=1704067200,v1=abc123... ```

    Cause: Missing express.raw() middleware or wrong webhook secret.

    Fix: ```javascript // Wrong ❌ app.post('/webhooks/stripe', express.json(), (req, res) => { // req.body is parsed JSON, signature verification fails });

    // Correct ✅ app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => { // req.body is Buffer, signature verifies }); ```

    Error #2: Timeout on Stripe's Retry

    ``` "Webhook endpoint did not respond with a 2xx status code. Received status: 504" ```

    Cause: Your handler is taking too long (Stripe times out after ~30 seconds).

    Fix: Make webhook handlers async but don't await long operations:

    ```javascript app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => { const event = stripe.webhooks.constructEvent(...); // Respond immediately res.json({ received: true }); // Process in background handleWebhookEvent(event).catch(err => { console.error('Background processing failed:', err); // Send alert to Sentry/PagerDuty }); }); ```

    Error #3: Duplicate Event Processing

    ``` Customer charged twice: charge_id=ch_1Abc... processed at 14:23:01 and 14:23:17 ```

    Cause: Stripe retries failed webhooks; you processed the same event twice.

    Fix: Make handlers idempotent:

    ```javascript async function handleChargeSucceeded(event) { const chargeId = event.data.object.id; // Check if already processed const existing = await db.payments.findOne({ stripe_charge_id: chargeId }); if (existing) { console.log(Charge ${chargeId} already processed, skipping); return; } // Create payment record await db.payments.create({ stripe_charge_id: chargeId, amount: event.data.object.amount, customer_id: event.data.object.customer, processed_at: new Date(), }); } ```

    Event Handler Pattern (Production-Ready)

    ```javascript const eventHandlers = { 'charge.succeeded': async (event) => { const { id, customer, amount } = event.data.object; await db.payments.create({ stripe_id: id, customer_id: customer, amount, status: 'succeeded', }); }, 'charge.failed': async (event) => { const { id, customer, failure_message } = event.data.object; await db.payments.create({ stripe_id: id, customer_id: customer, status: 'failed', error: failure_message, }); await notifyUser(customer, 'Payment failed'); }, 'customer.subscription.deleted': async (event) => { const { id, customer } = event.data.object; await db.subscriptions.updateOne( { stripe_subscription_id: id }, { status: 'cancelled', cancelled_at: new Date() } ); }, };

    async function handleWebhookEvent(event) { const handler = eventHandlers[event.type]; if (!handler) { console.warn(No handler for event type: ${event.type}); return; } await handler(event); } ```

    Testing Locally

    Use Stripe CLI to test without ngrok:

    ```bash

    Install: verify installation in [Stripe CLI docs](https://docs.stripe.com/stripe-cli)

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

    In another terminal

    stripe trigger charge.succeeded ```

    You'll see the test event hit your handler.

    Monitoring & Reliability

    1. Log every event: Include event.id and timestamp for debugging 2. Use retry logic: Stripe retries failed webhooks up to 5 times over 3 days 3. Alert on failures: Send to Sentry, PagerDuty, or Slack 4. Test in dashboard: Stripe provides test event resend in the webhook details page

    See [webhook reliability guide](/?guide=error-handling) and [monitoring SaaS payments](/?guide=payment-monitoring) for deeper dives.

    Pricing & Rate Limits

    Webhooks themselves are free. Verify current Stripe pricing and API limits in [official pricing docs](https://stripe.com/pricing)—pricing models change and we don't want outdated info.

    Production Checklist

  • [ ] HTTPS endpoint configured
  • [ ] Webhook secret in environment variables (not hardcoded)
  • [ ] Signature verification enabled
  • [ ] Idempotent handlers implemented
  • [ ] Error logging configured
  • [ ] Background processing doesn't block response
  • [ ] Monitored for failed events
  • [ ] Tested with Stripe CLI locally
  • [ ] All required events subscribed to
  • [ ] Timeout handling implemented (respond in <30s)
  • What am I missing?

    Webhook setups vary wildly—database choices, async patterns, monitoring tools. What did I overlook? Are there error messages from your production logs that should be here? Drop corrections and additions in the comments. Especially flag outdated API versions or pricing info.

    🔥 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