Utiliser l’API

Temps réel (WebSocket)

Une seule connexion WebSocket peut porter plusieurs subscriptions. Pour chacune, vous recevez d’abord l’état actuel (le snapshot), puis chaque changement, dans l’ordre. Après une coupure, vous reprenez exactement là où vous en étiez.

Le SDK TypeScript gère pour vous tout ce qui est décrit sur cette page : snapshot, reprise, doublons et reconnexion. Lisez la suite si vous écrivez votre propre client, ou pour comprendre ce qui se passe.

Se connecter

HTTP
wss://stream.fathomcharts.com/v1
  • Depuis un serveur : ajoutez le header Authorization: Bearer <votre clé> à l’ouverture.
  • Depuis un navigateur : ouvrez la connexion sans header, puis envoyez un stream token dans un message auth, en premier, dans les 5 secondes.

Tous les messages, dans les deux sens, sont des objets JSON dont le champ t donne le type.

S’abonner

JSON
{
  "t": "subscribe",
  "sub": "bt",
  "instrument": "NQ.front",
  "indicator": "big-trades",
  "params": { "minimum": 30 },
  "mode": "live",
  "from": "live"
}
ChampRôle
subNom que vous donnez à la subscription (1 à 64 bytes), unique sur la connexion. Chaque message qui le concerne le reprend.
instrumentContrat précis (NQZ6) ou contrat front month (NQ.front). Liste complète : GET /v1/instruments.
indicatorIdentifiant de l’indicateur dans le catalogue.
paramsParamètres de l’indicateur. Ceux que vous omettez prennent leur valeur par défaut.
modelive (par défaut) : les objets en cours et terminés. confirmed : seulement les objets terminés.
from"live" (par défaut) : à partir de maintenant. {"cursor": "…"} : reprise après une coupure. {"time": "…"} : à partir d’un instant passé.

Pour arrêter : {"t":"unsubscribe","sub":"bt"}. Aucune confirmation n’est envoyée ; ignorez les messages de cette subscription encore en route.

Si la subscription est refusée, vous recevez un message error qui porte son sub. La connexion et vos autres subscriptions continuent normalement.

JSON
{"sub":"bt","t":"error","code":"INVALID_PARAMETERS","message":"params.minimum: must be between 1 and 1000000"}

Causes les plus fréquentes : paramètre invalide ou nom sub déjà pris (INVALID_PARAMETERS), indicateur ou instrument inconnu (UNKNOWN_INDICATOR, UNKNOWN_INSTRUMENT), droit manquant (FORBIDDEN), limite de votre offre atteinte (QUOTA_EXCEEDED). Toutes les causes sont sur la page Erreurs. Une erreur peut aussi survenir plus tard sur une subscription déjà acceptée : elle est alors fermée.

Ce que vous recevez

Chaque subscription commence toujours de la même façon :

  1. subscribed : la subscription est acceptée ;
  2. snapshot : l’état de départ, c’est-à-dire les objets en cours et les derniers objets terminés (32 au plus) ;
  3. status : l’état du stream (en direct, marché fermé…) ;
  4. puis un upsert ou un remove à chaque changement, dans l’ordre.

Exemple réel : le footprint en barres d’une minute (timeframe: 60, groupTicks: 4) sur NQZ6, en mode confirmed, abonné juste avant l’ouverture du 24 septembre 2026. Le snapshot est vide, puis chaque barre arrive à sa clôture (ici celle de 13:33 UTC) :

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}}

En mode live, le snapshot contiendrait la barre en cours (final: false), et vous recevriez une nouvelle version de cette barre à chaque trade.

Pour tenir votre état à jour :

  • à la réception du snapshot, remplacez tout votre état local pour cette subscription par ses items ;
  • à chaque upsert, remplacez l’objet id par celui reçu, ou ajoutez-le. Un upsert contient toujours l’objet complet : une barre footprint contient tous ses niveaux, pas seulement ceux qui ont changé ;
  • à chaque remove, supprimez l’objet id ;
  • un objet final: false peut encore changer ; un objet final: true est terminé.

Si le calcul demandé n’est pas encore prêt (une configuration que personne n’utilisait, par exemple), vous recevez un status warming juste après subscribed, et le snapshot arrive dès que le calcul est à jour. Rien n’est envoyé tant qu’il n’est pas exact.

Liste des messages

Messages que vous envoyez :

Type (t)ChampsRôle
authtokenAuthentifie une connexion ouverte depuis un navigateur, avec un stream token. Premier message, dans les 5 s.
subscribesub, instrument, indicator, params, mode, fromOuvre une subscription.
unsubscribesubFerme une subscription.
ping—Le serveur répond pong. Facultatif.

Messages que vous recevez :

