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 cost | URL stability | Latency | Good fit for | |
|---|---|---|---|---|
| Tunnel (free tier) | Lowest | Changes per restart | Real-time | Local dev, quick tests |
| Tunnel (paid) | Low | Stable | Real-time | A single long-lived process you don't mind paying to keep addressable |
| Hosted polling inbox | Low | Permanently stable | Seconds (long-poll) | Cron/batch agents, OAuth redirect_uris, anything without an open inbound port |
| Self-hosted receiver | Highest | Stable (you own it) | Real-time | Existing 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.