Stripe Webhooks for SaaS 2026: Setup & Error Handling

Production-ready webhook implementation for Stripe in indie SaaS. Real error patterns, exact code, and gotchas developers miss.

TL;DR

Set up Stripe webhooks with signed verification, idempotency keys, and exponential backoff retry logic. Use Stripe CLI for local testing. Handle the three most common webhook failures: signature verification, duplicate processing, and timestamp validation. Always verify against [official Stripe docs](https://stripe.com/docs/webhooks) for current endpoints and payload schemas.

---

Why Webhooks Matter for SaaS

Webhooks are how Stripe notifies your application about customer payments, subscription changes, disputes, and refunds. Unlike polling the API every 30 seconds (wasteful), webhooks are event-driven and near-instantaneous.

For indie SaaS, this means:

  • Real-time subscription activations
  • Immediate dunning when cards fail
  • Accurate usage-based billing
  • Proper audit trails for compliance
  • ---

    Step 1: Create Your Webhook Endpoint

    Your endpoint must: 1. Verify the signature using Stripe's secret 2. Return 200 immediately (process async) 3. Be idempotent (same event processed twice = same result)

    Here's a Node.js/Express example using stripe@14.15.0 (verify in [npm](https://www.npmjs.com/package/stripe)):

    ```javascript const express = require('express'); const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); 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']; 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}); }

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

    // Queue event for processing await processWebhookEvent(event); } );

    async function processWebhookEvent(event) { const idempotencyKey = ${event.id}-${event.api_version};

    // Check if already processed const existing = await db.webhookLog.findOne({ idempotencyKey }); if (existing) { console.log(Webhook ${idempotencyKey} already processed. Skipping.); return; }

    try { switch (event.type) { case 'payment_intent.succeeded': await handlePaymentSuccess(event.data.object); break; case 'customer.subscription.updated': await handleSubscriptionUpdate(event.data.object); break; case 'customer.subscription.deleted': await handleSubscriptionCancel(event.data.object); break; case 'charge.refunded': await handleRefund(event.data.object); break; default: console.log(Unhandled event type: ${event.type}); }

    // Mark as processed await db.webhookLog.create({ idempotencyKey, eventId: event.id }); } catch (err) { console.error(Webhook processing error: ${err.message}, { eventId: event.id }); // Don't throw—log to error tracking (Sentry, etc) await db.webhookFailure.create({ idempotencyKey, error: err.message }); } } ```

    ---

    Step 2: Local Testing with Stripe CLI

    Don't test webhooks in production. Use [Stripe CLI](https://stripe.com/docs/stripe-cli):

    ```bash

    Install (verify current version at official docs)

    stripe login

    Forward webhooks to localhost:3000

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

    In another terminal, trigger test events

    stripe trigger payment_intent.succeeded stripe trigger customer.subscription.updated ```

    The CLI outputs a webhook signing secret—copy it to your .env as STRIPE_WEBHOOK_SECRET.

    ---

    Step 3: Handle Common Webhook Failures

    Error 1: Signature Mismatch

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

    Cause: Using parsed JSON instead of raw body, or wrong secret.

    Fix: Ensure you're using express.raw() middleware and correct STRIPE_WEBHOOK_SECRET.

    Error 2: Duplicate Processing

    ``` Error: Duplicate key error collection: webhooks index: { idempotencyKey: 1 } ```

    Cause: Processing the same event twice (Stripe retries failed webhooks).

    Fix: Always store processed event IDs and check before processing (shown in code above).

    Error 3: Timestamp Too Old

    ``` Error: Webhook timestamp outside tolerance window (300s) ```

    Cause: System clock skew or very old replay attempts.

    Fix: Stripe's constructEvent() validates timestamps by default (5-minute window). Keep server time synchronized with NTP.

    ---

    Step 4: Production Deployment

    Environment Variables: ```bash STRIPE_SECRET_KEY=sk_live_... # from Stripe Dashboard STRIPE_WEBHOOK_SECRET=whsec_... # from Webhook Endpoints settings NODE_ENV=production ```

    Webhook Endpoint Registration: 1. Go to [Stripe Dashboard](https://dashboard.stripe.com/) → Developers → Webhooks 2. Add endpoint: https://yourdomain.com/webhooks/stripe 3. Select events: payment_intent.succeeded, customer.subscription.updated, customer.subscription.deleted, charge.refunded 4. Copy signing secret to .env

    Retry Logic: Stripe automatically retries failed webhooks (exponential backoff: 5s, 5m, 30m, 2h, 5h, 10h). Your endpoint must handle retries idempotently.

    ---

    Monitoring & Debugging

    Check webhook delivery status: ```bash stripe logs list # Via CLI ```

    Store all webhook attempts: ```javascript await db.webhookLog.create({ idempotencyKey, eventId: event.id, eventType: event.type, processedAt: new Date(), status: 'success' | 'failed', error: err?.message }); ```

    This enables debugging failed events and replaying if needed.

    ---

    Best Practices Checklist

    ✅ Verify signatures with stripe.webhooks.constructEvent() ✅ Return 200 before async processing ✅ Implement idempotency with event ID + API version ✅ Use [database transactions](/?guide=database-transactions) for consistency ✅ Log all webhook attempts with timestamps ✅ Set up [error tracking](/?guide=error-monitoring) (Sentry, etc) ✅ Test with Stripe CLI before production ✅ Use HTTPS only for webhook endpoints ✅ Rotate webhook secrets monthly ✅ Monitor delivery in Stripe Dashboard

    ---

    What am I missing?

    Did this guide miss edge cases you've hit? Common gotchas with specific frameworks? Stripe API changes since 2026?

    Drop corrections and additions in comments. Indie hackers building real SaaS will thank you.

    🔥 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