Stripe Webhooks Setup for SaaS in 2026

Production-ready webhook implementation for indie SaaS. Real error patterns, exact code, and critical gotchas that will save you hours of debugging.

TL;DR

Set up Stripe webhooks by (1) creating an endpoint in your dashboard, (2) signing requests with your endpoint secret, (3) handling events idempotently, (4) retrying failed processing. Most failures stem from timestamp validation, missing idempotency keys, or race conditions in database writes. Test with stripe listen before production.

---

Why Webhooks Matter for SaaS

Stripe webhooks are your source of truth for subscription changes, payments, and disputes. Unlike polling, webhooks fire immediately when events occur—critical for dunning flows, seat count updates, and cancellation workflows.

Skipping proper webhook setup? You'll lose revenue on failed charges you didn't retry, charge customers twice, or grant access before payment clears.

Step 1: Create Your Endpoint

In [Stripe Dashboard](https://dashboard.stripe.com) → Developers → Webhooks:

1. Click "Add endpoint" 2. Enter your URL: https://yourdomain.com/api/webhooks/stripe 3. Select events: - charge.failed - charge.succeeded - customer.subscription.updated - customer.subscription.deleted - invoice.payment_failed - invoice.payment_succeeded

Stripe assigns you a signing secret (starts with whsec_). Save this in your .env:

```bash STRIPE_WEBHOOK_SECRET=whsec_your_secret_here ```

Do NOT commit this to version control.

Step 2: Verify Request Signatures

This is non-negotiable. Every webhook request includes a Stripe-Signature header. Stripe signs with HMAC-SHA256. Verify it before processing.

Node.js/Express (Stripe SDK v14+):

```javascript import Stripe from 'stripe'; import express from 'express';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); const app = express();

// IMPORTANT: Use raw body for webhook verification // If using body-parser, configure this route BEFORE middleware app.post( '/api/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => { const sig = req.headers['stripe-signature']; const secret = process.env.STRIPE_WEBHOOK_SECRET;

let event; try { event = stripe.webhooks.constructEvent(req.body, sig, secret); } catch (err) { console.error(Webhook signature verification failed: ${err.message}); return res.status(400).send(Webhook Error: ${err.message}); }

// Process event asynchronously handleWebhookEvent(event).catch((err) => { console.error(Event processing failed: ${event.id}, err); // Log to your error tracking (Sentry, etc.) });

// Acknowledge receipt immediately res.json({ received: true }); } );

async function handleWebhookEvent(event) { switch (event.type) { case 'charge.succeeded': await handleChargeSucceeded(event.data.object); break; case 'charge.failed': await handleChargeFailed(event.data.object); break; case 'customer.subscription.updated': await handleSubscriptionUpdated(event.data.object); break; case 'customer.subscription.deleted': await handleSubscriptionDeleted(event.data.object); break; default: console.log(Unhandled event type: ${event.type}); } } ```

Common Error #1: Raw Body Not Used ``` Error: No signatures found matching the expected signature for payload. ``` Fix: Ensure webhook route uses express.raw() BEFORE other body parsers apply.

Step 3: Idempotent Processing

Webhooks can fire multiple times (network retries, Stripe retries). Your code must handle duplicates safely.

Store processed event IDs:

```javascript async function handleChargeSucceeded(charge) { const eventId = charge.id; // or use metadata if needed

// Check if already processed const existing = await db.query( 'SELECT id FROM processed_charges WHERE stripe_charge_id = $1', [charge.id] );

if (existing.rows.length > 0) { console.log(Charge ${charge.id} already processed, skipping); return; }

// Process the charge await db.query('BEGIN'); try { await db.query( 'INSERT INTO processed_charges (stripe_charge_id, amount, processed_at) VALUES ($1, $2, NOW())', [charge.id, charge.amount] ); await updateSubscriptionStatus(charge.customer); await db.query('COMMIT'); } catch (err) { await db.query('ROLLBACK'); throw err; } } ```

Critical Pattern: Use database transactions. Insert the event record atomically with business logic changes.

Step 4: Testing Locally

Use the Stripe CLI ([install here](https://stripe.com/docs/stripe-cli)):

```bash

v1.19+ supports login flow

stripe login

Forward events to your local server

stripe listen --events charge.succeeded,charge.failed --forward-to localhost:3000/api/webhooks/stripe

In another terminal, trigger test events

stripe trigger charge.succeeded ```

The CLI outputs your signing secret for local testing: ``` Your webhook signing secret is: whsec_test_... ```

Step 5: Handle Edge Cases

Race Condition Example:

Webhook fires before your checkout page redirect completes. Customer sees "pending" but webhook already updated their subscription.

Fix: Query Stripe API for current state if webhook hasn't arrived:

```javascript async function getSubscriptionStatus(customerId) { const subs = await stripe.subscriptions.list( { customer: customerId, limit: 1 }, { apiVersion: '2023-10-16' } // verify in official docs ); return subs.data[0]; } ```

Common Error #2: Timestamp Validation Fails ``` Error: Request timestamp outside the tolerance window. ``` Fix: Ensure server time is NTP-synced. Stripe allows ±5 minutes by default; adjust if needed:

```javascript event = stripe.webhooks.constructEvent(req.body, sig, secret, 300); // 5 min tolerance ```

Common Error #3: Event Handler Timeout ``` Error: Task timed out after 30000ms ``` Fix: Return 200 immediately, process async:

```javascript res.json({ received: true }); await handleWebhookEvent(event); // Don't await before responding ```

Monitoring & Debugging

In your Stripe Dashboard, webhook delivery shows:

  • Successful deliveries (green)
  • Failures with retry schedule
  • Manual retry button
  • Check the "Logs" tab for raw request/response bodies.

    For production, log all webhook processing:

    ```javascript console.log([${new Date().toISOString()}] Event: ${event.id} Type: ${event.type}); ```

    Verify with Official Docs

  • [Stripe Webhooks Overview](https://stripe.com/docs/webhooks)
  • [Webhook Event Types](https://stripe.com/docs/api/events)
  • [Webhook Security](https://stripe.com/docs/webhooks/signatures)
  • [API Versions](https://stripe.com/docs/api/versioning) - verify current stable version in official docs
  • ---

    Related Guides

  • [SaaS Billing Architecture](/guide=saas-billing-patterns)
  • [Stripe Subscription Renewals](/guide=subscription-renewal-logic)
  • ---

    What am I missing?

    This guide covers the critical path for indie SaaS. But webhook handling is deep:

  • Are you using a job queue (Bull, Temporal, etc.) for reliable processing?
  • How do you handle webhook events for deleted customers?
  • Do you need webhook resend for failed processing?
  • Are there edge cases in your specific business model?
  • Share your webhook gotchas in the comments. This guide will be updated based on real feedback.

    🔥 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