Local Webhook Testing
Test webhooks against your local dev server using Runlater endpoints and runlocal.eu as a tunnel. Replay real events, swap URLs on the fly, and debug without deploying.
The problem
Webhook providers like Stripe, GitHub, and Shopify need a public URL to send events to. Your
localhost:3000
isn't reachable from the
internet, so you end up in a painful loop:
- Deploy to staging just to test a webhook handler
- Use CLI tools that give you a new URL every time you restart
- Manually craft fake payloads that don't match real ones
- Miss edge cases because you can't reproduce the exact event that broke production
The solution
Combine two services:
- Runlater inbound endpoints — a stable public URL that captures every webhook and forwards it wherever you tell it to
- runlocal.eu — a reverse proxy that exposes your localhost to the internet with a stable URL
Stripe / GitHub / Shopify
|
v
runlater.eu/in/ep_xxx <-- stable URL, never changes
|
| stores payload, then forwards
v
xxx.runlocal.eu <-- tunnel to your machine
|
v
localhost:3000/webhooks <-- your dev server
The key insight: the Runlater endpoint URL never changes. You set it once in Stripe/GitHub/Shopify and forget about it. When you want to test locally, you just swap the forward URL to your runlocal tunnel. When you're done, swap it back to production.
Setup
1. Start your tunnel
runlocal.eu gives your local dev server a public URL. Start a tunnel to your app:
npx runlocal 3000 # Your local server is now available at: # https://your-tunnel.runlocal.eu
2. Create an inbound endpoint
Create a Runlater endpoint that forwards to your tunnel. You can do this from the dashboard or the API:
$RUNLATER_KEY
in the examples below
is your Runlater API key
— used to manage
endpoints via the API. The inbound URL itself
(runlater.eu/in/ep_xxx) is public and
needs no authentication, so webhook providers can send to it directly.
curl -X POST https://runlater.eu/api/v1/endpoints \
-H "Authorization: Bearer $RUNLATER_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Stripe dev",
"forward_urls": ["https://your-tunnel.runlocal.eu/webhooks/stripe"],
"retry_attempts": 0
}'
# Response includes your stable inbound URL:
# https://runlater.eu/in/ep_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
retry_attempts
to 0
for local testing. You don't want
retries hammering your dev server while you're setting breakpoints.
3. Point your webhook provider at the endpoint
Copy the inbound_url
from the response
and paste it into Stripe (or GitHub, Shopify, etc.) as your webhook URL. This URL is permanent
— you'll never need to change it in Stripe again.
Swap URLs without touching Stripe
This is where it gets powerful. Your webhook provider always sends to the same Runlater endpoint. To switch where events go, just update the forward URL:
curl -X PUT https://runlater.eu/api/v1/endpoints/ENDPOINT_ID \
-H "Authorization: Bearer $RUNLATER_KEY" \
-H "Content-Type: application/json" \
-d '{
"forward_urls": ["https://your-tunnel.runlocal.eu/webhooks/stripe"]
}'
curl -X PUT https://runlater.eu/api/v1/endpoints/ENDPOINT_ID \
-H "Authorization: Bearer $RUNLATER_KEY" \
-H "Content-Type: application/json" \
-d '{
"forward_urls": ["https://myapp.com/webhooks/stripe"]
}'
You can also do this from the Runlater dashboard — just edit the endpoint and change the URL. No Stripe dashboard needed, no webhook re-registration, no new signing secrets.
Forward to both local and production
Endpoints support multiple forward URLs. Send real production webhooks to both your live app and your local dev server at the same time:
curl -X PUT https://runlater.eu/api/v1/endpoints/ENDPOINT_ID \
-H "Authorization: Bearer $RUNLATER_KEY" \
-H "Content-Type: application/json" \
-d '{
"forward_urls": [
"https://myapp.com/webhooks/stripe",
"https://your-tunnel.runlocal.eu/webhooks/stripe"
]
}'
payment_intent.succeeded, you
could double-process it. Use this for read-only debugging, or make sure your local handler
skips writes.
Replay events to debug locally
Every webhook that hits your endpoint is stored — method, headers, body, everything. Found a bug in production? Replay the exact event that caused it against your local server:
# List recent events
curl https://runlater.eu/api/v1/endpoints/ENDPOINT_ID/events \
-H "Authorization: Bearer $RUNLATER_KEY"
# Update forward URL to your local tunnel curl -X PUT https://runlater.eu/api/v1/endpoints/ENDPOINT_ID \ -H "Authorization: Bearer $RUNLATER_KEY" \ -H "Content-Type: application/json" \ -d '{"forward_urls": ["https://your-tunnel.runlocal.eu/webhooks/stripe"]}' # Replay the event curl -X POST https://runlater.eu/api/v1/endpoints/ENDPOINT_ID/events/EVENT_ID/replay \ -H "Authorization: Bearer $RUNLATER_KEY"
The replayed event hits your local server with the exact same headers and body as the original. Set a breakpoint, step through your handler, and see exactly what went wrong.
Forward a single event to any URL
Sometimes you don't want to change the endpoint's forward URLs at all — you just want to send one specific event to your local tunnel. The Forward to URL action on the event detail page does exactly that.
- Open the event you want to test in the dashboard
-
Paste your tunnel URL (e.g.
https://your-tunnel.runlocal.eu/webhooks/stripe) into the forward input - Click Forward
Runlater creates a one-shot task with the event's original headers and body, sends it to your URL, and shows the result in the event's forwarding section. The endpoint settings stay untouched — no need to swap URLs back and forth.
Typical workflow
Here's how this fits into a day-to-day development cycle:
- Set up once: Create a Runlater endpoint, register it with Stripe. This URL never changes.
-
Start your tunnel:
npx runlocal 3000 - Swap the forward URL to your tunnel (dashboard or API).
- Trigger the webhook — make a test payment in Stripe, push to a repo, or create an order.
- Debug locally with breakpoints, logging, or whatever you prefer. The webhook arrives at your local server.
- Fix the bug, replay the event as many times as you need.
- Swap back to production when you're done.
Use cases beyond local dev
Staging environments
Same pattern works for staging. Point the forward URL at your staging server to test with real webhook payloads before deploying to production.
Team debugging
A teammate reports a webhook bug. Find the event in the dashboard, share the event ID, and they replay it against their own local tunnel. No need to reproduce the exact conditions that triggered it.
Load testing webhook handlers
Replay a batch of historical events against your local server to see how your handler performs under load — with real payloads, not synthetic ones.
Onboarding new developers
New team members can replay real events to understand the webhook flow without needing test accounts or triggering real actions in third-party services.
Best practices
- Use separate endpoints for dev and prod. Create a "Stripe dev" endpoint for local testing and a "Stripe prod" endpoint for production. This avoids accidentally routing production webhooks to localhost.
- Set retries to 0 for local testing. Your dev server will go up and down. You don't want queued retries from yesterday's session flooding your server when you start it today.
-
Use parallel delivery for debugging.
When fan-out testing to both prod and local, set
use_queuetofalseso a slow local server doesn't block production delivery. - Check the event log. The Runlater dashboard shows every event with its delivery status. If a webhook isn't arriving locally, check whether the event was received and whether forwarding succeeded.