Type (t)ChampsRôle
subscribedsub, topic, cursorSubscription acceptée. topic identifie le stream suivi : deux subscriptions identiques ont le même.
snapshotsub, cursor, itemsÉtat de départ : les objets existants, chacun avec id, final, ts et data.
upsertsub, cursor, id, final, ts, dataCrée l’objet id, ou le remplace entièrement.
removesub, cursor, id, final, tsSupprime l’objet id.
statussub, state, lagMsÉtat du stream : en direct (live), en préparation (warming), source retardée ou interrompue, marché fermé.
resetsub, reasonVotre état local n’est plus valable : jetez-le, un nouveau snapshot suit.
errorsub, code, messageSubscription refusée ou interrompue (avec son sub), ou message incompréhensible (sans sub).
hbcursorsHeartbeat toutes les 5 s, avec le dernier cursor de chaque subscription.
noticekindAnnonce une maintenance imminente (kind: "reconnect").
pong—Réponse à ping.

Dans les messages, les dates (ts, etc.) sont des timestamps en nanosecondes depuis le 1er janvier 1970 (UTC), transmis sous forme de string pour ne rien perdre en précision. Les prix sont en ticks ; les moyennes (un VWAP, par exemple) peuvent avoir des décimales.

Cursors et reprise

Chaque snapshot, upsert et remove porte un cursor, par exemple 20720.111434.0 : sa position dans le stream. Les cursors ne reculent jamais. Ils sont identiques pour tous les clients, en direct comme sur l’historique.

Gardez, pour chaque subscription, le dernier cursor reçu. Le heartbeat hb, envoyé toutes les 5 secondes, le fait avancer même quand il ne se passe rien :

JSON
{"t":"hb","cursors":{"bt":"20720.112164.0","fp":"20720.124235.0"}}

Après une coupure, rouvrez une connexion et renvoyez le même subscribe avec ce cursor :

JSON
{"t":"subscribe","sub":"bt","instrument":"NQ.front","indicator":"big-trades","params":{"minimum":30},"mode":"confirmed","from":{"cursor":"20720.112164.0"}}
  • Coupure courte (jusqu’à 15 minutes environ, moins sur un marché très actif) : vous recevez subscribed, puis directement tout ce que vous avez manqué, puis un status. Pas de snapshot : gardez votre état local tel quel.
  • Coupure trop longue : vous recevez un reset (raison cursor_expired) suivi d’un nouveau snapshot. Repartez de celui-ci.

Après une reprise, un message déjà traité peut vous parvenir à nouveau. Ignorez tout upsert ou remove dont le cursor est inférieur ou égal au dernier que vous avez traité.

Pour comparer deux cursors, comparez leurs trois nombres un à un, comme des entiers (le SDK fournit compareCursors). Ne les comparez jamais comme des strings (9 passerait après 10), et ne les fabriquez jamais vous-même : utilisez ceux que vous avez reçus.

Démarrer dans le passé

Avec from: {"time": "<instant>"}, vous recevez l’état des objets à cet instant, puis tout ce qui s’est passé depuis, et le stream enchaîne en direct sans interruption. C’est le moyen le plus simple de remplir un graphique avec l’historique récent avant de suivre le marché.

JSON
{"t":"subscribe","sub":"bt","instrument":"NQ.front","indicator":"big-trades","params":{"minimum":30},"from":{"time":"1790256600000000000"}}
  • Cela passe par l’historique : il faut l’offre Historique, ou la Sandbox sur ses 7 derniers jours. Sinon, la subscription est refusée avec FORBIDDEN.
  • La partie passée est décomptée de votre compute budget, comme une requête historique (voir Limites). Si son coût estimé dépasse ce qui vous reste, la subscription reçoit une erreur QUOTA_EXCEEDED et elle est fermée.
  • Avec des données différées, le stream rejoint le direct différé : l’instant demandé doit remonter à plus de 10 minutes.
  • L’historique vous est envoyé au rythme où vous le lisez.

État du stream

Un message status suit chaque snapshot et chaque reprise, puis chaque changement d’état. Seul le dernier reçu compte.

JSON
{"sub":"bt","t":"status","state":"live","lagMs":4}
stateSignification
liveLe stream suit le marché. lagMs donne la latence de traitement estimée, en millisecondes (hors différé de 10 minutes des données différées).
warmingLe calcul se prépare. Rien n’est envoyé tant qu’il n’est pas exact.
source_delayedLes données du marché n’arrivent plus pour le moment. Reconnexion en cours, rien n’est perdu.
source_downDonnées du marché interrompues depuis plus de 30 secondes. Aucune donnée n’est inventée ; le stream reprendra là où il s’est arrêté.
market_closedLe marché est fermé (pause quotidienne, week-end).

Reset

