# Build a token page

> Header, chart, tape, venues, pools, holders and a trade card for one token on Arc — which calls, in what order, and what to stream.

Source: https://getchord.xyz/docs/recipes/token-page/

**Goal**: the page Chord itself shows for a token on Arc — price and change in the header, a candle chart, the trade tape, venue split, pools, holders, and a route card — built from six calls on load and two WebSocket channels after.

## On load, in parallel

| Component | Call | Read |
| --- | --- | --- |
| Header (symbol, name, logo, price, 24h change, market cap, flags) | `GET /arc/token/{address}?hours=24` | `token` — the full token row; `series` for a 24-hour volume bar |
| Windowed stats (5m / 1h / 6h / 24h, ATH, 24h range, makers) | `GET /tokens/stats/{address}` | `windows`, `ath`, `range24h`, `markets` |
| Chart | `GET /tokens/chart/{address}?tf=1h` | `candles` (200, oldest first); page back with `before` |
| Trade tape | `GET /tokens/trades/{address}?limit=50` | `trades`, `stats24h` |
| Pools | `GET /lp/token/{address}/pools?chain=5042` | pools ranked by health, with launch facts |
| Holders | `GET /arc/token/{address}/holders?limit=20` | `holders`, `concentration`, `top` (each tagged wallet / pool / burn) |

```ts
const A = "https://api.getchord.xyz/v1";
const [tok, stats, chart, tape, pools, holders] = await Promise.all([
  `${A}/arc/token/${addr}?hours=24`, `${A}/tokens/stats/${addr}`, `${A}/tokens/chart/${addr}?tf=1h`,
  `${A}/tokens/trades/${addr}?limit=50`, `${A}/lp/token/${addr}/pools?chain=5042`, `${A}/arc/token/${addr}/holders?limit=20`,
].map((u) => fetch(u).then((r) => (r.ok ? r.json() : null))));
```

Treat a `null` (a `404` or `503`) per component: a `503 HOLDERS_PENDING` means the ledger just started tracking this token — show "building" and retry in a few minutes; a `404` on the chart means the token has no indexed pool yet.

## Then stream

```ts
const ws = new WebSocket("wss://api.getchord.xyz/v1/ws/live");
ws.onopen = () => {
  ws.send(JSON.stringify({ op: "sub", channel: "candles", token: addr, tf: "1h" }));
  ws.send(JSON.stringify({ op: "sub", channel: "trades", token: addr }));
};
ws.onmessage = (e) => {
  const m = JSON.parse(e.data);
  if (m.op === "candle") chart.upsert(m.candle);          // by t; closed:true seals the bucket
  if (m.op === "trades") { tape.prepend(m.items); header.price = m.items[0].priceUsd; }
};
```

The header price is the latest print; a green dot while frames arrive is honest "live". Re-fetch `/arc/token` and `/tokens/stats` every 30–60 s for the rolled-up figures.

## Header details

- `token.listed` false: show the on-chain symbol with a "not on the list" hint; `logoURI` is a placeholder until art is found.
- `token.flags` includes `wash` or `impostor`: badge it. The boards already sort such rows down; the page should say why.
- `token.routable` false: hide the trade card, or show "no route yet".
- `marketCapUsd` is fully diluted; label it FDV.

## The trade card

Quote on the user's amount with `GET /quote?chain=5042&inputMint=0x3600…0000&outputMint={address}&amount=…`, then follow the [Swap recipe](/docs/recipes/swap/). Link the row to `https://getchord.xyz/arc/token/{address}?side=buy` if you would rather hand off to Chord.

## Venues and pools

`tok.byDex` (24-hour share per venue id) makes the venue table; map ids to names with the [venue ids](/docs/resources/venue-ids/) table. `pools` from the liquidity family carries `score` and `reasons` — show the reasons on hover, they are the difference between a pool with fifty providers and one where the launcher holds everything.

## Holders

`heldByWalletsPct` is supply outside pools and burn sinks. `concentration.top10` above ~60 % on a token with thousands of holders is worth a badge. Rows tagged `pool` are the pools themselves; `burn` is the dead address.
