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