Whisper · Docs
Containers & cloud

The forward gateway

Some runtimes hand you fetch() and nothing else — no net.Socket, no dns, no child_process, not even the CONNECT-tunneling trick that gets Cloudflare Workers by.

Every one of Whisper's other egress tiers assumes you can open some kind of socket; the forward gateway is what's left when you can't.

Vercel's Edge Runtime and Netlify Edge Functions are both built on a V8 isolate with a single primitive: a global fetch. No net, no tls, no dns.resolve, no raw TCPSocket — not even the restricted cloudflare:sockets escape hatch Workers gets. That rules out Tier 1 (WireGuard needs a UDP-capable interface) and Tier 1.5 (SOCKS5/HTTP-CONNECT needs a raw TCP socket you can hand off to a proxy) outright — there is no interface layer to bind, and no socket to negotiate a CONNECT handshake over. If your agent's code happens to run in one of these sandboxes, the only wire you have is HTTPS itself, so the egress mechanism has to be HTTPS: one request in, one request out, no tunnel in between. That's forward.whisper.online/forward.

The problem: some runtimes have no socket to bind

Every other Whisper connect tier (see Connect & egress) ultimately needs to hold a socket: WireGuard needs a UDP interface, SOCKS5/CONNECT needs a raw TCP connection to negotiate a proxy handshake over before layering TLS to the real origin. A fetch-only sandbox has neither.

With stock tools — the pain, no Whisper involved, from inside a Vercel Edge Function or similar isolate:

export const config = { runtime: "edge" };

export default async function handler() {
  // this is genuinely the entire socket API available to you:
  const r = await fetch("https://api64.ipify.org?format=json");
  return Response.json(await r.json());
  // -> {"ip":"76.76.21.9"}  — Vercel's edge PoP address, nothing to do with any agent
}

There's no dig, no ip, no raw socket to reach for here — the sandbox genuinely only offers fetch. Anything that fixes this has to be expressible as one more fetch call.

The mechanism: one HTTPS hop, server-side dial

POST https://forward.whisper.online/forward takes the outbound request you wanted to make, dials it server-side from a node that holds the agent's egress binding, sources the connection to the origin from the agent's routable /128 (the same AnyIP + IP_FREEBIND source-binding Tier 1.5 uses over 2a04:2a01::/32 — see Connect & egress), and streams the origin's response back to you over the one TLS session you already had open. No CONNECT, no nested handshake, no interface.

With stock tools — it's just JSON over HTTPS, so curl drives it exactly as a library would:

curl -s -X POST https://forward.whisper.online/forward \
  -H "X-API-Key: whisper_live_..." \
  -H 'content-type: application/json' \
  -d '{
        "agent": "acef2002a323d40d4",
        "url": "https://api64.ipify.org?format=json"
      }'
{"status": 200, "body": "{\"ip\":\"2a04:2a01:eb5a:ca74:cef2:2a:323d:40d4\"}"}

The response's X-Whisper-Egress-Source header (and the body, if the origin echoes your address) both read back the agent's own /128 — proof the fetch actually left from the identity, not from forward.whisper.online's own address. Confirm it the same way you'd confirm any egress tier (Connect & egress → "never trust the client's own claim"):

curl -s -X POST https://forward.whisper.online/forward \
  -H "X-API-Key: whisper_live_..." -H 'content-type: application/json' \
  -d '{"agent":"acef2002a323d40d4","url":"https://whisper.online/egress-ip"}' -D - -o -
# X-Whisper-Egress-Source: 2a04:2a01:eb5a:ca74:cef2:2a:323d:40d4
# {"ip":"2a04:2a01:eb5a:ca74:cef2:2a:323d:40d4"}

With Whisperwhisper-edge's agentEgress() picks this transport automatically on runtimes with no socket API, or on request:

import { agentEgress } from "whisper-edge";

const egress = await agentEgress(env.WHISPER_API_KEY, "acef2002a323d40d4", {
  transport: "forward",
});
const res = await egress.fetch("https://api64.ipify.org?format=json");
console.log(egress.transport.address);   // 2a04:2a01:eb5a:ca74:cef2:2a:323d:40d4
console.log(await res.text());
egress.close();

egress.fetch() is a drop-in fetch — same signature, same Response object — so existing code that calls fetch(url, init) needs no rewrite beyond swapping the function reference. The full API surface, including the keyless verify/resolve/rdap calls that don't need this at all, is in the Edge SDK reference; the Cloudflare-specific walkthrough (Workers use forward too, for a different reason — see below) is in Cloudflare Workers.

