Your secret API key never reaches a web page. A page can call the hub’s gateway itself with your public project id (Browser apps), which opens every route but the order history. For the history, and to know your users, the page calls your backend: it holds the key and forwards the page’s calls one to one, after checking who the user is. createInteropProxy is that forwarder, ready-made.

Mount the proxy

hubTransport sends the key in x-api-key on every call, under /v1: there is no sign-in and no session to keep alive. proxy is a fetch-style handler ((request: Request) => Promise<Response>), so it also mounts directly in any runtime that speaks Request/Response (a Next.js route handler, Hono, Bun, Deno, a Cloudflare Worker). toNodeListener adapts it to Node’s http module, Express or Connect.

What it does on every call

Only the routes a browser needs are exposed (BROWSER_ROUTES); the OpenAPI document and everything else stay on the server. The hub’s own answers come back unchanged (its 200/400 envelope), and so do the gateway’s rate limits and outages: a 429 or 5xx with its EPX code and its Retry-After. The proxy’s own refusals come back in the same envelope with PXY codes:

The user and their wallets

resolveUser is your authentication. Return null for a signed-out request. wallets are the addresses you have verified the user controls (for example by a signed message at sign-in). With them, the proxy refuses a quote that pays from or to anyone else, and filters the history to rows that touch those wallets. Without them, those checks are off: only do that when your own backend already enforces them.

Keeping your users apart

The key tells the hub your organization, never which of your users a call is for. userScope picks how the proxy keeps your users apart (Authentication — your users): With "subject":
  • the subject is your stable id for the person, never reused for someone else, in the hub’s alphabet (^[A-Za-z0-9._@+~-]{1,128}$); a subjectOf that returns anything else is a misconfiguration (PXY0500, and onError);
  • before it serves any history the proxy runs the subject canary: it lists orders for a subject none of your users has, which must come back empty and echo that subject. While the canary fails it refuses history (PXY0503), reports it to onError, and tries again at most once a minute. Run it yourself at start-up with proxy.checkSubjectScoping(), or with verifySubjectScoping(client.api) (from /server) on a client over hubTransport;
  • every history page must echo the user’s subject (data.subject), or the proxy refuses it (PXY0502);
  • GET /status/{orderId}, which the hub does not scope by subject, is still answered for the user’s own orders only, from the proxy’s records.
Either way the proxy records who submitted each order. The default memoryOwnershipStore() is in memory and lost on restart; pass your own OwnershipStore (four methods: recordQuote, ownerOfQuote, recordOrder, ownerOfOrder) backed by your database when that matters.

Relaying RPC calls

Your frontend also reads chains: balances, allowances, receipts. Some RPCs answer no CORS; others need a key you would rather not publish. Relay them through the same backend, for the chains you configure only:
The SDK repository’s examples/proxy-server has a complete relay with a size cap and a timeout. In production, also allow-list the JSON-RPC methods your frontend uses and rate-limit them.

Hardening

  • beforeForward(context): your last word before a call is forwarded. Return a Response to answer it yourself (a per-user rate limit, a deny list), or nothing to continue. Every user of your backend shares your organization’s pool at the gateway and its buckets at the hub, so meter them here.
  • onError(error, context): a key the gateway refused, misconfiguration, a failed subject canary and proxy bugs land here: page an operator. Users only ever see a generic “temporarily unavailable”.
  • Timeouts: hubTransport waits a little longer than the gateway (25 s on quotes, 12 s on reads, 15 s on submissions), so the gateway’s own answer ends a slow call; change them with timeouts only upwards.
  • Pages on other origins: answer CORS for exactly those origins, never *: your backend spends your API key. From an https page your backend must be https too (or on the user’s own computer, http://localhost): browsers block other http:// calls from https pages, bare IPs included. The example sets this with CORS_ORIGINS.
  • The example has no login: examples/proxy-server believes whatever user id and wallets the page sends, so anyone who can reach it spends your key and can read any of your users’ history through it. It listens on 127.0.0.1 unless HOST says otherwise, and warns at start-up once HOST or CORS_ORIGINS opens it up. Put your own authentication in resolveUser before anyone else can reach it. It keeps users apart by subject unless HUB_USER_SCOPE=ownership, and runs the subject canary at start-up.

Keys

Keys are made, listed and revoked in the hub dashboard, never through the API. Keep the key in your secret store, give each environment its own, and rotate by making the new key, deploying it, then revoking the old one. hubTransport refuses at start-up a value that is not in a secret key’s format (c8n_sk_ and 43 characters), with a message that says so. When the gateway refuses the key (EPX0001, EPX0002), fix it before calling again: 20 refused credentials within a minute lock your server’s IP out of every call for the rest of that minute (EPX0081), valid keys included. hubTransport refuses to start in a web page. Its dangerouslyAllowBrowser: true lifts that only for a test key on a hub that accepts browser calls; the hub’s gateway does not. See Browser apps and API keys and rate limits.