# How to test webhooks locally for Stripe

- Tool: Stripe
- Difficulty: Intermediate
- Time required: 10 minutes
- Compatibility: Stripe CLI v1.19+, Node.js 18+
- Last updated: March 2026

## TL;DR

The Stripe CLI lets you forward webhook events to your local development server without deploying to a public URL. Run 'stripe listen --forward-to localhost:3000/webhook' to start receiving events locally. The CLI provides a temporary webhook signing secret and lets you trigger specific events for testing. This guide covers installation, event forwarding, triggering test events, and debugging webhook issues.

## Testing Stripe Webhooks on Your Local Machine

During development, your webhook endpoint is running on localhost which Stripe cannot reach. The Stripe CLI bridges this gap by establishing a WebSocket connection to Stripe and forwarding events to your local server. It also provides a temporary webhook signing secret (whsec_) for signature verification. This is the recommended way to develop and test webhook handlers.

## Before you start

- Node.js 18 or later with your webhook server code ready
- A Stripe account (test mode)
- Homebrew (macOS), scoop (Windows), or apt (Linux) for CLI installation

## Step-by-step guide

### 1. Install the Stripe CLI

Install the CLI using your platform's package manager.

```
# macOS
brew install stripe/stripe-cli/stripe

# Windows (scoop)
scoop bucket add stripe https://github.com/stripe/scoop-stripe-cli.git
scoop install stripe

# Linux (Debian/Ubuntu)
curl -s https://packages.stripe.dev/api/security/keypair/stripe-cli-gpg/public | gpg --dearmor | sudo tee /usr/share/keyrings/stripe.gpg
echo "deb [signed-by=/usr/share/keyrings/stripe.gpg] https://packages.stripe.dev/stripe-cli-debian-local stable main" | sudo tee -a /etc/apt/sources.list.d/stripe.list
sudo apt update && sudo apt install stripe

# Verify installation
stripe version
```

**Expected result:** The Stripe CLI is installed and outputs its version number.

### 2. Authenticate the CLI with your Stripe account

Log in to connect the CLI to your Stripe account. This opens a browser for authentication.

```
# Log in to Stripe (opens browser for OAuth)
stripe login

# Or use an API key directly (useful for CI)
stripe login --api-key sk_test_yourTestKey
```

**Expected result:** The CLI authenticates with your Stripe account and confirms the connection.

### 3. Forward webhooks to your local server

Start the webhook listener to forward all Stripe events to your local endpoint. The CLI provides a temporary signing secret.

```
# Forward all events to your local server
stripe listen --forward-to localhost:3000/webhook

# Output:
# Ready! Your webhook signing secret is whsec_abc123...
# (use this secret for signature verification in your local server)

# Forward only specific events:
stripe listen --forward-to localhost:3000/webhook \
  --events payment_intent.succeeded,payment_intent.payment_failed,customer.subscription.created
```

**Expected result:** The CLI connects to Stripe and begins forwarding events to localhost:3000/webhook. A signing secret is displayed.

### 4. Update your server to use the CLI signing secret

Use the whsec_ secret from the CLI output in your webhook endpoint for local development.

```
// .env (for local development)
STRIPE_WEBHOOK_SECRET=whsec_abc123...  // From 'stripe listen' output

// Your webhook handler stays the same:
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

app.post('/webhook',
  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 // Uses CLI secret locally
      );
    } catch (err) {
      return res.status(400).send(`Webhook Error: ${err.message}`);
    }
    console.log('Event received:', event.type);
    res.json({ received: true });
  }
);
```

**Expected result:** Webhook signature verification works with the CLI-provided signing secret.

### 5. Trigger test events from the CLI

Use 'stripe trigger' to fire specific webhook events for testing. This creates real test objects in your Stripe test account.

```
# Trigger a payment_intent.succeeded event
stripe trigger payment_intent.succeeded

# Trigger a subscription lifecycle
stripe trigger customer.subscription.created

# Trigger a dispute
stripe trigger charge.dispute.created

# Trigger an invoice payment failure
stripe trigger invoice.payment_failed

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

# List all available trigger events
stripe trigger --list
```

**Expected result:** Each command creates test objects in Stripe and fires the corresponding webhook event to your local server.

### 6. Debug webhook delivery issues

