All articles
WebhooksAugust 13, 2026 · 9 min read

How to test Stripe webhooks locally

Get a permanent webhook URL for Stripe in one command: capture events, verify signatures, replay them against localhost, and keep the same URL from first test to CI.


Stripe delivers webhooks to a public HTTPS URL. Your handler runs on localhost:3000. Between those two facts sits most of the friction in payments development: you either deploy to staging every time you touch the handler, or you run a tunnel that hands you a new URL on every restart - which you then re-paste into the Stripe dashboard, again.

There is a quieter setup. Give Stripe a URL that never changes and never goes down, let it capture everything, and pull events into your local handler when you're ready for them. You stop re-configuring Stripe entirely - the URL outlives your terminal, your laptop battery, and your redeploys.

Step 1: a permanent URL for Stripe

Create a webhook endpoint with a name you choose. There's no server behind it - OtterKit answers Stripe from the edge and stores what arrived:

bash
npx otterkit webhook --daemon --subdomain stripe-dev --ttl never
# paste https://stripe-dev.otterkit.app into the Stripe dashboard

Paste that URL into Developers → Webhooks in Stripe once, and you're done configuring forever. --subdomain reserves the name so the URL survives restarts; --ttl never keeps the endpoint up until you stop it. Stripe gets a 200 on every delivery - even at 3am with your machine closed - so you never hit Stripe's retry-then-disable behavior for unreachable endpoints.

Every request is captured with full headers and body to a local log (~/.otterkit/requests/stripe-dev.jsonl). Browse it from the terminal:

bash
npx otterkit inspect stripe-dev --last 5
npx otterkit inspect stripe-dev --follow          # tail live traffic
npx otterkit inspect stripe-dev --method POST --path /webhook

Step 2: verify signatures at the door

Arm the endpoint with your signing secret and every arriving request gets its Stripe-Signature checked server-side, at arrival:

bash
npx otterkit webhook --daemon --subdomain stripe-dev --ttl never \
  --verify stripe:whsec_abc123

Each capture carries a verdict - verified, invalid, or unsigned - with the failure reason attached, so a wrong secret (signature_mismatch) and a stale replay (timestamp_out_of_tolerance) look different. Verification never blocks the request; you just know. This settles the single most common webhook argument: is my signature check wrong, or is the payload wrong?

For captures you already have, otterkit verify answers the same question after the fact:

$ npx otterkit verify stripe-dev stripe --secret whsec_...
✓ 08:14:02.101  POST /webhooks/stripe
✗ 08:15:11.882  POST /webhooks/stripe
    Stripe-Signature: signature_mismatch

A mismatch with the correct secret means the body you're verifying isn't the raw bytes Stripe signed. All-valid means the bug is in your handler.

Step 3: replay events into localhost

Captures are inputs, not just records. Once your handler is running locally, re-send any captured event straight at it - no tunnel round-trip, no credits, no waiting for Stripe to fire again:

bash
# Re-send the latest capture to your local handler
npx otterkit replay stripe-dev --target 127.0.0.1:3000

# Re-send capture #3 (1 = oldest, -1 = latest)
npx otterkit replay stripe-dev --index 3 --target 127.0.0.1:3000

This is the edit-compile-replay loop that makes webhook work feel like normal development: fix the handler, replay the same event, watch it process. You can also edit fields on the way through - and because editing a signed body breaks its signature, --resign re-signs the edited payload so your verification still passes:

bash
npx otterkit replay stripe-dev --set data.object.amount=999 \
  --resign stripe --secret whsec_...

No Stripe account? Fire synthetic events.

You don't need a real event - or a Stripe account - to develop a handler. otterkit send fires a correctly-signed synthetic event at your local server, signed exactly as Stripe would sign it:

bash
npx otterkit send stripe:payment_intent.succeeded 127.0.0.1:3000/webhooks/stripe \
  --secret whsec_your_signing_secret

# Customize the payload
npx otterkit send stripe:checkout.session.completed 127.0.0.1:3000/hooks \
  --secret whsec_... --body '{"data":{"object":{"amount_total":19900}}}'

# List every provider and event
npx otterkit send --list

Run it without --secret and the event arrives unsigned - the test for the branch your handler should reject. Everything runs on your machine; the secret never leaves it.

Prefer live forwarding? Use a tunnel.

If you want Stripe's deliveries to hit your running handler directly, put a tunnel in front of it with the same stable-name trick:

bash
npx otterkit tunnel 3000 --subdomain stripe-dev --log
# paste https://stripe-dev.otterkit.app/webhook into the Stripe dashboard

Same permanent URL, but requests forward into localhost:3000 live, and --log keeps the capture log so inspect and replay still work. The endpoint mode wins when you want capture-first workflows and a URL that answers while you're away; the tunnel wins when you want to watch your real handler process real deliveries.

The CI recipe

The same pieces compose into an end-to-end payment test. await blocks until a matching event lands, with deterministic exit codes:

bash
# 1. endpoint up (idempotent if it already exists)
npx otterkit webhook --daemon --subdomain stripe-ci --ttl 24h --json

# 2. trigger the payment flow under test...

# 3. assert the webhook arrived: exit 0 on arrival, exit 2 after 2 minutes
npx otterkit await stripe-ci --method POST --count 1 --timeout 120s --json

With --json the matched event prints as one JSON line - pipe it to jq and assert on the payload itself, not just its arrival.

Cost, for completeness: an endpoint bills 1 credit ($0.01) per hour, capped at $3 per rolling 30 days - so --ttl never is a deliberate option, not a foot-gun. See the webhooks docs for response rules, forwarding, and signature providers beyond Stripe.