> For the complete documentation index, see [llms.txt](https://docs.glacislabs.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.glacislabs.com/zero-delta/integration-guide/discover-what-you-can-trade.md).

# Discover What You Can Trade

The tradeable surface is a cross product narrowed by live measurement, not a list of routes. Four calls, one copy-paste function, and the three filtering mistakes to design out.

Build your route picker from what the clearing house can actually settle right now. Four calls, one function to copy, and the three mistakes that otherwise reach production.

### Three questions, three endpoints

ZeroDelta's tradeable surface is **not a list of routes**. It is a cross product — any enabled token on any connected chain, to any enabled token on any connected chain — narrowed by live measurement. Three endpoints answer three different questions, and conflating them is the root of most integration problems.

| Question              | Endpoint                                   | Answers                                                                                                       |
| --------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------- |
| What exists?          | `GET /api/v1/chains`, `GET /api/v1/tokens` | The chains we are connected to and the tokens enabled on each. Their cross product is your candidate surface. |
| What works?           | `GET /api/v1/route-health`                 | One measured row per lane, from a prober that actually quotes each pair. This narrows the candidates.         |
| What fills right now? | `POST /api/v1/quote`                       | The authoritative answer for one trade, at one size, at this moment.                                          |

> **`/api/v1/routes` is not the trade catalogue.** It is the bridge-rail table: one row per token per chain pair, describing how that *one* token moves — its bridge, adapter, standard and facet. A `Route` carries a single `tokenId` and no destination-token field, so it cannot express a trade between two different tokens at all, and it covers only the tokens that are bridge-configured. Read it to understand a rail; never to build a picker.

### 1. Authenticate

Every endpoint requires a key, sent as a request header. There is no public surface — an unauthenticated request returns `401 UNAUTHORIZED` with `missing API key`, including for `/chains`.

```
X-API-Key: your-api-key
```

Every `/api/v1/*` response uses the same envelope: `data` on success, plus `meta` and `paging` on list endpoints, and `error` carrying a `code` and `message` on failure. On an error there is **no `data` key at all** — check the status before reading it, as the sample below does.

### 2. Read the chains

`GET /api/v1/chains`. Each entry carries `chainId`, `escrowContract`, `isExecutionChain` and the contract set. You need the escrow address later to submit and to cancel, so treat this endpoint as canonical rather than hardcoding addresses.

### 3. Read the tokens

`GET /api/v1/tokens`. One row per *(token, chain)* cell, with `address`, `decimals`, `isEnabled` and the rail metadata. These cells are your candidate surface.

> **Page this endpoint.** The default page size is 100 and the catalogue is larger, so a single bare call returns a **truncated** list and nothing in the response body looks wrong. `meta.totalCount` is the real number — compare it against what you received, and page with `limit` (max 500) and `offset` until they match. A picker built from the first page silently omits whole tokens.

### 4. Read the measured health, and filter

`GET /api/v1/route-health?scope=all`. One row per lane the prober has an opinion about. The top level carries `summary` with the live counts, `measuredAt`, and `nextSweepDueAt`. This endpoint is not paginated — it returns `data` only, with no `meta` or `paging`.

> **`scope` has exactly two behaviours, and only one of them is spelled.** `scope=all` returns every measured lane. **Any other value — including omitting the parameter entirely, and including `scope=healthy` — returns the unhealthy subset**, and the response echoes `"scope": "unhealthy"` whatever you asked for. Always send `scope=all` when you want the full picture, and read the echoed `scope` back rather than assuming your value was honoured.

```json
{
  "mode": "enforce",
  "scope": "all",
  "summary": { "healthy": 128, "degraded": 3, "broken": 11, "unknown": 6, "total": 148 },
  "measuredAt": "2026-09-11T08:06:23Z",
  "lastSweepAt": "2026-09-11T08:06:23Z",
  "nextSweepDueAt": "2026-09-11T08:21:23Z",
  "lanes": [
    {
      "laneKey": "10:0x01bf...071 then 10:0x0b2c...f85",
      "route": "USDT@10 to USDC@10",
      "status": "healthy",
      "par": 0.9994,
      "lastProbedAt": "2026-09-11T08:06:23Z",
      "lastOkAt": "2026-09-11T08:06:23Z"
    }
  ]
}
```

Shape only — the values above are illustrative, not a census. Read `summary` from your own call for the live counts. A lane that is not healthy also carries `kind`, and usually a human-readable `message`; broken lanes add `consecutiveFailures` and `firstFailedAt`.

| Field                       | Meaning                                                                                                                                            |
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `laneKey`                   | Stable identifier. Source `chainId:address`, then a greater-than sign, then destination `chainId:address`, both addresses lowercased. Key on this. |
| `route`                     | Human-readable label. Display only.                                                                                                                |
| `status`                    | `healthy`, `degraded`, `broken` or `unknown`.                                                                                                      |
| `kind`                      | Why the lane is not healthy. Absent on healthy lanes. See the table below.                                                                         |
| `message`                   | The upstream reason, when there is one worth surfacing.                                                                                            |
| `par`                       | The ratio the prober last measured for this lane.                                                                                                  |
| `lastProbedAt` / `lastOkAt` | When the lane was last measured, and last measured working.                                                                                        |

### What to do with each `kind`

This table is the whole decision. `kind` tells you *why* a lane is not offerable, and each reason has a different correct response.

| `kind`                | What it means                                                                  | Do                   |
| --------------------- | ------------------------------------------------------------------------------ | -------------------- |
| *absent*              | Measured and working. The healthy majority carry no `kind` at all.             | Offer                |
| `firewalled`          | Disabled by ZeroDelta operations; it will not quote. Not a transient failure.  | Hide, do not retry   |
| `not-fillable`        | The lane prices cleanly but cannot actually be filled at the measured size.    | Hide                 |
| `lane-gap`            | No bridge wiring exists for this token pair.                                   | Hide                 |
| `bridge-throttled`    | The token's bridge path is rate-limited to zero upstream. Temporary.           | Hide, retry later    |
| `persistent-upstream` | Liquidity providers unavailable for this pair. Temporary.                      | Hide, retry later    |
| `vault-closed`        | An RWA vault is not accepting deposits. `message` carries the upstream reason. | Hide, explain        |
| `value-floor`         | No acceptable price at the probed size. Size-dependent, not structural.        | Offer with a warning |

### Three mistakes to design out

> **Page `/tokens`, or your catalogue is quietly incomplete.** This is the failure that hides best: the default page is 100 cells, the response is well-formed, and the tokens you never received simply never appear in your picker. Read `meta.totalCount` and page until you match it.

> **Filter on `kind`, never on `status`.** The two are not interchangeable, and the overlap is exactly where integrations break. A firewalled lane's status is `unknown`, **not** `broken` — the prober skips it rather than measuring it — so "hide broken, show the rest" puts every disabled lane back into your picker, where it will be refused the moment a user selects it. In the other direction, `not-fillable` appears under both `degraded` and `broken`. Build your allow and deny sets from `kind`.

> **Key on `laneKey`, never on a symbol pair.** A symbol does not identify a cell: most symbols exist on several chains at once, so "USDC to AUSD" names many different lanes with different verdicts. `laneKey` pins both ends to a chain and an address. Symbol casing is a second reason to avoid them — it is not uniform across the catalogue (`USDe`, `USDtb`, and the `n`- and `de`-prefixed tokenized assets are not upper-case), so an exact comparison against a hardcoded upper-case symbol misses them.

### Copy this

Everything above, as the function most integrations end up writing anyway.

```jsx
// Lanes that should never reach a user. 'value-floor' is deliberately absent:
// it is a size problem, not a broken lane.
const HIDE = new Set([
  'firewalled', 'not-fillable', 'lane-gap',
  'bridge-throttled', 'persistent-upstream', 'vault-closed',
]);

const cell = (t) => t.chainId + ':' + t.address.toLowerCase();
const laneKey = (from, to) => cell(from) + '>' + cell(to);
const sameCell = (from, to) => cell(from) === cell(to);

// On failure the envelope carries 'error', not 'data'. Check the status first, or a bad
// key surfaces as "data is not iterable" three frames away from the cause. These endpoints
// are rate limited per key, so honour 429 and its Retry-After header.
async function get(base, path, headers) {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(base + path, { headers });
    if (res.status === 429 && attempt < 5) {
      const retryAfter = Number(res.headers.get('Retry-After')) || 1;
      await new Promise((r) => setTimeout(r, retryAfter * 1000 * (attempt + 1)));
      continue;
    }
    const body = await res.json();
    if (!res.ok) {
      const err = (body && body.error) || {};
      throw new Error(path + ' failed: HTTP ' + res.status + ' ' + err.code + ' ' + err.message);
    }
    return body;
  }
}

// List endpoints page at 100 by default, and a truncated page is indistinguishable from a
// complete one. Page until the rows you hold match meta.totalCount.
async function pageAll(base, path, headers) {
  const out = [];
  for (let offset = 0; ; offset += 500) {
    const body = await get(base, path + '?limit=500&offset=' + offset, headers);
    out.push(...body.data);
    if (body.data.length === 0) return out;
    const total = body.meta && body.meta.totalCount;
    if (total != null ? out.length >= total : body.data.length < 500) return out;
  }
}

export async function loadCatalogue(base, apiKey) {
  const headers = { 'X-API-Key': apiKey, Accept: 'application/json' };
  const [tokens, health] = await Promise.all([
    pageAll(base, '/api/v1/tokens', headers),
    get(base, '/api/v1/route-health?scope=all', headers),
  ]);

  const hidden = new Map();
  for (const lane of health.data.lanes) {
    if (HIDE.has(lane.kind)) hidden.set(lane.laneKey, lane.message || lane.kind);
  }

  return {
    // Every (token, chain) cell you may offer.
    cells: tokens.filter((t) => t.isEnabled),
    // Refresh when the next sweep is due, not on a timer of your own.
    refreshAt: health.data.nextSweepDueAt,
    canTrade: (from, to) => !sameCell(from, to) && !hidden.has(laneKey(from, to)),
    whyNot: (from, to) => hidden.get(laneKey(from, to)),
  };
}
```

`canTrade` and `whyNot` take two cells from `cells`, not symbols. `base` is the environment base URL you were given at onboarding — a `dev` key is not valid against `prod`.

> **One pair is never tradeable and needs no measurement.** The same token on the same chain is rejected with `400 BAD_REQUEST` — there is nothing to bridge or swap. That is what `sameCell` guards. The same token across *different* chains is fine: it is quoted as a direct bridge (`isDirect: true`), with no liquidity-provider swap and no solver fill, and it is not cancellable once submitted.

### Health narrows the menu; the quote decides the trade

Use `/route-health` to decide what appears in your interface, and `/quote` to decide whether a specific trade proceeds. They are not redundant, and the quote is not a substitute for the health filter.

> **A served quote is not proof a lane can be filled.** `/quote` refuses the cases it can see at pricing time: a closed vault, a throttled bridge, a disabled lane, a lane measured broken while enforcement is on. It cannot refuse what it cannot see — a `degraded` lane marked `not-fillable` prices cleanly and still fails to fill. If you skip the health filter and rely on the quote alone, you will publish lanes that quote and never settle.

The reverse also holds. Health is measured at a probe size, seconds to minutes ago; the quote is priced for *your* size, now. A lane that passes the filter can still return `422` — `BELOW_MIN_TRADE_SIZE`, `COMPLIANCE_BLOCKED`, or no acceptable price at your amount. Surface the error rather than treating it as a catalogue bug.

### Refreshing

* **The health data moves continuously.** The prober re-measures lanes all the time, so `summary`, `measuredAt` and individual verdicts change between two calls seconds apart. Treat every read as a snapshot, not as a stable list.
* `nextSweepDueAt` is a sensible poll interval — roughly how long until the next full pass lands. Refreshing much faster than that mostly re-reads the same verdicts.
* `/chains` and `/tokens` change on onboarding, not on a schedule. Cache them for hours.
* `mode` tells you whether measurement is being enforced. Under `enforce`, lanes measured broken are already withheld from quoting, so your filter and the gateway agree.

Once you know what you can offer, continue with [Request a quote](/zero-delta/integration-guide.md). Field-level reference for every endpoint above is in the [API reference](/zero-delta/integration-guide/api.md).