Why not just tunnel? The SNI problem

It's worth understanding why a proxy tunnel doesn't work here rather than treating forward as an arbitrary alternative. A CONNECT request (RFC 9110 §9.3.6) asks a proxy to open a raw byte pipe to a target host:port; the client then runs its own TLS handshake to the real origin inside that pipe, with its own SNI naming the real origin. That's two TLS sessions, nested: one (implicit) trust relationship with the proxy at the TCP layer, one real handshake with the origin inside it.

Fetch-only runtimes give you exactly one TLS session, terminated by the platform's own fetch implementation, with the SNI and certificate validation already decided before your code runs. There is no hook to say "now open a second handshake inside this one." forward.whisper.online sidesteps the nesting problem entirely: your one real request is to Whisper, over one normal TLS session with forward.whisper.online's own certificate; the second hop (to the actual origin) happens entirely server-side, where a real socket is available. You trade "the proxy tunnels my bytes" for "the proxy makes the request I asked for and hands me back the result" — one hop from your code's point of view either way, but implemented as a relay instead of a tunnel. Cloudflare Workers hit a narrower version of the same wall: cloudflare:sockets' startTls() pins SNI to the socket you already opened, so a CONNECT-style tunnel to a SOCKS5/HTTP proxy can't then carry a second handshake to the HTTPS target either — which is why the Cloudflare recipe uses transport:"forward" too, despite Workers technically having a raw-socket API that Vercel/Netlify Edge don't.

Request and response shape

Field In Required Notes
X-API-Key header yes the tenant key; never a URL parameter, never logged
agent body yes the agent's name or its /128 — must belong to the caller's tenant
url body yes the target; must be https:// or http:// — no other scheme
method body no default GET
headers body no forwarded to the origin verbatim, minus hop-by-hop headers
body body no forwarded as the origin request body
status / headers / body response the origin's response, relayed as JSON (small/text payloads)

A target resolving to a private, loopback, link-local, or otherwise non-routable address is rejected before any dial is attempted — the same SSRF guard Tier 1.5's proxy applies, because the mechanism is the same source-binding path, just invoked as a relay instead of a live proxy connection. A malformed body, a missing agent, or a scheme other than http(s) gets a 400 with an RFC 7807 problem body, never a bare stack trace or a silent pass-through — consistent with every other Whisper endpoint (see RDAP for the shape).

When to reach for it — and when not to

Use forward only where you're actually stuck without a socket:

Prefer Tier 1 (WireGuard) or Tier 1.5 (SOCKS5/CONNECT) everywhere else: they're a single relay away from your code (no JSON-wrapped round trip through Whisper's own infrastructure per request), they stream natively instead of buffering into a JSON body, and they don't add a second network hop's latency to every fetch. forward is the fallback for the sandboxes that leave you no other choice, not a general-purpose replacement for a real proxy — see Connect & egress for the full tier comparison.

Reliability caveat: the egress binding is node-local today

This is the one operational fact to know before you build on forward at scale: the state that lets a node source-bind a connection to a given agent's /128 lives on whichever node allocated that agent's identity — it is not (yet) replicated to every node behind the gateway's name. Concretely, forward.whisper.online today resolves to a single node rather than round-robin anycast across the fleet — check it yourself:

dig +short forward.whisper.online AAAA
# -> 2a04:2a01:0:53::a         (one answer, one node — not a round-robin set)
dig +short forward.whisper.online A
# -> 78.141.218.153

If a future deployment starts answering with multiple nodes' addresses before that binding state is shared cluster-wide, a request that happens to land on a node that never allocated the agent's binding will fail closed (502) rather than silently egress from the wrong address — Whisper never guesses at whose /128 to source from. The safe assumption for anything you build today: treat forward.whisper.online as a single logical endpoint, not a load-balanced anycast pool, until this doc says otherwise. whisper-edge's forward transport already retries transiently-failed calls rather than surfacing a one-off 502 to your code, but a design that fans requests for the same agent across multiple independent client processes should still expect them to land consistently on one node for now.

Next

Connect & egress — the full tier comparison (WireGuard, SOCKS5, resolver-only) this gateway fits underneath · Cloudflare Workers — the recipe that uses transport:"forward" for a different reason (SNI nesting on cloudflare:sockets) · Edge SDK — the full whisper-edge API including agentEgress().