· 7 min read

How to Test Stripe Webhooks Locally: 2 Methods

By HookCap Team

How to Test Stripe Webhooks Locally

Stripe sends webhooks for everything that matters: successful payments, failed charges, subscription changes, disputes. If your app depends on any of these events, you need a reliable way to test them during development — before your code hits production and a customer’s payment silently fails to process.

The problem is straightforward: Stripe needs to reach your server over the public internet, but your development server is running on localhost:3000. This guide covers two practical methods to bridge that gap, plus the gotchas that trip up most developers.

The Core Problem

When you create a webhook endpoint in the Stripe dashboard, you provide a URL like https://yourapp.com/webhooks/stripe. Stripe sends POST requests to that URL when events occur.

During local development, your server is not publicly accessible. You need one of two things:

  1. A tunnel that exposes your local server to the internet
  2. A cloud endpoint that captures webhooks so you can inspect them

Both approaches work. The right choice depends on what you are testing.

Method 1: Stripe CLI (Direct Forwarding)

The Stripe CLI has a built-in listen command that forwards webhook events directly to your local server. This is Stripe’s official approach and works well for basic testing.

Setup

Install the Stripe CLI:

# macOS
brew install stripe/stripe-cli/stripe

# Linux (Debian/Ubuntu)
apt-get install stripe

# Or download directly from https://stripe.com/docs/stripe-cli

Authenticate with your Stripe account:

stripe login

This opens your browser for OAuth. Once authenticated, the CLI stores your API key locally.

Forward Events to Your Local Server

Start listening and forwarding:

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

The CLI prints a webhook signing secret:

> Ready! Your webhook signing secret is whsec_abc123def456...

Important: Copy this signing secret and use it in your local environment. This is different from the signing secret in your Stripe dashboard — the CLI generates its own.

# .env.local
STRIPE_WEBHOOK_SECRET=whsec_abc123def456...

Trigger Test Events

In a separate terminal, trigger specific events:

# Trigger a checkout.session.completed event
stripe trigger checkout.session.completed

# Trigger a payment failure
stripe trigger invoice.payment_failed

# Trigger a subscription cycle
stripe trigger customer.subscription.updated

You will see the events flow through in both terminals — the CLI window shows the forwarded events, and your server logs show the incoming requests.

Limitations of the Stripe CLI Approach

The CLI works well for triggering individual test events, but has some friction points:

  • Test data only. The CLI sends synthetic events with fake data. The payload structure is correct, but the IDs and amounts are generic. If you need to test with specific product configurations or pricing, you need to create real test-mode objects first.
  • No payload inspection UI. Events scroll past in the terminal. If you need to examine a complex nested payload (say, a subscription update with proration line items), you are copying JSON from terminal output.
  • One endpoint at a time. If multiple services in your stack consume Stripe webhooks, you need multiple CLI sessions or a local router.
  • Session-based. The signing secret changes every time you restart stripe listen. You need to update your env var each time.

Method 2: Cloud Endpoint (Capture and Inspect)

The alternative is to point Stripe at a persistent cloud URL that captures and displays every incoming webhook. You inspect payloads in a web UI, verify signatures, and optionally forward to your local server.

This is what tools like HookCap, webhook.site, and RequestBin are built for.

Step-by-Step with a Cloud Endpoint

1. Create an endpoint URL

Sign up for a webhook testing tool and get a unique endpoint URL. It will look something like:

https://hook.hookcap.dev/ep_a1b2c3d4e5

This URL is permanent (it does not expire after a session) and immediately starts accepting requests.

2. Configure Stripe to send events to your endpoint

In the Stripe Dashboard:

  • Go to Developers > Webhooks
  • Click Add endpoint
  • Paste your cloud endpoint URL
  • Select the events you care about (or select all for exploration)

For test mode, you can also set this via the API:

curl https://api.stripe.com/v1/webhook_endpoints \
  -u sk_test_your_key: \
  -d url="https://hook.hookcap.dev/ep_a1b2c3d4e5" \
  -d "enabled_events[]"="checkout.session.completed" \
  -d "enabled_events[]"="invoice.payment_failed" \
  -d "enabled_events[]"="customer.subscription.updated"

3. Trigger real events in test mode

