Using the API

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

HTTP
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 auth message, first, within 5 seconds.

Every message, in both directions, is a JSON object whose t field gives its type.

Subscribing

JSON
{
  "t": "subscribe",
  "sub": "bt",
  "instrument": "NQ.front",
  "indicator": "big-trades",
  "params": { "minimum": 30 },
  "mode": "live",
  "from": "live"
}
FieldPurpose
subName you give the subscription (1 to 64 bytes), unique on the connection. Every message about it carries this name.
instrumentA specific contract (NQZ6) or the active contract (NQ.front). Full list: GET /v1/instruments.
indicatorThe indicator's ID in the catalog.
paramsThe indicator's parameters. Any you leave out take their default value.
modelive (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.

JSON
{"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:

  1. subscribed: the subscription is accepted;
  2. snapshot: the starting state, meaning the objects in progress and the most recent completed objects (up to 32);
  3. status: the stream status (live, market closed…);
  4. then an upsert or a remove for 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):

JSON
{"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 its items;
  • on each upsert, replace object id with the one received, or add it. An upsert always contains the complete object: a footprint bar carries all its levels, not just the ones that changed;
  • on each remove, delete object id;
  • a final: false object can still change; a final: true object 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)FieldsPurpose
authtokenAuthenticates a connection opened from a browser, with a stream token. Must be the first message, within 5 s.
subscribesub, instrument, indicator, params, mode, fromOpens a subscription.
unsubscribesubCloses a subscription.
ping—The server answers pong. Optional.

Messages you receive:

Type (t)FieldsPurpose
subscribedsub, topic, cursorSubscription accepted. topic identifies the stream you follow: two identical subscriptions share it.
snapshotsub, cursor, itemsStarting state: the existing objects, each with id, final, ts and data.
upsertsub, cursor, id, final, ts, dataCreates object id, or replaces it entirely.
removesub, cursor, id, final, tsDeletes object id.
statussub, state, lagMsStream state: live, warming up, source delayed or down, market closed.
resetsub, reasonYour local state is no longer valid: discard it, a new snapshot follows.
errorsub, code, messageA subscription was refused or stopped (with its sub), or a message could not be understood (no sub).
hbcursorsHeartbeat every 5 s, with the last cursor of each subscription.
noticekindAnnounces 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:

JSON
{"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:

JSON
{"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 a status. No snapshot: keep your local state as it is.
  • Disconnect too long: you receive a reset (reason cursor_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.

JSON
{"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_EXCEEDED error 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.

JSON
{"sub":"bt","t":"status","state":"live","lagMs":4}
stateMeaning
liveThe stream is following the market. lagMs gives the estimated processing latency, in milliseconds (not counting the 10-minute delay of delayed data).
warmingThe computation is getting ready. Nothing is sent until it is exact.
source_delayedMarket data has stopped arriving for now. Reconnecting; nothing is lost.
source_downMarket data interrupted for more than 30 seconds. No data is ever made up; the stream will resume where it stopped.
market_closedThe 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.

JSON
{"sub":"bt","t":"reset","reason":"roll"}
reasonCause
rollNQ.front now follows a new contract. A new subscribed arrives before the snapshot.
cursor_expiredYour resume cursor is too old to be served, or the server could not bridge a gap on its side.
entitlement_changedYour access switched from delayed to real time, or back (start or end of the Live offer). A new subscribed arrives before the snapshot.
engine_changeA 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 upsert contains 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.

CodeNameCauseWhat to do
4001UNAUTHORIZEDThe 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.
4003FORBIDDENMissing 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.
4008SLOW_CONSUMERYour 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.
4029TOO_MANY_CONNECTIONSYou 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.
1012SERVICE_RESTARTMaintenance or a service update, announced by a notice message.Reconnect and resume from the last cursor you received.
1013TRY_AGAIN_LATERThe service is temporarily at capacity, or the session expired on the server.Reconnect with exponential backoff.
1011INTERNAL_ERRORInternal error while opening the connection.Reconnect with exponential backoff.

Reconnection rules:

  • on 4001 or 4003, 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 subscribe messages with from: {"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.

TS
// 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();