← Back to blog
Guides April 17, 2026

How to Test Stripe Webhooks Locally Without Stripe CLI

Testing Stripe webhooks during local development is one of those problems that sounds simple until you try it. Stripe needs a public HTTPS URL. Your app is running on localhost:8000. The official answer is "install the Stripe CLI" — but that's not always the right tool, and it's never the only one.

This guide covers three ways to test Stripe webhooks locally without touching the Stripe CLI, plus a quick tip that doesn't require any setup at all.

TL;DR

Most developers end up using a combination.

Why Stripe webhooks are awkward to test

Stripe sends webhook events as POST requests to a URL you configure. That URL needs to be publicly accessible over HTTPS. Your local dev server doesn't meet either requirement.

The typical developer experience looks like this:

  1. Make a code change to your webhook handler
  2. Deploy to staging
  3. Trigger the event in Stripe
  4. Check the logs
  5. Realise you had a typo
  6. Repeat

That feedback loop kills your momentum. Every approach below solves the same core problem: getting Stripe's webhook events to reach your local machine without deploying anything.

Option 1: Use a tunnel (ngrok, Cloudflare Tunnel)

The most common approach. Run a tunnel, get a public URL, point Stripe at it.

With ngrok:

ngrok http 8000

You'll get a URL like https://a1b2c3d4.ngrok-free.app. Paste that into your Stripe webhook settings as https://a1b2c3d4.ngrok-free.app/stripe/webhook.

The good: It works. You're receiving real Stripe events hitting your actual local code, with real signature verification.

The annoying: The free ngrok URL changes every time you restart, which means updating Stripe's dashboard every session. Free tier connections also show a browser interstitial page that can occasionally interfere with webhook delivery. Paid plans start at $8/month for stable URLs.

If you're already in the Cloudflare ecosystem:

cloudflared tunnel --url http://localhost:8000

Same idea, different provider. Free quick-tunnels also rotate URLs on each restart, but a named tunnel gives you a stable subdomain if you want to set it up properly.

Other options worth knowing: localhost.run needs no installation (ssh -R 80:localhost:8000 nokey@localhost.run), and Localtunnel works via npm (npx localtunnel --port 8000).

When to use this: You need to test the full end-to-end flow with real Stripe events and real signature verification. It's the most realistic option.

While you're here, Stripe's built-in "Send test webhook" feature in the Dashboard (Developers → Webhooks → your endpoint → Send test webhook) is handy for a quick connectivity check once your tunnel is running. The test events contain generic placeholder data so they won't match anything in your database, but they're fine for confirming your endpoint is reachable.

Option 2: Craft local webhook payloads yourself

Skip Stripe entirely and POST directly to your local endpoint. This gives you total control over the payload.

curl -X POST http://localhost:8000/stripe/webhook \
  -H "Content-Type: application/json" \
  -d '{
    "id": "evt_test_123",
    "type": "payment_intent.succeeded",
    "data": {
      "object": {
        "id": "pi_test_456",
        "amount": 2000,
        "currency": "usd",
        "status": "succeeded",
        "customer": "cus_test_789"
      }
    }
  }'

The problem: signature verification. Stripe signs every webhook with a secret, and your handler should be verifying that signature. If it does, this raw curl request will fail with a 400.

This is why crafted payloads work best inside your test suite rather than as ad-hoc curl commands. A PHPUnit or Jest test can construct a payload, generate a valid signature, and send it through the same code path as a real webhook — giving you fast, repeatable tests that don't require any external service.

Stripe's signature is an HMAC-SHA256 of the timestamp and raw payload, using your webhook secret as the key. The Stripe-Signature header combines both, formatted as t={timestamp},v1={signature}:

// In a test — generate a valid Stripe webhook signature
$payload = json_encode($eventData);
$timestamp = time();
$signedPayload = $timestamp . '.' . $payload;
$signature = hash_hmac('sha256', $signedPayload, $webhookSecret);

$response = $this->postJson('/stripe/webhook', $eventData, [
    'Stripe-Signature' => "t={$timestamp},v1={$signature}",
]);

When to use this: You want fast, repeatable tests in CI or during rapid local iteration. This pattern is how most mature Stripe integrations exercise their webhook handlers in automated tests.

Option 3: Capture real webhooks, replay them later

This is the approach that works best if you're debugging a specific webhook that already fired — or if you want to decouple "receiving the webhook" from "processing the webhook."

The idea: instead of pointing Stripe at your local server, point it at an inspection tool that captures and stores the raw request. Then replay that exact request to your local server whenever you're ready.

The workflow:

  1. Create an endpoint on an inspection tool — you get a public URL
  2. Paste that URL into Stripe's webhook settings
  3. Trigger the event (make a test payment, cancel a subscription, whatever)
  4. The tool captures the full request — headers, body, method, everything
  5. Inspect the payload to understand exactly what Stripe sent
  6. Replay it to http://localhost:8000/stripe/webhook when you're ready

