CRIUS
Trading terminalWebSocketChartsTutorial

How to Build a Real-Time Robinhood Chain Trading Terminal

Build a Robinhood Chain trading terminal: REST bootstrap, WebSocket updates, a live candle chart, trade tape and risk panels, with working code.

Crius 7 min read

Short answer: load the token’s profile, recent candles and recent trades over REST, then subscribe to candles:<token>:1m, trades:<token> and price:<token> on the Crius WebSocket and apply updates on top of that snapshot. The one subtle part is the hand-over: skip stream messages for blocks your snapshot already includes, and replace the last candle when an update has the same timestamp.

This guide builds the data layer of a single-token terminal (chart, price, trade tape, pools and risk panel) and shows how to render the chart with TradingView Lightweight Charts. The data code runs as-is in Node.js 22+ and in the browser; we ran it against the live API in October 2026.

What a terminal needs

Panel Source Refresh
Header: price, 24h change, market cap, liquidity GET /token/{address} price: stream
Chart: candles and volume GET /token/{address}/candles candles:<token>:1m stream
Trade tape GET /token/{address}/trades trades:<token> stream
Pools: where it trades GET /token/{address}/pools every minute
Risk: tax, honeypot, LP locks GET /token/{address}/security every few minutes
Holders: concentration GET /token/{address}/holders every few minutes

The pattern is the same for every panel: snapshot over REST, then stream deltas. REST gives you a consistent starting point at a known block; the stream keeps it current without polling.

Step 1: bootstrap over REST

Fire the snapshot requests in parallel. Each response’s meta.block tells you which block it reflects:

const API = "https://api.crius.sh/v1/robinhood";
const KEY = process.env.CRIUS_API_KEY;
const TOKEN = "0xc9675fe679c7d4e1b94416f66d61c96127eb964a";
const get = async (p) => {
const r = await fetch(`${API}${p}`, { headers: { "x-api-key": KEY } });
if (!r.ok) throw new Error(`${p}: ${r.status}`);
return r.json();
};
const [profile, candles, trades] = await Promise.all([
get(`/token/${TOKEN}`),
get(`/token/${TOKEN}/candles?interval=1m&limit=120&fill=true`),
get(`/token/${TOKEN}/trades?limit=20`),
]);
const state = {
symbol: profile.data.symbol,
price: profile.data.price?.usd,
bars: candles.data.t.map((t, i) => ({
t, o: candles.data.o[i], h: candles.data.h[i], l: candles.data.l[i], c: candles.data.c[i], v: candles.data.v[i],
})),
tape: trades.data,
asOfBlock: candles.meta.block,
};

Our run printed:

GIGATENEV: 120 bars, 20 trades, as of block 96714921, price 0.00116554

fill=true matters for a terminal: it turns minutes without trades into flat candles, so the chart’s time axis is evenly spaced instead of jumping over quiet periods.

Step 2: apply the stream on top

const ws = new WebSocket(`wss://api.crius.sh/v1/robinhood/stream?api_key=${KEY}`);
ws.onopen = () => ws.send(JSON.stringify({
op: "subscribe",
channels: [`candles:${TOKEN}:1m`, `trades:${TOKEN}`, `price:${TOKEN}`],
}));
ws.onmessage = (ev) => {
const m = JSON.parse(ev.data);
if (m.block && m.block <= state.asOfBlock) return; // already in the REST snapshot
if (m.ch.startsWith("candles:")) {
const last = state.bars.at(-1);
if (last && last.t === m.data.t) Object.assign(last, m.data); // same minute: update in place
else state.bars.push(m.data); // new minute: append
render.chart(state.bars);
} else if (m.ch.startsWith("trades:") && !m.data.removed) {
state.tape.unshift(m.data);
state.tape.length = Math.min(state.tape.length, 50);
render.tape(state.tape);
} else if (m.ch.startsWith("price:")) {
state.price = m.data.usd;
render.header(state);
}
};

Two rules make the hand-over exact:

  1. Skip what the snapshot already has. Anything at or below asOfBlock was in the REST response. Without this check, the trades that landed between your REST call and the subscription appear twice in the tape.
  2. Replace, don’t append, the current candle. A live candle message describes the whole current bucket, not a delta. If its t equals your last bar’s t, overwrite that bar.

In our 20-second test the terminal received 8 candle updates and 8 trades. In a separate check we compared the last streamed 1-minute candle with the REST candle for the same minute: open, high, low, close, volume and trade count were identical.

Step 3: render the chart

TradingView Lightweight Charts is a good fit: small, fast and designed for exactly this update pattern.

import { createChart, CandlestickSeries, HistogramSeries } from "lightweight-charts";
const chart = createChart(document.getElementById("chart"), { autoSize: true, timeScale: { timeVisible: true } });
const candles = chart.addSeries(CandlestickSeries);
const volume = chart.addSeries(HistogramSeries, { priceScaleId: "vol", priceFormat: { type: "volume" } });
chart.priceScale("vol").applyOptions({ scaleMargins: { top: 0.8, bottom: 0 } });
const toBar = (b) => ({ time: b.t / 1000, open: b.o, high: b.h, low: b.l, close: b.c });
const toVol = (b) => ({ time: b.t / 1000, value: b.v, color: b.c >= b.o ? "#16a34a66" : "#dc262666" });
const render = {
chart(bars) {
if (!render.loaded) { // first call: full history
candles.setData(bars.map(toBar));
volume.setData(bars.map(toVol));
render.loaded = true;
} else { // afterwards: only the last bar
candles.update(toBar(bars.at(-1)));
volume.update(toVol(bars.at(-1)));
}
},
tape(trades) { /* render the list */ },
header(s) { document.title = `${s.symbol} $${s.price}`; },
};
render.chart(state.bars);

