Real time (WebSocket)
A single WebSocket connection can carry several subscriptions. For each one, you first receive the current state, then every change, in order. After a disconnect, you resume exactly where you left off.
The TypeScript SDK handles everything on this page for you: initial state, resuming, duplicates and reconnection. Read on if you are writing your own client, or to understand what happens under the hood.
Connecting
wss://stream.fathomcharts.com/v1- From a server: add the
Authorization: Bearer <your key>header when opening the connection. - From a browser: open the connection without headers, then send a stream token in an
authmessage, first, within 5 seconds.
Every message, in both directions, is a JSON object whose t field gives its type.
Subscribing
{
"t": "subscribe",
"sub": "bt",
"instrument": "NQ.front",
"indicator": "big-trades",
"params": { "minimum": 30 },
"mode": "live",
"from": "live"
}| Field | Purpose |
|---|---|
sub | Name you give the subscription (1 to 64 bytes), unique on the connection. Every message about it carries this name. |
instrument | A specific contract (NQZ6) or the active contract (NQ.front). Full list: GET /v1/instruments. |
indicator | The indicator's ID in the catalog. |
params | The indicator's parameters. Any you leave out take their default value. |
mode | live (default): objects in progress and completed. confirmed: completed objects only. |
from | "live" (default): from now on. {"cursor": "…"}: resume after a disconnect. {"time": "…"}: start from a past point in time. |
To stop: {"t":"unsubscribe","sub":"bt"}. No confirmation is sent; ignore any messages for that subscription still in flight.
If the subscription is rejected, you receive an error message carrying its sub. The connection and your other subscriptions carry on unaffected.
{"sub":"bt","t":"error","code":"INVALID_PARAMETERS","message":"params.minimum: must be between 1 and 1000000"}Most common causes: an invalid parameter or a sub name already in use (INVALID_PARAMETERS), an unknown indicator or instrument (UNKNOWN_INDICATOR, UNKNOWN_INSTRUMENT), a missing scope (FORBIDDEN), a limit of your offer reached (QUOTA_EXCEEDED). Every cause is listed on the Errors page. An error can also arrive later on an already accepted subscription: the subscription is then closed.
What you receive
Every subscription always starts the same way:
subscribed: the subscription is accepted;snapshot: the starting state, meaning the objects in progress and the most recent completed objects (up to 32);status: the stream status (live, market closed…);- then an
upsertor aremovefor every change, in order.
A real example: the one-minute footprint (timeframe: 60, groupTicks: 4) on NQZ6, in confirmed mode, subscribed just before the open on September 24, 2026. The snapshot is empty, then each bar arrives when it closes (here the 13:33 UTC bar):
{"sub":"fp","t":"subscribed","topic":"77ca0a9221a93f7eadd15e77f62cec69","cursor":"20720.111120.0"}
{"sub":"fp","t":"snapshot","cursor":"20720.111120.0","items":[]}
{"sub":"fp","t":"status","state":"live","lagMs":4}
{"sub":"fp","cursor":"20720.124235.0","t":"upsert","id":"1790256780000000000","final":true,"ts":"1790256840033077853","data":{"levels":[[122008,14,3],[122012,44,31],[122016,31,37],[122020,16,32],[122024,48,24],[122028,26,24],[122032,40,28],[122036,24,75],[122040,35,46],[122044,14,32],[122048,12,14],[122052,12,9],[122056,20,9],[122060,36,38],[122064,61,58],[122068,29,68],[122072,28,37],[122076,19,20],[122080,30,46],[122084,32,29],[122088,39,42],[122092,87,67],[122096,154,125],[122100,151,163],[122104,149,192],[122108,66,99],[122112,50,81],[122116,22,38],[122120,27,42],[122124,39,72],[122128,10,25],[122132,0,8]],"poc":122104}}In live mode, the snapshot would contain the bar in progress (final: false), and you would receive a new version of that bar on every trade.
To keep your state in sync:
- on
snapshot, replace your entire local state for this subscription with itsitems; - on each
upsert, replace objectidwith the one received, or add it. Anupsertalways contains the complete object: a footprint bar carries all its levels, not just the ones that changed; - on each
remove, delete objectid; - a
final: falseobject can still change; afinal: trueobject is complete.
If the requested computation is not ready yet (a configuration nobody was using, for example), you receive a warming status right after subscribed, and the snapshot arrives as soon as the computation has caught up. Nothing is sent until it is exact.
Message reference
Messages you send:
Type (t) | Fields | Purpose |
|---|---|---|
auth | token | Authenticates a connection opened from a browser, with a stream token. Must be the first message, within 5 s. |
subscribe | sub, instrument, indicator, params, mode, from | Opens a subscription. |
unsubscribe | sub | Closes a subscription. |
ping | — | The server answers pong. Optional. |
Messages you receive:
Type (t) | Fields | Purpose |
|---|---|---|
subscribed | sub, topic, cursor | Subscription accepted. topic identifies the stream you follow: two identical subscriptions share it. |
snapshot | sub, cursor, items | Starting state: the existing objects, each with id, final, ts and data. |
upsert | sub, cursor, id, final, ts, data | Creates object id, or replaces it entirely. |
remove | sub, cursor, id, final, ts | Deletes object id. |
status | sub, state, lagMs | Stream state: live, warming up, source delayed or down, market closed. |
reset | sub, reason | Your local state is no longer valid: discard it, a new snapshot follows. |
error | sub, code, message | A subscription was refused or stopped (with its sub), or a message could not be understood (no sub). |
hb | cursors | Heartbeat every 5 s, with the last cursor of each subscription. |
notice | kind | Announces imminent maintenance (kind: "reconnect"). |
pong | — | Answer to ping. |
In messages, times (ts, etc.) are timestamps in nanoseconds since January 1, 1970 (UTC), sent as strings so no precision is lost. Prices are in ticks; averages (such as a VWAP) can have decimals.
Cursors and resuming
Every snapshot, upsert and remove carries a cursor, such as 20720.111434.0: its position in the stream. Cursors never go backwards. They are the same for every client, live and in history.
For each subscription, keep the last cursor you received. The hb heartbeat, sent every 5 seconds, moves it forward even when nothing happens:
{"t":"hb","cursors":{"bt":"20720.112164.0","fp":"20720.124235.0"}}After a disconnect, open a new connection and send the same subscribe with that cursor:
{"t":"subscribe","sub":"bt","instrument":"NQ.front","indicator":"big-trades","params":{"minimum":30},"mode":"confirmed","from":{"cursor":"20720.112164.0"}}- Short disconnect (up to about 15 minutes, less in a very busy market): you receive
subscribed, then everything you missed, then astatus. No snapshot: keep your local state as it is. - Disconnect too long: you receive a
reset(reasoncursor_expired) followed by a new snapshot. Start over from that snapshot.
After resuming, you may receive a message you already processed. Ignore any upsert or remove whose cursor is less than or equal to the last one you processed.
To compare two cursors, compare their three numbers one by one, as integers (the SDK provides compareCursors). Never compare them as strings (9 would sort after 10), and never build them yourself: only use cursors you received.
Starting in the past
With from: {"time": "<time>"}, you receive the state of the objects at that time, then everything that happened since, and the stream carries on live without a gap. It is the simplest way to fill a chart with recent history before following the market.
{"t":"subscribe","sub":"bt","instrument":"NQ.front","indicator":"big-trades","params":{"minimum":30},"from":{"time":"1790256600000000000"}}- This reads from history: it requires the Historical offer, or the Sandbox over its last 7 days. Otherwise the subscription is rejected with
FORBIDDEN. - The past part counts against your compute budget, like a historical query (see Limits). If its estimated cost exceeds what you have left, the subscription receives a
QUOTA_EXCEEDEDerror and is closed. - With delayed data, the stream joins the delayed live stream: the requested time must be more than 10 minutes in the past.
- History is sent at the pace you read it.
Stream status
A status message follows every snapshot and every resume, then every change of state. Only the latest one counts.
{"sub":"bt","t":"status","state":"live","lagMs":4}state | Meaning |
|---|---|
live | The stream is following the market. lagMs gives the estimated processing latency, in milliseconds (not counting the 10-minute delay of delayed data). |
warming | The computation is getting ready. Nothing is sent until it is exact. |
source_delayed | Market data has stopped arriving for now. Reconnecting; nothing is lost. |
source_down | Market data interrupted for more than 30 seconds. No data is ever made up; the stream will resume where it stopped. |
market_closed | The market is closed (daily break, weekend). |
Reset
A reset means your local state for this subscription is no longer valid. Discard it and forget your last cursor: a new snapshot follows and becomes your new starting point.
{"sub":"bt","t":"reset","reason":"roll"}reason | Cause |
|---|---|
roll | NQ.front now follows a new contract. A new subscribed arrives before the snapshot. |
cursor_expired | Your resume cursor is too old to be served, or the server could not bridge a gap on its side. |
entitlement_changed | Your access switched from delayed to real time, or back (start or end of the Live offer). A new subscribed arrives before the snapshot. |
engine_change | A new version of the computation, announced in the changelog. |
Keeping the connection alive
The server sends an hb every 5 seconds, even with no subscription. If you receive nothing for 15 seconds, treat the connection as lost and reconnect.
In the other direction, the server sends WebSocket pings regularly. Browsers and common WebSocket libraries answer them automatically, as long as your program keeps reading the connection. If the server receives nothing from you for 30 seconds, it closes the connection (code 4008).
Backpressure
If your program reads messages more slowly than they arrive, the server queues them, up to a limit:
- beyond 256 KB queued, the server only sends the latest version of each object in progress. Since every
upsertcontains the complete object, your state stays exact. Completed objects and removals are never skipped; - beyond 1 MB, the connection is closed (code
4008). Reconnect and resume from your last cursor: you will receive what was missing.
To avoid this, read the connection continuously and process messages separately (a queue, a worker) rather than blocking the read loop. A slow client never slows down anyone else.
Closing and reconnecting
Before maintenance, the server sends {"t":"notice","kind":"reconnect"}, then closes the connection within 20 seconds (code 1012). Keep reading until the connection closes, then reconnect and resume from your last cursor: you lose nothing.
| Code | Name | Cause | What to do |
|---|---|---|---|
4001 | UNAUTHORIZED | The key or token is invalid, expired or already used, or the auth message is missing or came too late. | Do not reconnect until the key or token is fixed. |
4003 | FORBIDDEN | Missing rights: the key lacks the stream scope, the IP address is not allowed, the key was revoked, or your new plan no longer covers your open subscriptions. | Do not reconnect with this key until its rights are fixed. |
4008 | SLOW_CONSUMER | Your client stopped reading fast enough (over 1 MB pending), or has been silent for 30 s (reason PING_TIMEOUT). | Reconnect right away and resume from the last cursor you received. |
4029 | TOO_MANY_CONNECTIONS | You reached the maximum number of concurrent connections of your plan, across all your keys. | Close another connection, or group your subscriptions on a single one. |
1012 | SERVICE_RESTART | Maintenance or a service update, announced by a notice message. | Reconnect and resume from the last cursor you received. |
1013 | TRY_AGAIN_LATER | The service is temporarily at capacity, or the session expired on the server. | Reconnect with exponential backoff. |
1011 | INTERNAL_ERROR | Internal error while opening the connection. | Reconnect with exponential backoff. |
Reconnection rules:
- on
4001or4003, do not reconnect: fix the key or its scopes first; - on
4029, close another connection before trying again; - on
4008, reconnect immediately; - in every other case (network drop,
1011,1012,1013, 15 seconds without a message), reconnect with a delay that doubles after each failure, up to 30 seconds, plus random jitter so that clients don't all come back at once. Reset to the initial delay as soon as a subscription is accepted; - on every reconnection, resend all your
subscribemessages withfrom: {"cursor": …}. From a browser, request a new stream token.
The SDK applies these rules. On 4001 and 4003, it ends the subscriptions with a FathomChartsError (code UNAUTHORIZED or FORBIDDEN).
Full example
A Node 22 client without the SDK: per-subscription state, cursor-based resume, duplicate filtering and reconnection.
// Node 22+: global WebSocket (undici) accepts request headers.
const URL = 'wss://stream.fathomcharts.com/v1';
const KEY = process.env.FATHOM_CHARTS_API_KEY!;
type Item = { id: string; final: boolean; ts: string; data: unknown };
type Sub = { request: Record<string, unknown>; cursor?: string; objects: Map<string, Item> };
const subs = new Map<string, Sub>([
['bt', {
request: { instrument: 'NQ.front', indicator: 'big-trades', params: { minimum: 30 }, mode: 'live' },
objects: new Map(),
}],
]);
const FATAL = new Set([4001, 4003]);
let attempt = 0;
function connect() {
const ws = new WebSocket(URL, { headers: { Authorization: `Bearer ${KEY}` } });
ws.onopen = () => {
for (const [sub, s] of subs) {
const from = s.cursor ? { cursor: s.cursor } : 'live';
ws.send(JSON.stringify({ t: 'subscribe', sub, ...s.request, from }));
}
};
ws.onmessage = (event) => {
const m = JSON.parse(String(event.data));
if (m.t === 'hb') {
for (const [sub, cursor] of Object.entries(m.cursors as Record<string, string>)) {
const s = subs.get(sub);
if (s && (s.cursor === undefined || compareCursors(cursor, s.cursor) > 0)) s.cursor = cursor;
}
return;
}
const s = m.sub === undefined ? undefined : subs.get(m.sub);
if (!s) return;
switch (m.t) {
case 'subscribed':
attempt = 0;
break;
case 'snapshot':
s.objects = new Map(m.items.map((i: Item) => [i.id, i]));
s.cursor = m.cursor;
break;
case 'upsert':
case 'remove':
// Equal-or-older cursors were already applied (duplicates after a resume).
if (s.cursor !== undefined && compareCursors(m.cursor, s.cursor) <= 0) return;
if (m.t === 'upsert') s.objects.set(m.id, { id: m.id, final: m.final, ts: m.ts, data: m.data });
else s.objects.delete(m.id);
s.cursor = m.cursor;
if (m.t === 'upsert' && m.final) handle(m.sub, m.data);
break;
case 'reset':
// A snapshot follows and sets the new reference: drop local state and cursor.
s.objects.clear();
s.cursor = undefined;
break;
case 'error':
// The server has closed this subscription: do not resubscribe it.
subs.delete(m.sub);
console.error(m.sub, m.code, m.message);
break;
}
};
ws.onclose = (event) => {
if (FATAL.has(event.code)) {
console.error('closed', event.code, event.reason);
return;
}
const ceiling = Math.min(30_000, 250 * 2 ** attempt++);
setTimeout(connect, event.code === 4008 ? 0 : ceiling / 2 + Math.random() * (ceiling / 2));
};
}
// Total order of cursors <utcDay>.<rank>.<k>: numeric comparison of the three fields.
function compareCursors(a: string, b: string): number {
const x = a.split('.').map(BigInt), y = b.split('.').map(BigInt);
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;
return 0;
}
function handle(sub: string, data: unknown) {
console.log(sub, data);
}
connect();