← Back to Blog

Stripe Webhook Testing: Local Development Guide

By ThunderHooks Team ·
stripewebhookspaymentstestingtutorials

Stripe Webhook Testing: Local Development Guide

Stripe webhooks notify your application about events—successful payments, failed charges, subscription changes, disputes. Getting them right matters. A missed webhook can mean unfulfilled orders or confused customers.

Testing webhooks locally used to be painful. Now Stripe has decent tooling. Here's how to use it effectively.

Setting Up the Stripe CLI

The Stripe CLI is your primary tool for local webhook testing. Install it first.

macOS:

brew install stripe/stripe-cli/stripe

Windows:

scoop install stripe

Linux: Download from Stripe's releases page or use their apt/yum repos.

After installation, authenticate:

stripe login

This opens a browser to link your Stripe account. You'll need to redo this periodically—the session expires.

Forwarding Webhooks to Localhost

Start the listener:

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

You'll see output like:

Ready! Your webhook signing secret is whsec_abc123...

Save that signing secret. You'll need it for signature verification. It's different from your dashboard webhook secret—this one is specific to the CLI session.

Set it in your environment:

export STRIPE_WEBHOOK_SECRET=whsec_abc123...

Now Stripe CLI intercepts webhooks and forwards them to your local server.

Triggering Test Events

You can trigger specific events without making real payments:

stripe trigger payment_intent.succeeded

Common events to test:

# Payment flow
stripe trigger payment_intent.succeeded
stripe trigger payment_intent.payment_failed
stripe trigger charge.refunded

# Subscriptions
stripe trigger customer.subscription.created
stripe trigger customer.subscription.updated
stripe trigger customer.subscription.deleted
stripe trigger invoice.paid
stripe trigger invoice.payment_failed

# Checkout
stripe trigger checkout.session.completed

Each trigger sends a realistic webhook payload to your forwarded endpoint.

Signature Verification

Always verify webhook signatures. See Stripe's signature verification guide for full details. Here's the pattern in different languages:

Node.js:

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

app.post('/webhooks/stripe', express.raw({type: 'application/json'}), (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.log(`Webhook signature verification failed.`, err.message);
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  // Handle the event
  switch (event.type) {
    case 'payment_intent.succeeded':
      const paymentIntent = event.data.object;
      // Handle successful payment
      break;
    // ... other cases
  }

  res.json({received: true});
});

Go:

func handleWebhook(w http.ResponseWriter, r *http.Request) {
    payload, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "Error reading body", http.StatusBadRequest)
        return
    }

    event, err := webhook.ConstructEvent(
        payload,
        r.Header.Get("Stripe-Signature"),
        os.Getenv("STRIPE_WEBHOOK_SECRET"),
    )
    if err != nil {
        http.Error(w, "Signature verification failed", http.StatusBadRequest)
        return
    }

    switch event.Type {
    case "payment_intent.succeeded":
        // Handle
    }

    w.WriteHeader(http.StatusOK)
}

Python:

import stripe
from flask import Flask, request

@app.route('/webhooks/stripe', methods=['POST'])
def stripe_webhook():
    payload = request.get_data()
    sig_header = request.headers.get('Stripe-Signature')

    try:
        event = stripe.Webhook.construct_event(
            payload, sig_header, os.environ['STRIPE_WEBHOOK_SECRET']
        )
    except ValueError as e:
        return 'Invalid payload', 400
    except stripe.error.SignatureVerificationError as e:
        return 'Invalid signature', 400

    if event['type'] == 'payment_intent.succeeded':
        payment_intent = event['data']['object']
        # Handle

    return '', 200

The Raw Body Problem

A common gotcha: signature verification requires the raw request body. If your framework parses JSON automatically before your handler runs, verification will fail.

In Express, use express.raw():

app.post('/webhooks/stripe', express.raw({type: 'application/json'}), handler);

In other frameworks, ensure you're reading the raw body before any JSON parsing middleware touches it. The Stripe docs on signature errors cover this in detail.

Debugging Failed Webhooks

When things go wrong:

1. Check the CLI output

The Stripe CLI shows request/response details:

2026-01-25 10:23:45   --> payment_intent.succeeded [evt_123...]
2026-01-25 10:23:45   <-- [400] POST http://localhost:3000/webhooks/stripe

A 400 response usually means signature verification failed.

2. Check your logs

Add logging before and after signature verification to see where it fails.

3. Verify the secret

The CLI provides a session-specific secret. Make sure you're using the right one. The secret from your Stripe dashboard won't work with CLI-forwarded webhooks.

4. Check for body parsing issues

If you're getting "No signatures found matching the expected signature for payload" errors, you likely have a body parsing problem.

Testing Without the CLI

Sometimes you want to test with captured real payloads. Options:

Replay from Stripe Dashboard: In your Stripe dashboard, go to Developers > Webhooks > your endpoint. You can resend any recent webhook delivery.

Use a webhook capture service: Services like ThunderHooks act as a webhook inbox. Point Stripe at your ThunderHooks URL once, and every webhook is captured—even when your laptop is closed.

When you're ready to debug:

  1. Open your dashboard, see the exact payload Stripe sent
  2. Start ngrok or your preferred tunnel
  3. Replay the webhook to your tunnel URL
  4. Fix your bug, replay again—same webhook, no need to trigger another Stripe event

This is especially useful for debugging production issues. Capture real production webhooks, then replay them against your local code until you find the bug.

Mock the webhook: For unit tests, don't call Stripe at all. Mock the webhook payload and test your handler logic directly.

Production Checklist

Before going live:

  • Webhook endpoint is HTTPS (required by Stripe)
  • Signature verification is enabled with production secret
  • Handler returns 2xx quickly (< 30 seconds)
  • Idempotency handling for duplicate deliveries
  • Error logging captures event ID for debugging
  • Retry handling for temporary failures
  • Critical events have monitoring/alerting

Common Events to Handle

At minimum, most Stripe integrations need:

Event When Action
checkout.session.completed Customer finishes checkout Fulfill order
payment_intent.succeeded Payment completes Record payment, send receipt
payment_intent.payment_failed Payment fails Notify customer, retry logic
customer.subscription.created New subscription Provision access
customer.subscription.deleted Subscription canceled Revoke access
invoice.payment_failed Subscription payment fails Dunning flow

See Stripe's webhook events documentation for the full list.

Conclusion

The Stripe CLI makes local webhook testing manageable. Set up forwarding, trigger events, verify signatures work, and test your handler logic.

The key is testing the unhappy paths too—failed payments, disputed charges, expired cards. Those are where webhook handling bugs tend to hide.

Resources

Ready to simplify webhook testing?

Try ThunderHooks free. No credit card required.

Get Started Free