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:
npx otterkit webhook --daemon --subdomain stripe-dev --ttl never
# paste https://stripe-dev.otterkit.app into the Stripe dashboardPaste 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:
npx otterkit inspect stripe-dev --last 5
npx otterkit inspect stripe-dev --follow # tail live traffic
npx otterkit inspect stripe-dev --method POST --path /webhookStep 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:
npx otterkit webhook --daemon --subdomain stripe-dev --ttl never \
--verify stripe:whsec_abc123Each 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_mismatchA 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:
# 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:3000This 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:
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:
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 --listRun 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:
npx otterkit tunnel 3000 --subdomain stripe-dev --log
# paste https://stripe-dev.otterkit.app/webhook into the Stripe dashboardSame 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:
# 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 --jsonWith --json the matched event prints as one JSON line - pipe it to jq and assert on the payload itself, not just its arrival.
--ttl never is a deliberate option, not a foot-gun. See the webhooks docs for response rules, forwarding, and signature providers beyond Stripe.