Stripe Webhooks Setup for Indie SaaS 2026

Production-ready webhook implementation guide for indie SaaS. Real error patterns, exact code, security best practices.

TL;DR

Webhooks let Stripe notify your SaaS when payments succeed, fail, or refund. Set up a HTTPS endpoint, verify signatures, handle retries, and test with the CLI. Most failures stem from missing signature verification or timeout handling.

Why Webhooks Matter for Your SaaS

Polling Stripe's API every minute is wasteful. Webhooks push events to your server instantly—payment succeeded, subscription renewed, dispute filed. For indie SaaS, this means:

  • Activate features immediately after payment
  • Update subscription status without cron jobs
  • Respond to refunds in real-time
  • Reduce infrastructure costs
  • Step 1: Create Your Webhook Endpoint

    Your endpoint must be HTTPS, publicly accessible, and respond within 30 seconds. Here's a production pattern using Node.js/Express:

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

    // Raw body 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}); }

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

    // Handle async (don't block response) handleWebhookEvent(event).catch((err) => { console.error(Failed to process event ${event.id}: ${err.message}); // Your alerting system here }); } );

    async function handleWebhookEvent(event) { switch (event.type) { case 'payment_intent.succeeded': await handlePaymentSucceeded(event.data.object); break; case 'customer.subscription.updated': await handleSubscriptionUpdated(event.data.object); break; case 'charge.refunded': await handleRefund(event.data.object); break; default: console.log(Unhandled event type: ${event.type}); } }

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

    Critical detail: Use express.raw() for the webhook route. Using express.json() globally breaks signature verification because Stripe signs the raw byte string.

    Step 2: Configure Webhook Endpoints in Stripe Dashboard

    1. Go to [Developers > Webhooks](https://dashboard.stripe.com/webhooks) in your Stripe dashboard 2. Click "Add endpoint" 3. Enter your endpoint URL: https://yourdomain.com/webhooks/stripe 4. Select events you need (minimum: payment_intent.succeeded, customer.subscription.updated, charge.refunded) 5. Copy the Signing Secret (starts with whsec_) 6. Store it in .env as STRIPE_WEBHOOK_SECRET

    For testing before production, use Stripe CLI (covered below).

    Step 3: Verify Webhook Signatures (Non-Negotiable)

    Never trust unsigned webhooks. The signature proves Stripe sent it:

    ```javascript const crypto = require('crypto');

    function verifyStripeSignature(payload, signature, secret) { const computedSignature = crypto .createHmac('sha256', secret) .update(payload, 'utf8') .digest('hex');

    return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(computedSignature) ); } ```

    Stripe's library handles this, but understanding the mechanism prevents signature-bypass bugs. verify in official docs for your SDK version—Stripe PHP SDK v15.0+ and Node.js SDK v14.0+ use constructEvent() correctly.

    Real Error Messages You'll See

    Error 1: Signature verification failed ``` Error: No signatures found matching the expected signature for payload. ``` Cause: Wrong webhook secret, or using express.json() instead of express.raw().

    Error 2: Timeout ``` Error: Request timeout after 30000ms ``` Cause: Your handler takes >30 seconds. Always respond immediately, process asynchronously.

    Error 3: Database constraint violation ``` Error: Duplicate key value violates unique constraint ``` Cause: Stripe retries events. Process webhooks idempotently—check if the event ID exists before updating.

    Handle Retries & Idempotency

    Stripe retries failed webhooks for 3 days with exponential backoff. Process each event ID once:

    ```javascript async function handlePaymentSucceeded(paymentIntent) { const eventId = paymentIntent.id; // Use payment intent ID as idempotency key

    // Check if already processed const existing = await db.query( 'SELECT id FROM processed_events WHERE stripe_event_id = ?', [eventId] );

    if (existing.length > 0) { console.log(Event ${eventId} already processed, skipping); return; }

    // Process payment await db.query('UPDATE subscriptions SET status = ? WHERE customer_id = ?', ['active', paymentIntent.customer] );

    // Log event await db.query( 'INSERT INTO processed_events (stripe_event_id, processed_at) VALUES (?, NOW())', [eventId] ); } ```

    Test Locally with Stripe CLI

    1. [Download Stripe CLI](https://stripe.com/docs/stripe-cli) (verify latest version) 2. Authenticate: stripe login 3. Forward webhook events: stripe listen --forward-to localhost:3000/webhooks/stripe 4. Get your signing secret from the output 5. In another terminal, trigger a test event:

    ```bash stripe trigger payment_intent.succeeded ```

    Check your server logs for successful processing.

    Production Checklist

  • [ ] Environment variables loaded from .env (never hardcode secrets)
  • [ ] HTTPS only (HTTP fails signature verification)
  • [ ] Response sent within 30 seconds
  • [ ] Async processing with error logging
  • [ ] Idempotency check before database updates
  • [ ] Dead-letter queue for failed events
  • [ ] Monitoring/alerting on webhook failures
  • [ ] Webhook endpoint tested with Stripe CLI
  • [ ] Rate limiting disabled on webhook route (Stripe has one request at a time)
  • Related Resources

  • [Official Stripe Webhooks Docs](https://stripe.com/docs/webhooks)
  • [Stripe CLI Reference](https://stripe.com/docs/stripe-cli)
  • [Security Best Practices](https://stripe.com/docs/webhooks/best-practices)
  • [Webhook Event Types](https://stripe.com/docs/api/events/types)
  • Further Reading

  • [Handling Subscriptions](/?guide=stripe-subscriptions)
  • [Error Handling & Retries](/?guide=webhook-retries)
  • What am I missing?

    Did I gloss over payment intent idempotency? Webhook testing in staging? Django/Rails patterns? Leave corrections and missing patterns in the comments. This guide will evolve with your 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