Whisper · Docs
Serverless & edge

Vercel · Netlify · Deno

Verifiable agent identity — and real, source-bound egress — from a runtime that can't open a socket.

Edge and serverless functions are the worst place to prove who is calling out: the sandbox gives you a fetch and nothing else, the outbound IP is a shared, rotating address you don't control, and by the time a downstream API sees the request every identifying signal from your own infrastructure is gone. You can't node:net.connect() a proxy on Vercel Edge or Netlify Edge — there is no net module, no raw socket, no way to bind a source address at all. Most "route your agent's traffic" advice assumes a runtime with a socket API. These don't. An egress path for this world has to be fetch-shaped all the way down — not a VPN client wedged in where it doesn't fit.

whisper-edge is a zero-dependency TypeScript SDK that gives a function on Vercel, Netlify, or Deno Deploy the same two things a Whisper agent gets anywhere else: a cryptographically-checkable identity (no key needed) and, with your key, real egress sourced from a routable Whisper /128 — even when the runtime has no socket to open.

The same SDK runs unchanged on Cloudflare Workers (/docs/cloudflare) and AWS Lambda / Supabase Edge. This page is the Vercel/Netlify/Deno tour; /docs/sdk-edge is the full API reference.

Keyless first: verify without installing anything

Every agent's identity is a public fact, checkable with tools you already have.

With stock tools:

curl -s "https://rdap.whisper.online/verify-identity?ip=2a04:2a01:eb5a:ca74:cef2:2a:323d:40d4" | jq .
# { "is_whisper_agent": true, "fqdn": "acef2002a323d40d4.demo.agents.whisper.online.",
#   "dane_ok": true, "jws_ok": true, "operator": "Whisper Security" }

curl -s -H 'accept: application/rdap+json' \
  https://rdap.whisper.online/ip/2a04:2a01:eb5a:ca74:cef2:2a:323d:40d4 | jq .   # RFC 9083

With Whisper (no key needed for this call):

import { verify, resolve } from "whisper-edge";

if (await verify("2a04:2a01:eb5a:ca74:cef2:2a:323d:40d4")) { /* a real Whisper agent */ }
const who = await resolve("2a04:2a01:eb5a:ca74:cef2:2a:323d:40d4");
// { fqdn, operator, tenant, daneOk: true, jwsOk: true, rdapUrl }

verify/resolve run the whole chain server-side: reverse-DNS PTR → forward-confirm AAAA → the DANE-EE TLSA record (RFC 6698) pinning the agent's certificate under a DNSSEC-signed zone → the JWS identity document (RFC 7515). dane_ok is the field that matters — it means the pin chains to a signed zone, not a CA's say-so. One HTTPS GET, identical whether called from a laptop or a Vercel Edge function.

Egress: the part that actually needs a runtime-specific answer

Verifying someone else is a keyless GET. Egressing as your own agent — so a peer's reverse-DNS resolves your request back to a /128 you control — needs a transport, and Vercel/Netlify/Deno diverge here. agentEgress() detects the runtime and picks the best one automatically:

Runtime Egress transport Bearer on the wire
Vercel Node functions, Netlify Functions, Lambda node:net/node:tls CONNECT tunnel encrypted end-to-end (nested TLS)
Deno Deploy, Supabase Edge Deno.connect + Deno.startTls CONNECT tunnel clear leg to the proxy¹
Vercel Edge, Netlify Edge — no raw-socket API fetch-forward gateway, one HTTPS hop through forward.whisper.online/forward encrypted (plain HTTPS to the gateway)

¹ Deno can't layer TLS-inside-TLS, so the CONNECT preamble rides the clear leg (tokenProtected: false); Node nests TLS and keeps it encrypted throughout.

Vercel

Vercel ships two runtimes under one name, and they need different transports — agentEgress handles both without you choosing:

// api/whisper.ts — Vercel Node function (has node:net)
import { resolve, rdap, control, agentEgress } from "whisper-edge";

export default async function handler(req: Request): Promise<Response> {
  const url = new URL(req.url);
  const key = process.env.WHISPER_API_KEY;

  if (url.searchParams.has("egress")) {
    if (!key) return Response.json({ error: "set WHISPER_API_KEY to egress" }, { status: 401 });
    const egress = await agentEgress(key);               // node:net CONNECT tunnel, auto-selected
    const seen = await (await egress.fetch("https://whisper.online/egress-ip")).json();
    return Response.json({ agent: egress.transport.address, sourcedFromAgent: seen.ip === egress.transport.address });
  }

  const op = url.searchParams.get("op");
  if (op) {
    if (!key) return Response.json({ error: "set WHISPER_API_KEY to use the control plane" }, { status: 401 });
    const res = op === "list" ? await control(key).list() : await control(key).agents(op, {});
    return Response.json({ op, records: res.records });
  }

  const addr = url.searchParams.get("addr");
  if (!addr) return new Response("usage: ?addr=<agent /128> | ?egress (key) | ?op=list (key)\n", { status: 400 });
  const identity = await resolve(addr);
  return Response.json({ address: addr, identity, rdap: identity ? await rdap(addr) : null });
}

Add export const config = { runtime: "edge" }; and deploy the same file to the Edge runtime — there is no node:net there, so agentEgress transparently falls back to the fetch-forward gateway. Nothing at the call site changes.

vercel env add WHISPER_API_KEY          # paste your whisper_live_… key, never commit it
vercel deploy --prod
curl "https://<project>.vercel.app/api/whisper?egress=1"
# {"agent":"2a04:2a01:...","sourcedFromAgent":true}

Netlify