Why this is powerful: You're working with real payloads from real Stripe events, but you're not under time pressure. You can replay the same webhook 20 times while debugging your handler. You can replay it after changing your code without triggering a new event in Stripe. And the request history persists, so you can come back to it tomorrow.

This is particularly useful for debugging those tricky edge cases — like a customer.subscription.updated event where you need to check what Stripe actually sends when someone downgrades mid-billing-cycle. Good luck reconstructing that payload by hand.

Tools that support this workflow include WebhookHub, Webhook.site, Beeceptor, and Hookdeck. They differ in persistence, replay capabilities, and how many endpoints you can manage, but the core workflow is the same.

Which approach should you actually use?

It depends on what you're testing:

Scenario Best approach
"Does my endpoint work at all?" Dashboard test events
"Does my handler process real events correctly?" Tunnel + real test-mode transactions
"I need fast, repeatable tests in CI" Crafted payloads with generated signatures
"I'm debugging a specific production-like event" Capture and replay
"I'm building multiple integrations, not just Stripe" Capture and replay — works with any provider

Most developers end up mixing all three. Tunnels for end-to-end testing, crafted payloads for automated tests, and capture-and-replay for debugging specific issues.

Three mistakes that will cost you hours

1. Verifying the parsed body instead of the raw body

This is the most common Stripe webhook bug, and it's a nasty one because it can work in development and fail silently in production. Stripe computes the signature over the raw request body — the exact bytes that were sent. If your framework parses the JSON before you verify the signature, the re-serialised JSON might differ from the original (different key ordering, different whitespace), and verification fails.

In Express, define your webhook route before express.json():

// This route must come BEFORE app.use(express.json())
app.post('/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  handleWebhook
);

In Laravel, if you're using Cashier, this is already handled for you — its webhook controller reads the raw body via its signature verification middleware. If you're building a custom webhook handler, make sure you're reading from $request->getContent(), not $request->all() or $request->json().

2. Blocking on heavy work before returning 200

Stripe expects a response within a few seconds. If your handler does anything slow — sending emails, hitting external APIs, running complex queries — you risk timing out. Stripe then retries the event, and you might end up processing it twice.

If you're using Cashier, its built-in webhook controller fires a WebhookReceived event and your listeners run inline. That's fine for lightweight work — updating a user's plan, logging a status change. For anything heavier, queue it:

// In a Cashier webhook listener
public function handle(WebhookReceived $event): void
{
    if ($event->payload['type'] !== 'invoice.payment_succeeded') {
        return;
    }

    // Lightweight sync work — fine to do here
    $user = User::where('stripe_id', $event->payload['data']['object']['customer'])->first();

    // Anything slow — push to a queue
    Mail::to($user)->queue(new PaymentReceiptMail($event->payload));
}

The rule of thumb: the webhook handler itself should finish in well under a second. Anything that can wait goes to a queue.

3. Forgetting idempotency

Stripe will send the same event more than once. Retries on network blips, replays after outages, even occasional duplicates with no obvious cause. Your handler needs to handle this gracefully.

The simplest approach is to short-circuit on event.id:

if (!Cache::add("stripe_event:{$event->id}", true, now()->addDays(7))) {
    return;
}

// Process the event

Cache::add() only sets the key if it doesn't already exist, so duplicate events return immediately without reprocessing.

A more granular pattern — and arguably a better one — is to key the cache on the action you're taking, not the raw event. For example, if you're sending a "payment failed" email, key it on the user and invoice ID:

$cacheKey = "email_sent:payment_failed:{$user->id}:{$invoiceId}";

if (!Cache::add($cacheKey, true, now()->addHours(72))) {
    return;
}

Mail::to($user)->send(new PaymentFailedMail(...));

This protects you against duplicate actions even when Stripe sends slightly different events that would trigger the same side effect. For a payment failure, that's what you actually care about — not that the specific event arrived once, but that the email went out once.

Wrapping up

The Stripe CLI is a fine tool, but it's not the only way to test webhooks locally. Depending on what you're building, a tunnel, crafted payloads, or a capture-and-replay workflow might fit your development process better.

The common thread across all these approaches: shorten your feedback loop. The faster you can see what Stripe is actually sending and test how your code handles it, the fewer hours you'll lose to silent failures.

If you're testing webhooks regularly — whether from Stripe or any other provider — WebhookHub gives you persistent request history, real-time streaming, and replay on the free tier. It's built for exactly this kind of debugging workflow.

Stop debugging blind.

Get your first endpoint in 30 seconds. No credit card. No setup. Just clarity.

Start for free