Now create actual test-mode transactions. Go through your checkout flow, create a subscription, or use the Stripe Dashboard to manually create a payment. Each event Stripe generates will appear at your cloud endpoint in real time.

4. Inspect payloads

The key advantage here: you get a full UI to inspect headers, body, and timing for every webhook delivery. You can:

  • Expand nested JSON objects
  • Compare payloads across different events
  • See Stripe’s retry behavior when your endpoint returns errors
  • Search through historical webhooks

5. Replay when needed

Found a bug in your handler? Fix the code, then replay the exact same webhook payload against your local server. No need to re-trigger the event in Stripe.

When to Use Which Method

ScenarioStripe CLICloud Endpoint
Quick smoke test of a single eventBest choiceWorks
Debugging complex subscription flowsWorkableBetter (payload inspection)
Testing retry/failure behaviorManualBuilt-in
Team sharing a test endpointNot easyShare the URL
CI/staging environmentsNot applicableGood fit

Common Gotchas

These are the issues that show up repeatedly in Stripe webhook integrations, regardless of which testing method you use.

1. Signature Verification Fails

Stripe signs every webhook with a secret. Your server must verify this signature. The most common mistake:

// WRONG: body has already been parsed as JSON
app.use(express.json());
app.post('/webhooks/stripe', (req, res) => {
  // req.body is already parsed -- signature verification will fail
  const event = stripe.webhooks.constructEvent(
    req.body, // This is an object, not a string
    req.headers['stripe-signature'],
    webhookSecret
  );
});
// RIGHT: use raw body for signature verification
app.post('/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const event = stripe.webhooks.constructEvent(
      req.body, // This is a Buffer (raw bytes)
      req.headers['stripe-signature'],
      webhookSecret
    );
  }
);

The fix: ensure your webhook route receives the raw request body, not the parsed JSON. In Express, use express.raw() middleware on the webhook route specifically.

2. Event Ordering Is Not Guaranteed

Stripe does not guarantee that invoice.created arrives before invoice.payment_succeeded. Your handler must be idempotent and must not assume ordering.

Practical approach: use the event type to decide what to do, and always fetch the latest state from the Stripe API rather than relying solely on the webhook payload.

case 'invoice.payment_succeeded':
  // Don't trust the webhook payload for current subscription status.
  // Fetch the latest subscription state from Stripe.
  const subscription = await stripe.subscriptions.retrieve(
    event.data.object.subscription
  );
  await updateSubscriptionInDb(subscription);
  break;

3. Responding Too Slowly

Stripe expects a 2xx response within 20 seconds. If your handler does heavy processing (sending emails, provisioning resources), Stripe considers it a failure and retries.

Solution: acknowledge the webhook immediately, then process asynchronously.

app.post('/webhooks/stripe', async (req, res) => {
  const event = verifyAndParse(req);

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

  // Process in the background
  processWebhookEvent(event).catch(err => {
    console.error('Webhook processing failed:', err);
  });
});

4. Forgetting Test Mode vs. Live Mode

Stripe test mode and live mode have separate webhook endpoints and separate signing secrets. A webhook endpoint configured in test mode will not receive live events, and vice versa. Double-check which mode you are working in when things seem silent.

Why HookCap

If the cloud endpoint approach fits your workflow, HookCap is built specifically for this. A few things that matter for Stripe webhook testing:

  • Permanent endpoint URLs that do not expire between sessions. Configure once in Stripe, keep testing.
  • Real-time payload streaming via WebSocket. You see webhooks arrive instantly, without refreshing.
  • One-click replay to re-send any captured webhook to your local or staging server. Useful when iterating on handler logic.
  • Response mocking to simulate different server responses (200, 500, timeout) and test Stripe’s retry behavior.

Free tier available at hookcap.dev. Paid plans start at $12/month.

Summary

Testing Stripe webhooks locally comes down to two viable approaches:

  1. Stripe CLI for quick, individual event testing during development. Fast to set up, limited in inspection capabilities.
  2. Cloud endpoint for deeper debugging, payload inspection, replay testing, and team collaboration.

Most developers end up using both: the CLI for rapid iteration, and a cloud endpoint for complex integration testing and debugging production-like scenarios. Start with whichever solves your immediate problem, and add the other when you need it.