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}$); asubjectOfthat returns anything else is a misconfiguration (PXY0500, andonError); - 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 toonError, and tries again at most once a minute. Run it yourself at start-up withproxy.checkSubjectScoping(), or withverifySubjectScoping(client.api)(from/server) on a client overhubTransport; - 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.
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: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 aResponseto 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:
hubTransportwaits 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 withtimeoutsonly 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 otherhttp://calls from https pages, bare IPs included. The example sets this withCORS_ORIGINS. - The example has no login:
examples/proxy-serverbelieves 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 on127.0.0.1unlessHOSTsays otherwise, and warns at start-up onceHOSTorCORS_ORIGINSopens it up. Put your own authentication inresolveUserbefore anyone else can reach it. It keeps users apart by subject unlessHUB_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.