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:

About authentication: The $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.
Create endpoint via API
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
Tip: Set 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:

Switch to local development
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"]
  }'
Switch back to production
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:

Fan-out to local + production
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"
    ]
  }'
Heads up: Be careful with fan-out on payment webhooks. If both your prod app and local server process the same 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:

Step 1: Find the event
# List recent events
curl https://runlater.eu/api/v1/endpoints/ENDPOINT_ID/events \
  -H "Authorization: Bearer $RUNLATER_KEY"
Step 2: Point to localhost, then replay
# 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.

  1. Open the event you want to test in the dashboard
  2. Paste your tunnel URL (e.g. https://your-tunnel.runlocal.eu/webhooks/stripe) into the forward input
  3. 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.

When to use Forward vs Replay: Replay re-sends the event to the endpoint's current forward URLs. Forward sends it to any URL you type in, without changing the endpoint config. Use Forward when you want a quick one-off test against your local tunnel.

Typical workflow

Here's how this fits into a day-to-day development cycle:

  1. Set up once: Create a Runlater endpoint, register it with Stripe. This URL never changes.
  2. Start your tunnel: npx runlocal 3000
  3. Swap the forward URL to your tunnel (dashboard or API).
  4. Trigger the webhook — make a test payment in Stripe, push to a repo, or create an order.
  5. Debug locally with breakpoints, logging, or whatever you prefer. The webhook arrives at your local server.
  6. Fix the bug, replay the event as many times as you need.
  7. 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_queue to false so 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.
Ready to get started? Create a free account (3 endpoints on the free tier), start a tunnel with runlocal.eu, and test your first webhook locally in under 5 minutes.

Back to all guides