Stripe Webhooks Setup for Indie SaaS 2026

Production-ready webhook implementation for Stripe events. Handle payment confirmations, subscription updates, and failures with idempotency patterns.

TL;DR

Webhooks are how Stripe tells your app about payment events asynchronously. You need: (1) an HTTPS endpoint, (2) event signature verification, (3) idempotent handlers, (4) proper error responses. Test with the Stripe CLI before going live.

Why Webhooks Matter

Polling your Stripe dashboard constantly is unreliable and wasteful. Webhooks deliver real-time events—payment succeeded, subscription canceled, invoice failed—directly to your infrastructure. For indie SaaS, this means:

  • Instant user activation after successful payment (no 30-second delay)
  • Subscription lifecycle management without cron jobs
  • Failed payment recovery before customers churn
  • Compliance audit trail (webhooks document what happened and when)
  • Step 1: Create Your Webhook Endpoint

    Your endpoint must be HTTPS and publicly accessible. Here's a production-ready Express.js handler:

    ```javascript // webhook.js - Express handler const express = require('express'); const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); const app = express();

    const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;

    // Important: Use raw body for signature verification app.post('/api/webhooks/stripe', express.raw({type: 'application/json'}), async (req, res) => { const sig = req.headers['stripe-signature'];

    let event;

    try { // Verify signature - this validates the event came from Stripe 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}); }

    // Log for debugging console.log(Received event: ${event.type} (ID: ${event.id}));

    try { // Handle specific event types switch (event.type) { case 'payment_intent.succeeded': await handlePaymentSuccess(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 handleSubscriptionCanceled(event.data.object); break;

    case 'invoice.payment_failed': await handleInvoiceFailed(event.data.object); break;

    default: console.log(Unhandled event type: ${event.type}); }

    // Return 200 immediately - Stripe expects this before handler completes res.json({received: true});

    } catch (err) { console.error(Webhook handler error for ${event.type}: ${err.message}); // Return 500 so Stripe retries res.status(500).send('Webhook handler failed'); } } );

    module.exports = app; ```

    Step 2: Implement Idempotent Handlers

    Stripe retries failed webhooks. Your handlers must be idempotent—running twice produces the same result as running once:

    ```javascript // handlers.js const db = require('./database');

    async function handlePaymentSuccess(paymentIntent) { const {id, metadata, amount, currency} = paymentIntent;

    // Check if we already processed this event const existing = await db.webhookEvents.findOne({stripe_event_id: id}); if (existing) { console.log(Event ${id} already processed, skipping); return; }

    // Extract business data from metadata const userId = metadata.user_id; const planId = metadata.plan_id;

    // Update user subscription in database (wrapped in transaction) const transaction = await db.startTransaction(); try { await db.users.updateOne( {_id: userId}, { plan: planId, status: 'active', subscription_started_at: new Date() }, {session: transaction} );

    // Log the webhook event for audit trail await db.webhookEvents.insertOne( { stripe_event_id: id, event_type: 'payment_intent.succeeded', user_id: userId, amount_cents: amount, currency: currency, processed_at: new Date() }, {session: transaction} );

    await transaction.commitTransaction(); console.log(Successfully activated user ${userId} for plan ${planId});

    } catch (err) { await transaction.abortTransaction(); throw err; // Let webhook handler return 500 } }

    async function handleChargeFailed(charge) { const {id, customer, failure_message} = charge;

    const existing = await db.webhookEvents.findOne({stripe_event_id: id}); if (existing) return;

    await db.webhookEvents.insertOne({ stripe_event_id: id, event_type: 'charge.failed', customer_id: customer, failure_reason: failure_message, logged_at: new Date() });

    // Send user notification email await sendEmailNotification(customer, { template: 'payment_failed', reason: failure_message }); }

    module.exports = {handlePaymentSuccess, handleChargeFailed}; ```

    Step 3: Test with Stripe CLI

    Don't test webhooks against production without Stripe CLI first. Install it ([verify in official docs](https://stripe.com/docs/stripe-cli)):

    ```bash

    macOS

    brew install stripe/stripe-cli/stripe

    Login to your account

    stripe login

    Forward Stripe events to localhost:3000

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

    In another terminal, trigger test events

    stripe trigger payment_intent.succeeded stripe trigger charge.failed ```

    Expect to see these console messages in development:

    ``` Received event: payment_intent.succeeded (ID: evt_1234567890) Successfully activated user 605a7c9d8f1b4 for plan pro_monthly ```

    Common errors you'll encounter:

    1. Error: No signatures found matching the expected signature for payload — Your STRIPE_WEBHOOK_SECRET is wrong or you're not using raw body parser.

    2. Error: Webhook handler error for payment_intent.succeeded: user not found — metadata.user_id doesn't match a real user. Verify metadata passed to payment intent creation.

    3. Webhook Error: Timestamp outside the time tolerance window — Server clock is out of sync. Run ntpdate -s time.nist.gov on Linux or check system time.

    Step 4: Configure in Stripe Dashboard

    1. Go to [Developers → Webhooks](https://dashboard.stripe.com/webhooks) 2. Click "Add endpoint" 3. Enter your production URL: https://yourdomain.com/api/webhooks/stripe 4. Select events: payment_intent.succeeded, charge.failed, customer.subscription.updated, invoice.payment_failed 5. Copy the Signing secret → set as STRIPE_WEBHOOK_SECRET in production

    Environment Variables Checklist

    ```bash STRIPE_SECRET_KEY=sk_live_xxx # Production secret STRIPE_WEBHOOK_SECRET=whsec_xxx # From dashboard NODE_ENV=production LOG_LEVEL=info ```

    Monitoring & Debugging

    Always log webhook processing:

    ```javascript const logger = require('winston');

    logger.info('webhook_received', { event_id: event.id, event_type: event.type, timestamp: new Date() });

    logger.error('webhook_failed', { event_id: event.id, error: err.message, stack: err.stack }); ```

    Stripe retries failed webhooks with exponential backoff over 3 days. Check the [Webhooks API reference](https://stripe.com/docs/api/events) for all event types.

    Related Guides

  • [Stripe subscription management](/?guide=stripe-subscriptions)
  • [Handling failed payments](/?guide=payment-recovery)
  • What am I missing?

    Did you implement webhooks differently? Encounter an error not listed? Have production patterns for handling duplicate events or webhook ordering issues? Drop them in the comments—this guide should reflect how indie hackers actually do this.

    🔥 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