Stripe Webhooks for SaaS in 2026: Setup & Error Handling

Production-ready webhook implementation for Stripe. Handle events reliably, avoid common pitfalls, and secure your payment infrastructure.

TL;DR

Stripe webhooks notify your SaaS when payments complete, subscriptions change, or disputes occur. Set up a HTTPS endpoint, verify webhook signatures with your endpoint secret, and implement idempotent event handlers. Use the Stripe CLI (v1.18+, verify in official docs) for local testing before deploying.

---

Why Webhooks Matter for Indie SaaS

Polling Stripe's API every minute is wasteful. Webhooks push events to your server instantly—subscription renewed, customer disputed a charge, trial ended. For indie SaaS, this is critical:

  • Billing accuracy: Update user access immediately when payment fails
  • Revenue recognition: Log events in your accounting system of record
  • User experience: Don't leave customers locked out wondering why
  • ---

    Step 1: Create Your Webhook Endpoint

    Your endpoint must: 1. Accept POST requests 2. Return 200 OK quickly (process async) 3. Verify the signature

    Here's a Node.js/Express example (production-ready pattern):

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

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

    // Raw body required 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 signature before processing 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}); }

    // Always return 200 immediately, then process res.status(200).json({ received: true });

    // Async processing - retry on failure try { await handleStripeEvent(event); } catch (err) { console.error(Event processing failed (${event.id}): ${err.message}); // Log to error tracking (Sentry, etc.) // Stripe will retry within 3 days } } );

    async function handleStripeEvent(event) { // Idempotent: safe to call multiple times const eventId = event.id; // Check if already processed const existing = await db.webhookEvent.findUnique({ where: { eventId } }); if (existing) return;

    switch (event.type) { case 'charge.succeeded': await handleChargeSucceeded(event.data.object); break; case 'customer.subscription.updated': await handleSubscriptionUpdated(event.data.object); break; case 'customer.subscription.deleted': await handleSubscriptionDeleted(event.data.object); break; case 'invoice.payment_failed': await handlePaymentFailed(event.data.object); break; default: console.log(Unhandled event type: ${event.type}); }

    // Record processed event await db.webhookEvent.create({ eventId, type: event.type, processedAt: new Date(), }); }

    async function handleChargeSucceeded(charge) { // Example: Update user account status const customerId = charge.customer; const amountPaid = charge.amount / 100; // Convert cents to dollars await db.user.update( { stripeCustomerId: customerId }, { accountStatus: 'active', lastPaymentDate: new Date(charge.created * 1000), } ); }

    async function handleSubscriptionUpdated(subscription) { const customerId = subscription.customer; const planId = subscription.items.data[0].plan.id; await db.user.update( { stripeCustomerId: customerId }, { plan: planId, renewalDate: new Date(subscription.current_period_end * 1000) } ); }

    async function handlePaymentFailed(invoice) { const customerId = invoice.customer; await db.user.update( { stripeCustomerId: customerId }, { accountStatus: 'suspended', failureReason: invoice.last_payment_error?.message } ); // Send email notification await sendPaymentFailedEmail(customerId); }

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

    Key patterns:

  • express.raw() preserves the request body for signature verification
  • Return 200 *before* processing—Stripe retries on timeout
  • Idempotency via eventId prevents duplicate charges
  • Async error handling logs failures for later retry
  • ---

    Step 2: Configure 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: - charge.succeeded - customer.subscription.updated - customer.subscription.deleted - invoice.payment_failed - customer.deleted (for cleanup) 5. Copy the Signing Secret (starts with whsec_) → save to .env

    ---

    Step 3: Test Locally with Stripe CLI

    Download [Stripe CLI](https://stripe.com/docs/stripe-cli) (verify latest version in official docs):

    ```bash stripe login stripe listen --forward-to localhost:3000/webhooks/stripe ```

    Capture the signing secret from CLI output. In another terminal:

    ```bash stripe trigger charge.succeeded stripe trigger customer.subscription.updated ```

    Watch your server logs for successful processing.

    ---

    Common Console Errors & Solutions

    Error 1: "Webhook signature verification failed"

    ``` Webhook signature verification failed: No signatures found matching the expected signature for payload. ``` Cause: Wrong secret, or request body modified before verification Fix: Ensure express.raw() is used; check .env has correct STRIPE_WEBHOOK_SECRET

    Error 2: "Cannot read property 'customer' of undefined"

    ``` TypeError: Cannot read property 'customer' of undefined at handleChargeSucceeded ``` Cause: Event data structure doesn't match expectation Fix: Log JSON.stringify(event.data.object, null, 2) to inspect actual structure

    Error 3: "Timeout waiting for response"

    ``` Webhook endpoint returned status 504; will retry later ``` Cause: Your endpoint takes >30 seconds to process Fix: Return 200 immediately; queue processing to background job (Bull, RQ)

    ---

    Production Checklist

  • [ ] Use HTTPS only (Stripe rejects HTTP)
  • [ ] Store webhook secret in environment variables, never in code
  • [ ] Implement idempotency (store processed event.id)
  • [ ] Return 200 OK within 30 seconds
  • [ ] Log all webhook events to database for audit trail
  • [ ] Set up error tracking (Sentry, Rollbar) for webhook failures
  • [ ] Monitor webhook delivery in Stripe Dashboard (Developers → Webhooks → Logs)
  • [ ] Test event replay in dashboard (re-send past events)
  • [ ] Use background jobs for long-running handlers (sending emails, etc.)
  • ---

    Related Reading

  • [Stripe Webhooks Official Docs](https://stripe.com/docs/webhooks)
  • [Stripe CLI Documentation](https://stripe.com/docs/stripe-cli)
  • [Payment handling best practices](/?guide=stripe-payment-reconciliation)
  • [Securing webhook endpoints](/?guide=webhook-security-guide)
  • ---

    What am I missing?

    Did I skip your favorite footgun? Common issues:

  • Race conditions in subscription updates?
  • Handling timezone differences in renewal dates?
  • Retry strategies for database failures?
  • Testing webhook events in staging vs. production?
  • Add your experience in comments below. Indie hackers helping indie hackers.

    🔥 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