Un reset signifie que votre état local pour cette subscription n’est plus valable. Jetez-le et oubliez votre dernier cursor : un nouveau snapshot suit et devient votre nouveau point de départ.

JSON
{"sub":"bt","t":"reset","reason":"roll"}
reasonCause
rollNQ.front suit désormais un nouveau contrat (rollover). Un nouveau subscribed arrive avant le snapshot.
cursor_expiredVotre cursor de reprise est trop ancien pour être servi, ou le serveur n’a pas pu combler une interruption de son côté.
entitlement_changedVotre accès est passé du différé au temps réel, ou l’inverse (début ou fin de l’offre Live). Un nouveau subscribed arrive avant le snapshot.
engine_changeNouvelle version du calcul, annoncée dans le changelog.

Garder la connexion vivante

Le serveur envoie un hb toutes les 5 secondes, même sans subscription. Si vous ne recevez plus rien pendant 15 secondes, considérez la connexion comme perdue et reconnectez-vous.

Dans l’autre sens, le serveur envoie régulièrement des pings WebSocket. Les navigateurs et les bibliothèques WebSocket courantes y répondent automatiquement, tant que votre programme lit la connexion. Si le serveur ne reçoit plus rien de votre part pendant 30 secondes, il ferme la connexion (close code 4008).

Backpressure

Si votre programme lit les messages moins vite qu’ils n’arrivent, le serveur les garde dans un buffer, dans une certaine limite :

  • au-delà de 256 Ko dans le buffer, le serveur n’envoie plus que la dernière version de chaque objet en cours. Comme chaque upsert contient l’objet complet, votre état reste exact. Les objets terminés et les suppressions ne sont jamais sautés ;
  • au-delà de 1 Mo, la connexion est fermée (close code 4008). Reconnectez-vous et reprenez au dernier cursor : vous recevrez ce qui manquait.

Pour l’éviter, lisez la connexion en continu et traitez les messages à part (queue, worker) plutôt que de bloquer la lecture. Un client lent ne ralentit jamais les autres.

Fermetures et reconnexion

Avant une maintenance, le serveur envoie {"t":"notice","kind":"reconnect"}, puis ferme la connexion dans les 20 secondes (close code 1012). Continuez à lire jusqu’à la fermeture, puis reconnectez-vous et reprenez au dernier cursor : vous ne perdez rien.

CodeNomCauseQue faire
4001UNAUTHORIZEDClé ou token invalide, expiré ou déjà utilisé, ou message auth absent ou trop tardif.Ne reconnectez pas tant que la clé ou le token n’est pas corrigé.
4003FORBIDDENAccès refusé : clé sans le scope stream, adresse IP non autorisée, clé révoquée, ou nouvelle offre qui ne couvre plus vos subscriptions ouvertes.Ne reconnectez pas avec cette clé avant d’avoir corrigé ses scopes ou ses restrictions.
4008SLOW_CONSUMERVotre client ne lit plus assez vite (plus de 1 Mo en attente), ou ne donne plus signe de vie depuis 30 s (raison PING_TIMEOUT).Reconnectez immédiatement et reprenez au dernier cursor reçu.
4029TOO_MANY_CONNECTIONSNombre maximal de connexions simultanées de votre offre atteint, toutes clés confondues.Fermez une autre connexion, ou regroupez vos subscriptions sur une seule.
1012SERVICE_RESTARTMaintenance ou mise à jour du service, annoncée par un message notice.Reconnectez et reprenez au dernier cursor reçu.
1013TRY_AGAIN_LATERService momentanément saturé, ou session expirée côté serveur.Reconnectez avec un backoff exponentiel.
1011INTERNAL_ERRORErreur interne à l’ouverture de la connexion.Reconnectez avec un backoff exponentiel.

Règles de reconnexion :

  • sur 4001 ou 4003, ne vous reconnectez pas : corrigez d’abord la clé ou ses droits ;
  • sur 4029, fermez une autre connexion avant de réessayer ;
  • sur 4008, reconnectez-vous immédiatement ;
  • dans tous les autres cas (coupure réseau, 1011, 1012, 1013, 15 secondes sans message), reconnectez-vous avec un backoff exponentiel plafonné à 30 secondes, avec du jitter pour que tous les clients ne reviennent pas en même temps. Revenez au délai initial dès qu’une subscription est acceptée ;
  • à chaque reconnexion, renvoyez tous vos subscribe avec from: {"cursor": …}. Depuis un navigateur, demandez un nouveau stream token.

Le SDK applique ces règles. Sur 4001 et 4003, il arrête les subscriptions avec une erreur FathomChartsError (code UNAUTHORIZED ou FORBIDDEN).

Exemple complet

Un client Node 22 sans SDK : état par subscription, reprise par cursor, doublons ignorés et reconnexion.

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