The CLI shows real-time event delivery status. Use it to debug signature verification failures and handler errors.

```
# The CLI shows delivery status for each event:
#   --> payment_intent.succeeded [evt_1ABC] -> localhost:3000/webhook [200]
#   --> payment_intent.payment_failed [evt_2DEF] -> localhost:3000/webhook [400]

# Replay a specific event
stripe events resend evt_1ABCxyz

# View recent events in your account
stripe events list --limit 10

# Get details of a specific event
stripe events retrieve evt_1ABCxyz

# Common issues and fixes:
# 400 error → Signature verification failing → Check STRIPE_WEBHOOK_SECRET matches CLI output
# Connection refused → Server not running → Start your server on port 3000
# Timeout → Handler too slow → Return 200 immediately, process async
```

**Expected result:** You can see event delivery status, replay events, and diagnose issues in real time.

## Complete code example

File: `local-webhook-test.js`

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

// Webhook endpoint
app.post('/webhook',
  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.error('Signature verification failed:', err.message);
      console.error('Tip: Use the whsec_ from "stripe listen" output');
      return res.status(400).send(`Webhook Error: ${err.message}`);
    }

    console.log(`\nReceived: ${event.type} (${event.id})`);

    switch (event.type) {
      case 'payment_intent.succeeded':
        console.log('  Amount:', event.data.object.amount);
        break;
      case 'payment_intent.payment_failed':
        console.log('  Error:', event.data.object.last_payment_error?.message);
        break;
      case 'customer.subscription.created':
        console.log('  Sub ID:', event.data.object.id);
        break;
      case 'charge.dispute.created':
        console.log('  Dispute:', event.data.object.id);
        break;
      default:
        console.log('  (unhandled event type)');
    }

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

app.use(express.json());

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Webhook test server on port ${PORT}`);
  console.log('\nSteps:');
  console.log('1. Run: stripe listen --forward-to localhost:' + PORT + '/webhook');
  console.log('2. Copy the whsec_ secret to your .env file');
  console.log('3. Restart this server');
  console.log('4. Run: stripe trigger payment_intent.succeeded');
});
```

## Common mistakes

- **Using the Dashboard webhook secret instead of the CLI's whsec_ for local testing** — undefined Fix: The CLI generates its own signing secret. Use the whsec_ displayed when you run 'stripe listen' in your local .env.
- **Forgetting to start the webhook server before running stripe listen** — undefined Fix: Start your server first (node server.js), then run stripe listen to forward events to it.
- **Closing the stripe listen terminal session while testing** — undefined Fix: Keep the stripe listen process running in a separate terminal. Events stop forwarding when you close it.
- **Not restarting the server after updating the webhook secret in .env** — undefined Fix: dotenv reads .env at startup. Restart your server after changing STRIPE_WEBHOOK_SECRET.

## Best practices

- Keep stripe listen running in a dedicated terminal during development
- Use --events flag to filter only the events you are testing to reduce noise
- Use stripe trigger to test each event handler individually
- Use separate .env files for local (CLI whsec_) and production (Dashboard whsec_) webhook secrets
- Test error scenarios: trigger payment_intent.payment_failed and charge.dispute.created
- Run stripe events list to see recent events when debugging issues

## Frequently asked questions

### Do I need a public URL for local webhook testing?

No. The Stripe CLI creates a WebSocket tunnel to forward events to your local server. No public URL, ngrok, or port forwarding needed.

### Is the CLI webhook secret different from the Dashboard secret?

Yes. The CLI generates a temporary signing secret each time you run stripe listen. Use the CLI secret for local development and the Dashboard secret for production.

### Can I trigger custom webhook events?

stripe trigger supports common event types. For custom scenarios, create the objects via the API (e.g., create a PaymentIntent and confirm it) and the webhook will fire naturally.

### How do I test webhook retries locally?

Return a non-2xx status from your handler. The CLI will show the failed delivery but does not automatically retry like Stripe's production infrastructure does.

### Can multiple developers share one stripe listen session?

No. Each developer should run their own stripe listen session forwarding to their local server. Events from one session will not reach other sessions.

---

Source: https://www.rapidevelopers.com/stripe-guide/how-to-test-webhooks-locally-for-stripe
© RapidDev — https://www.rapidevelopers.com/stripe-guide/how-to-test-webhooks-locally-for-stripe
