For the complete documentation index, see llms.txt. This page is also available as Markdown.

The Keyless Proxy

The same-origin proxy you deploy so the browser never holds a ZeroDelta key — contract, reference handler and CSP.

The widget never talks to the ZeroDelta gateway directly. It calls a same-origin endpoint you deploy, which injects your API key server-side and forwards a fixed set of requests. The browser holds no credential at all.

browser  ──►  https://your-app.com/api/widget/v1/*   ──►  https://zd-api.prod.glacis-infra.network/api/v1/*
           (same origin, no key)                      (your key, server-side)

This is a hard prerequisite. Until it is deployed, every widget request answers 503 proxy_misconfigured.

Getting an API key

ZeroDelta is permissioned — there is no self-serve signup. Integrators are onboarded and KYB'd by the Glacis team, who issue:

  • a viewer key, which this proxy holds server-side and sends upstream as x-api-key, and

  • the base URL for your environment.

Ask for both here — Glacis integrations team. It is the same base URL used for a direct API integration. A key with special rates works with the widget from v4.7.0: the gateway returns a marker in the quote's partnerId, and the widget carries it into the order. On an earlier version use a standard viewer key — those versions submit bytes32(0), and a quote at a special rate submitted without its marker may remain unfilled. See API Reference.

The key is a server-side credential. It goes in your deployment's environment, never in the browser bundle and never in your repository. If you ever need the browser to hold one, read If you cannot be same-origin first — and prefer not to.

The contract

Mount a handler at /api/widget/v1/:path*. It must implement exactly this.

Allowed requests

Everything else is rejected before any upstream call. This is never an open relay that attaches your key to an arbitrary path.

Resource
Method
Purpose

chains

GET

the enabled chain registry

tokens

GET

per-chain token addresses and decimals

routes

GET

the live route matrix

route-health

GET

measured lane health, so the widget stops offering broken lanes

prices

GET

the gateway's USD price snapshot, for the dollar line under an amount

quote

POST

pricing, and the gateway's pre-quote compliance screen

screen/wallet

POST

the standalone wallet screen — required for direct settlement

prices only paints a dollar line. The card reads it to print "$ 100.00" under an amount; the figure never reaches a quote, a plan or a signature. A deployment that omits the entry answers 404, and the card simply prints no dollar line — nothing else changes.

screen/wallet is not optional, and forgetting it fails closed loudly. A direct settlement never calls /quote, so it never reaches the gateway's pre-quote screen; this endpoint is its only compliance control. A deployment that omits the entry answers 404, the engine reads that as "could not screen", and direct settlement disappears from the picker. That is the intended failure direction — but it looks like a missing feature, so check this first if direct routes vanish.

Note it is a POST because it writes upstream: it bills a screening provider and appends an audit row. The engine caches and de-duplicates verdicts for that reason — do not call it per keystroke, and count it against your daily cap alongside /quote.

Forward the query string, and return the body untouched. The widget reads tokens and routes a page at a time: it sends limit on every request and offset from the second page on, and reads meta.totalCount and paging.next from the response. Pass every query parameter through except your router's own path parameter, as the reference handler below does. A proxy that drops the query string answers every page with the first one, and the widget cannot see every route and token the gateway publishes.

Forward the parameters, never the path. Rebuild the query from the parsed parameters (URLSearchParams encodes them), and take the upstream path only from the table above. Appending the incoming URL, or any part of its path, to the upstream request can let a caller reach resources this contract refuses.

Environment

Variable
Required
Default
Notes

STABLEGENIE_API_KEY

yes

—

The viewer key from onboarding, sent upstream as x-api-key. Alias: ZERODELTA_API_KEY.

ZD_API_UPSTREAM

no

https://zd-api.prod.glacis-infra.network

https only (http allowed on loopback). A trailing /api/v1 is ignored. Alias: ZERODELTA_API_URL.

WIDGET_ALLOWED_ORIGINS

no

—

Comma-separated origins allowed to call the proxy cross-origin. Same-origin is always allowed.

RATE_LIMIT_PER_MIN

no

60

Per-IP request budget.

GLOBAL_QUOTE_CAP_PER_DAY

no

5000

Daily ceiling on the resources that cost money upstream: quote and screen/wallet.

Never prefix these with a client-side bundler prefix. VITE_, NEXT_PUBLIC_, REACT_APP_ and their equivalents inline the value into the public bundle — which is precisely the leak this proxy exists to prevent.

