# 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](/docs/connect)) 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:

```ts
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](/docs/connect)), 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:

```bash
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"
      }'
```

```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"](/docs/connect#confirming-egress)):

```bash
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 Whisper** — `whisper-edge`'s `agentEgress()` picks this transport automatically on runtimes with no socket API, or on request:

```ts
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](/docs/sdk-edge) reference; the Cloudflare-specific walkthrough (Workers use `forward` too, for a different reason — see below) is in [Cloudflare Workers](/docs/cloudflare).

## 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](https://www.rfc-editor.org/rfc/rfc9110#section-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](https://www.rfc-editor.org/rfc/rfc6066#section-3) 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`, with the SNI and certificate validation already decided before your code runs. There's 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 its own certificate; the second hop, to the actual origin, happens 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 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 can't then carry a *second* handshake to the HTTPS target. That's why the [Cloudflare recipe](/docs/cloudflare) 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](/docs/rdap) for the shape).

## When to reach for it — and when not to

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

- Vercel Edge Functions, Netlify Edge Functions — no raw socket API at all.
- Cloudflare Workers, specifically for HTTPS targets — `cloudflare:sockets` exists but can't nest the second TLS handshake (see above).
- Any other fetch-only sandbox (a plugin runtime, a WASM host) with the same constraint.

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](/docs/connect) 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:

```bash
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](/docs/connect) — the full tier comparison (WireGuard, SOCKS5, resolver-only) this gateway fits underneath · [Cloudflare Workers](/docs/cloudflare) — the recipe that uses `transport:"forward"` for a different reason (SNI nesting on `cloudflare:sockets`) · [Edge SDK](/docs/sdk-edge) — the full `whisper-edge` API including `agentEgress()`.
