Receiving webhooks and OAuth callbacks when your agent has no public URL

A script on a homelab box, a laptop, or a VPS behind NAT can't just tell GitHub or an OAuth provider "POST here" — there's no "here" to point at. Three real options, and what each one costs you.

On this page

The actual problem

A webhook sender (GitHub, Stripe, an OAuth authorization server issuing a redirect) needs a URL it can reach over the public internet, right now, synchronously, to deliver one HTTP request. Your agent's process, if it's running behind a home router, inside a corporate VPN, on a laptop that sleeps, or even just on a VPS with a firewall that only opens outbound, has no such URL by default. The fix is always some variant of "put something reachable in front of the unreachable process" — the three options below differ in what that something is and who operates it.

Option A: tunnels

A tunnel client (cloudflared tunnel, ngrok, Tailscale Funnel) runs alongside your agent and opens an outbound connection to a relay service, which hands back a public URL that forwards incoming requests down that same connection to your local process:

# ngrok
ngrok http 8080
# Forwarding  https://a1b2c3d4.ngrok-free.app -> http://localhost:8080

# cloudflared, no account needed for a quick tunnel
cloudflared tunnel --url http://localhost:8080

This gets you a real public URL in seconds with zero server-side infrastructure of your own. The catch: the free tier of most tunnel products gives you a URL that changes every time you restart the tunnel — fine for an interactive debugging session, a real problem for anything registered once with a third party and expected to keep working (a GitHub webhook, an OAuth redirect URI) across restarts. A paid plan buys a stable subdomain; the free version means re-registering the webhook URL with every provider every time the tunnel process restarts, which does not scale past "quick test." A tunnel is also one more running process that has to actually stay up — if it crashes silently, deliveries fail silently until someone notices.

Option B: a hosted polling inbox

Instead of making your process reachable, give the sender a URL that's always reachable — hosted somewhere with a real, permanent address — and have that URL just store whatever hits it. Your agent then reaches out on its own schedule (which it can already do; it doesn't need to be reachable to make an outbound call) and asks "anything new?", optionally long-polling for a bit if it wants close-to-real-time delivery without a public inbound port:

# Once: create a stable inbound endpoint
curl -s -X POST https://api.example.dev/v1/endpoints -H "authorization: Bearer $KEY" \
  -d '{"name":"gh-webhook"}'
# => { "url": "https://api.example.dev/in/9f2a...", "secret": "..." }

# Register that url with GitHub/Stripe/whoever as the webhook target — it never changes.

# From your agent, whenever it runs:
curl -s "https://api.example.dev/v1/events?since_seq=$LAST_SEQ&wait_s=25" -H "authorization: Bearer $KEY"
# long-polls up to 25s for a new event, otherwise returns immediately with an empty list

The URL is stable forever (it's hosted by someone else's always-on infrastructure, not your laptop), and your agent never needs an open inbound port — pendnt's /in/:slug endpoints plus wait_for_event are one implementation of this exact pattern, but the shape works with any hosted inbox that stores-then-lets-you-poll. The tradeoff is latency and trust: delivery is only as fast as your next poll (a long-poll narrows that to seconds, not zero), and you're trusting a third party to hold the payload until you fetch it — worth checking their retention window and whether the payload is encrypted at rest if it's sensitive.

Option C: self-host a receiver with a static address

The heavyweight option: run a small receiver on infrastructure that already has a stable public address — a $5/mo VPS, a Cloudflare Worker, a Lambda behind an API Gateway URL — and have your actual agent process pull from that (over a private network, a queue, or just a database row) rather than being reachable itself. This is strictly more setup than either option above, but it's the right call when you're already running server infrastructure anyway, need full control over retention and payload handling, or don't want a third party in the delivery path at all for compliance reasons.

The OAuth-callback-specific wrinkle

An OAuth authorization code flow adds a constraint webhooks don't have: the redirect_uri usually has to be registered in advance with the authorization server and matched exactly on the callback — you can't just improvise a new one per attempt the way an ad-hoc webhook URL can sometimes get away with. That collides badly with a tunnel's free-tier rotating URL: every time the tunnel restarts, the previously-registered redirect_uri stops matching and the flow breaks until you update it with the provider (some providers make that a manual dashboard edit, not an API call, which makes automated recovery from a tunnel restart genuinely awkward). A stable inbound endpoint (option B) or a self-hosted static address (option C) sidesteps this entirely — register the redirect_uri once, and it's still correct after any number of restarts on your end, since the address doing the receiving never changes.

Tradeoffs at a glance

Setup costURL stabilityLatencyGood fit for
Tunnel (free tier)LowestChanges per restartReal-timeLocal dev, quick tests
Tunnel (paid)LowStableReal-timeA single long-lived process you don't mind paying to keep addressable
Hosted polling inboxLowPermanently stableSeconds (long-poll)Cron/batch agents, OAuth redirect_uris, anything without an open inbound port
Self-hosted receiverHighestStable (you own it)Real-timeExisting server infra, compliance/control requirements

For a headless cron agent specifically — the same shape of process this site's other guides cover (see running claude -p on cron) — the polling inbox is usually the best fit: the agent is already going to run on a schedule and make outbound calls, so adding "also poll for new events" costs nothing extra in infrastructure, while an inbound tunnel would need its own always-on process just to stay reachable between agent runs.