Responses

Every rejection fails closed and carries a machine-readable code:

Status

code

Meaning

403

origin_not_allowed

Cross-origin request from an origin not in WIDGET_ALLOWED_ORIGINS

404

not_found

Path is not one of the seven allowed resources

405

method_not_allowed

Wrong method for that resource

429

rate_limited

Per-IP or daily budget exhausted; carries Retry-After

503

proxy_misconfigured

No API key configured

503

service_unavailable

No valid upstream configured

504

PROXY_TIMEOUT

The gateway did not respond in time

502

PROXY_ERROR

Upstream request failed

On success, the gateway's status and JSON body pass through unchanged — with one optional addition to a quote, described under Naming the visitor's country.

Never answer with access-control-allow-origin: *. Echo the specific allowed origin and set Vary: Origin. CORS is not authentication — the surface is read-only pricing plus a screening call, and every payment is wallet-signed, so the blast radius of abuse is cost and quota, never funds — but these controls bound it, and they only work if each one fails closed.

Two rules that keep the compliance signal honest

Both are about what you forward, and both were tightened in v4.0.0. Get them wrong and the gateway screens the wrong party.

Forward the end user's IP, but only from an edge-verified header. The gateway resolves geo and OFAC jurisdiction from it. If you forward nothing, the gateway sees your serverless function's IP and screens that instead of the payer. Trust x-vercel-forwarded-for or x-real-ip (your platform writes these from the real TCP peer and overwrites any client copy). A raw x-forwarded-for is a client-appendable chain — if it is your only signal, take the last entry, never the first. With no trustworthy signal, emit no header at all; never a placeholder.

Never forward a caller-supplied country header. cf-ipcountry and cf-connecting-ip are only meaningful behind Cloudflare. On any other host nothing sets or strips them, so they are entirely attacker-controlled — and because the gateway prefers a country header over the raw-IP fallback, forwarding one lets a payer choose the jurisdiction their payment is judged in. Forward only a header your own edge writes. An absent signal must never be mistaken for a benign jurisdiction.

The gateway believes your forwarded IP and country only once your key is registered as a forwarder. Until then it ignores both headers and resolves the location of whatever connected to it — your serverless function. The symptom is every visitor being judged in your function's region (a deployment in a US region reports a US jurisdiction for a visitor in Europe). Ask the Glacis integrations team to register the key your proxy uses.

Naming the visitor's country

Optional, and only a display matter. The gateway answers a quote with a policy jurisdiction — compliance.sourceJurisdiction, for example EU — which is a region, not a place. From v4.9.0 the widget's compliance panel prints the country beside it (EU · Spain (ES)) when the decision carries sourceCountry, and the region alone when it does not.

The gateway does not return sourceCountry; your proxy adds it, from the same edge-verified header it already forwards. The reference handler below does this on a successful quote and nowhere else. Three things keep it honest:

  • take the country only from a header your edge writes — never from the browser;

  • never overwrite a sourceCountry the gateway returned itself;

  • with no trusted country, return the gateway's body untouched.

Reference handler

A complete implementation for the Vercel Node runtime. No dependencies; only the request and response objects are platform-shaped.

On Vercel, route it with a rewrite:

On Next.js App Router, place the same logic in app/api/widget/v1/[...path]/route.ts and export GET and POST.

On a host that is not Vercel, keep the shape but swap the header names for whichever ones your edge writes and overwrites. The rule is the header's provenance, not its spelling.

Verify it

A JSON chain list means you are done. {"error":{"code":"proxy_misconfigured"}} means the key is not reaching the function.

The escrow pin is scoped to production. The widget verifies the escrow address offline against a bundled production map, so pointing ZD_API_UPSTREAM at a non-production gateway makes that check fail closed on all nine chains at once. That is by design, not a bug.

Letting a caller price with their own key

Optional, and not part of the reference handler above, which forwards no browser credential. It is what the proxy in the widget's own repository (api/_proxy.ts, the one behind the public playground) does, for one purpose: a partner pasting their key into a page you host, to see the prices that key gets.

In that proxy a caller may send its own ZeroDelta key as x-api-key, and the request is priced with it in place of your server key:

Behaviour
Detail

On by default

Set WIDGET_ALLOW_CLIENT_KEY=false to switch it off; the header is then ignored and your key is used.

Same-origin only