Netlify Functions run on Node (raw CONNECT tunnel); Netlify Edge Functions run on a Deno-based runtime with no raw socket, so the same auto-fallback applies — same import, same agentEgress() call:

// netlify/functions/whisper.mts
import { resolve, rdap, control, agentEgress } from "whisper-edge";

export default async (req: Request): Promise<Response> => {
  const url = new URL(req.url);
  const key = Netlify.env.get("WHISPER_API_KEY");

  if (url.searchParams.has("egress")) {
    if (!key) return Response.json({ error: "set WHISPER_API_KEY to egress" }, { status: 401 });
    const egress = await agentEgress(key);
    const seen = await (await egress.fetch("https://whisper.online/egress-ip")).json();
    return Response.json({ agent: egress.transport.address, sourcedFromAgent: seen.ip === egress.transport.address });
  }

  const addr = url.searchParams.get("addr");
  if (!addr) return new Response("usage: ?addr=<agent /128> | ?egress (key)\n", { status: 400 });
  const identity = await resolve(addr);
  return Response.json({ address: addr, identity, rdap: identity ? await rdap(addr) : null });
};

declare const Netlify: { env: { get(k: string): string | undefined } };
netlify env:set WHISPER_API_KEY whisper_live_…
netlify deploy --prod
curl "https://<site>.netlify.app/.netlify/functions/whisper?addr=2a04:2a01:eb5a:ca74:cef2:2a:323d:40d4"

Deno Deploy

Deno's isolate exposes Deno.connect/Deno.startTls, so agentEgress opens a real CONNECT tunnel there too — with the clear-leg caveat from the table above:

// main.ts
import { resolve, rdap, agentEgress } from "npm:whisper-edge@^0.3.0";

Deno.serve(async (req: Request) => {
  const url = new URL(req.url);
  const key = Deno.env.get("WHISPER_API_KEY");

  if (url.searchParams.has("egress")) {
    if (!key) return Response.json({ error: "set WHISPER_API_KEY to egress" }, { status: 401 });
    const egress = await agentEgress(key);              // Deno.connect + Deno.startTls tunnel
    const seen = await (await egress.fetch("https://whisper.online/egress-ip")).json();
    return Response.json({ agent: egress.transport.address, sourcedFromAgent: seen.ip === egress.transport.address });
  }

  const addr = url.searchParams.get("addr");
  if (!addr) return new Response("usage: ?addr=<agent /128> | ?egress (key)\n", { status: 400 });
  const identity = await resolve(addr);
  return Response.json({ address: addr, identity, rdap: identity ? await rdap(addr) : null });
});
deployctl deploy --project=my-agent main.ts --env WHISPER_API_KEY=whisper_live_…
curl "https://my-agent.deno.dev/?egress=1"
deno run --allow-net --allow-env main.ts    # or run it locally first

Under the hood: the fetch-forward gateway

On a socket-having runtime, egress is a literal CONNECT request (RFC 9110 §9.3.6) to the egress proxy, then the real request tunneled inside it — what curl -x or an HTTP client's ProxyAgent does. A sandbox with no socket at all can't do that, so the fetch-forward gateway inverts the shape: one ordinary HTTPS POST, and the server does the egressing.

With stock tools (the exact request agentEgress makes on your behalf — runnable from any shell for debugging):

curl -s -X POST https://forward.whisper.online/forward \
  -H "Authorization: Basic $(printf 'w:whisper_live_your_key' | base64)" \
  -H "X-Whisper-Target: https://whisper.online/egress-ip" | jq .
# -> the target's response body; reply carries X-Whisper-Egress-Source: <your agent's /128>

With Whisper:

const egress = await agentEgress(apiKey, undefined, { transport: "forward" }); // force it anywhere
await egress.fetch("https://whisper.online/egress-ip");

The gateway authenticates the Basic credential (RFC 7617) as the agent's egress bearer, opens the real outbound connection server-side — source-bound to the agent's /128 via the same address binding every Whisper egress tier uses — and streams the response back stamped with the /128 it egressed from. agentEgress selects this path automatically whenever the runtime has no raw socket (exactly the Vercel Edge / Netlify Edge case), and retries a 407 a few times with a short capped backoff: a freshly-minted egress token needs a brief window to propagate to every gateway node, and a 407 in that window means "not yet known here," not "bad token."

Prove it, don't take the SDK's word for it

Whatever transport was used, the same keyless echo confirms the source address:

curl -s https://whisper.online/egress-ip
# {"ip":"<must equal the agent's own /128>"}
const seen = (await (await egress.fetch("https://whisper.online/egress-ip")).json()).ip;
seen === egress.transport.address; // true, or the SDK already threw

Errors and config

Every call throws a WhisperError carrying the server's exact .status/.detail — never an opaque 500 — or, for verify/resolve/rdap, simply returns false/null for "not an agent," which isn't an error. Every call accepts { timeoutMs, signal, fetch, endpoints }; agentEgress additionally takes { tier, transport, forwardUrl, retries, retryDelayMs }. Every example above uses the zero-argument defaults.

npm i whisper-edge                                        # Vercel (Node) / Netlify Functions / Lambda
import { agentEgress } from "npm:whisper-edge@^0.3.0";     // Deno Deploy / Supabase Edge

Source and the full example set (vercel, vercel-edge, netlify, deno, cloudflare, lambda, supabase): github.com/whisper-sec/whisper-edge. Every shipped integration: /integrations.


Next: /docs/sdk-edge — the full whisper-edge API reference · /docs/cloudflare — the same SDK on Workers, plus the Agents-SDK MCP tool set.