series.update() with the same time replaces the last bar, matching Step 2. Microcap prices need more precision than the default two decimals:

const prec = Math.max(2, Math.min(12, 3 - Math.floor(Math.log10(state.price))));
candles.applyOptions({ priceFormat: { type: "price", precision: prec, minMove: 10 ** -prec } });

A full working page with interval switching lives in the chart example.

Step 4: the slower panels

Pools, security and holders change slowly and some are heavier to compute. Poll them on a timer rather than streaming:

async function refreshSlow() {
const [pools, security] = await Promise.all([
get(`/token/${TOKEN}/pools?limit=10`),
get(`/token/${TOKEN}/security`),
]);
render.pools(pools.data.map((p) => ({ dex: p.dex, quote: p.quote.symbol, liquidity: p.liquidity_usd, price: p.price_usd })));
render.risk({
risk: security.data.risk,
honeypot: security.data.honeypot.is_honeypot,
sellTax: security.data.tax.sell_pct,
lp: security.data.liquidity.status,
flags: security.data.flags,
});
}
refreshSlow();
setInterval(refreshSlow, 60_000);

For the token in our test, the risk panel showed risk: low, no honeypot, a measured sell tax of 0% across 90,553 sells, and one flag, lp_unlocked: the main pool’s liquidity sits in wallets, not in a locker. That flag deserves a prominent badge, because unlocked liquidity can be pulled.

Heavy endpoints (security, holder snapshots, traders) return 503 with Retry-After when the server is busy. Keep their concurrency low, two or three at a time, and retry after the stated delay.

Step 5: reconnects and reorgs

A terminal stays open for hours, so connections will drop. Reuse the reconnect logic from the WebSocket API guide: keep the last seq per channel, send resume after reconnecting, and treat silence longer than 45 seconds as a dead connection. If the gap was longer than the 60-second replay window, rerun Step 1.

On a reorg message, drop trades from the removed blocks from the tape, and refetch the last few candles over REST rather than trying to reverse the candle arithmetic. It is a single request and always correct.

Keeping the UI fast

A busy token produces several trades per second, and Robinhood Chain produces a block about every 108 ms. Rendering on every message wastes work and makes the page stutter. Three techniques keep a terminal smooth:

  • Coalesce renders. Apply messages to state immediately, but redraw at most once per animation frame. A flag set in the message handler and a requestAnimationFrame callback that clears it is enough; the chart then updates up to 60 times a second however many messages arrive.
  • Cap the tape. Keep the newest 50 to 100 trades in memory and render only the visible rows. An unbounded array grows by tens of thousands of entries a day on an active token.
  • Format once. Number formatting (Intl.NumberFormat) is surprisingly expensive; create the formatters once and reuse them.
let dirty = false;
const schedule = () => { if (!dirty) { dirty = true; requestAnimationFrame(() => { dirty = false; render.all(state); }); } };
// in ws.onmessage: update state, then call schedule() instead of rendering directly

On the server side, nothing changes: a single connection carries every channel the terminal needs.

Multiple tokens and watchlists

A watchlist of 50 tokens doesn’t need 50 candle subscriptions. Use POST /prices with up to 100 tokens every few seconds for the list view, and open the full set of streams only for the token in focus. A Free key allows 10 subscriptions per connection; Pro allows 1,000.

const res = await fetch(`${API}/prices`, {
method: "POST",
headers: { "x-api-key": KEY, "content-type": "application/json" },
body: JSON.stringify({ tokens: watchlist }),
});
const { data } = await res.json(); // { [address]: { usd, change_24h_pct, liquidity_usd, confidence, … } }

Frequently asked questions

How current is the terminal? Stream messages are sent as soon as Crius applies a block, so the chart and tape lag the chain by a fraction of a second. Robinhood Chain produces a block about every 108 ms.

Can I build this entirely in the browser? The REST part, yes: create a key restricted to your site’s origin in the dashboard. Origin-restricted keys are read-only by design, so they can’t open the stream; for live updates in a public web app, run a small relay on your server that holds a stream key and forwards the messages to your users.

How do I show prices in ETH instead of USD? Fetch candles with currency=quote for the history, and use the eth field of price: messages for the header.

What does running a terminal cost in credits? The bootstrap is three requests (5 credits in total), the stream connection is 1 credit and stream messages are free. Polling security and pools once a minute adds about 2,900 credits a day, well within the free plan’s 1,000,000 a month.

Next steps

Questions go to support@crius.sh.

Build with Crius

Robinhood Chain data in one API

Free plan with 1M credits a month. Your key in under a minute.

Related APIs

More from the blog