A cross-origin caller, even an allow-listed one, never has a key relayed, and the CORS preflight does not list the header.

A malformed key is refused

400 invalid_api_key. It is never dropped in favour of your key: that would return your prices under a "their key" label.

It replaces, never accompanies

The two keys are never sent together, and the caller's key is not stored, logged or echoed.

The response says whose key

A response priced with the caller's key carries x-widget-key-source: caller. Treat a key as accepted only when you see it.

The same bounds apply

The allowed-requests table and the rate limit are unchanged for a caller with its own key.

On the page, pass the key to the widget as apiKey (see below) and keep it in memory only — never in the URL, in storage or in a snippet.

A relayed key is judged at your function's region. The gateway trusts forwarded IP and country only from a registered forwarder key, and a visitor's own key is not one. Quotes priced with it still price correctly; their compliance jurisdiction is the region your proxy runs in.

If you cannot be same-origin

Passing apiKey in the browser is supported but constrained. The key is attached only when apiBase is a same-origin relative path, or an absolute URL whose origin matches trustedApiOrigin:

There is no default trusted origin, on purpose. The correct value is whichever proxy you deploy, so only your integration can know it. Set it to an origin you control — it is the sole destination permitted to receive the key. Protocol-relative and backslash-prefixed values (//evil.example, /\evil.example) are rejected: they pass a naive "starts with a slash" test but resolve to a foreign origin.

Prefer the same-origin proxy. It is the only configuration where the browser holds no credential at all.

Content Security Policy

The widget confirms settlement by reading the transaction receipt over a public RPC. If your CSP blocks that call, a payment that actually succeeded reports as unconfirmed and onSuccess never fires.

Add the nine source-chain RPCs to connect-src, alongside your proxy:

Override any of them with rpcOverrides if you run your own nodes — then list yours instead.

Token artwork is loaded from the registry's image URLs, so img-src needs https: (or your own allow-list). Blocked images degrade to a letters glyph; nothing else changes.

The rest of a working policy, for reference:

style-src needs 'unsafe-inline' because the widget injects its stylesheet and writes its theme tokens as inline custom properties.

What the widget verifies for you

The proxy is one half of the trust model. The other half runs in the browser, and none of it depends on the API being honest:

  • The escrow address is pinned offline. 0x5e25c8ABc19b88d6A7Ab0D805C77A34987a68b40 on every supported chain, cross-checked against the quote and the chain registry. Because both of those come from the same API, the offline pin is what makes the check meaningful — the widget fails closed on a mismatch.

  • Token decimals are pinned offline for every supported asset. An inflated decimals value from a compromised registry would move the amount scaling and mint bad calldata; it fails closed instead.

  • Every quote is re-validated against your selection — owner, receiver, both tokens, both chains, both amounts, and a bounded deadline. Minimum-received is enforced on the encoded calldata, not trusted from the response.

  • Every planned transaction is re-decoded before signing, with its value bound to the plan. Approvals are exact-amount, never unbounded.

  • Settlement is confirmed by receipt over a bundled RPC — never the registry's own — against a nonce baseline captured before broadcast.

  • The compliance decision is disclosed, not just enforced. Every route carries the checks the gateway actually ran, and the widget asserts only what the server reported — see Configure.

The user's wallet always owns the final signature. The widget never holds keys and never submits silently.

Settlement verdicts

Verdict
Meaning

confirmed

Mined with a success receipt. The only verdict that fires onSuccess.

reverted

Mined with a failure receipt — a real on-chain failure.

mismatch

The mined transaction does not match the plan.

unconfirmed

Could not establish either. Must not be presented as a completed payment.

No telemetry

The only network calls the widget makes are to your apiBase, to the RPC used for verification, and image loads for token artwork. There is no analytics endpoint, no beacon and no third-party script.

Upstream error messages are scrubbed of URLs, host:port pairs, filesystem paths and stack frames and bounded to 160 characters before they reach a payer's screen. The original still reaches onError for your own logging.

Known limits

  • The reference rate limiter is in-memory per warm instance — best-effort. A shared store is the production upgrade if you need a hard ceiling.

  • The proxy forwards the end user's IP and country so the gateway can screen, plus the request's query string and body — no other header, cookie or credential from the browser.

Still stuck?

If the proxy is deployed and curl still does not return a chain list, or your key is not working, the Glacis integrations team is the fastest route — the same channel that issued the key.