Temps réel

Flux WebSocket en temps réel pour développeurs

Un relais. Les événements des places couvertes sont normalisés et fusionnés en une seule sortie, poussée à chaque tick de 100 ms. C’est la seule transformation effectuée : une transaction entre, une transaction sort.

Ce n’est pas une source de métriques (ni fenêtre glissante, ni compteur, ni moyenne), et il n’y a pas d’historique : vous recevez ce qui se passe à partir de votre connexion. Le passé est servi par REST, et aux agents IA par MCP.

Pour un agent IA, le canal est le serveur MCP, pas le flux : un agent IA ne garde pas de connexion ouverte, il demande quand il en a besoin. L’outil MCP get_pair_data lit à la demande le carnet d’ordres et les dernières transactions d’une paire ; le flux sert le code qui écoute en continu.

#Connexion

WSSwss://api.bytnode.com/ws?token=wst_…Transport WebSocket

Le même flux est servi sur wss://ws.bytnode.com/ws, son propre serveur : même clé, mêmes canaux, mêmes trames. Utilisez l’une ou l’autre adresse.

Depuis un serveur, la clé va dans l’en-tête X-API-KEY de la poignée de main ; depuis une page web, un jeton éphémère va dans l’URL (?token=, émis par POST /v1/ws/token : 60 secondes, une connexion). Les deux sont vérifiés à la poignée de main, avant que la connexion ne s’ouvre. C’est la même clé que pour REST et le serveur MCP (?api_key= dans l’URL reste accepté). Deux refus sont possibles à la poignée de main : clé ou jeton absent, invalide, expiré ou déjà utilisé, un 401 (X-Deny-Reason: missing_api_key / invalid_api_key, et WWW-Authenticate comme en REST) ; offre sans le flux, ce qui est le cas de Free, un 403 (X-Deny-Reason: plan_websocket). Un 503 à la poignée de main dit autre chose : le service de flux a atteint sa capacité, tous comptes confondus ; réessayez un peu plus tard. Une fois ouverte, la connexion de trop par rapport au plafond de l’offre est fermée avec le code 4003.

La session s’ouvre donc déjà authentifiée : le premier message peut être l’abonnement. L’ancienne trame op: auth reste acceptée pour les clients écrits avant la vérification à la poignée de main : sur une connexion déjà authentifiée, elle est ignorée et répond {"op": "authenticated", "authenticated_by": "handshake"}, quelle que soit la clé qu’elle porte.

{"op": "auth", "api_key": "your_key"}

#S’abonner

{
  "op": "subscribe",
  "channels": ["trades.futures.BTCUSDT", "orderbook.futures.BTCUSDT"],
  "depth": 5
}

Le désabonnement est symétrique : op: unsubscribe, réponse op: unsubscribed.

  • La réponse liste tous les canaux actifs de la connexion, triés, pas seulement ceux de la requête. C’est un état, pas un accusé de réception.
  • depth s’applique à la connexion, pas à un canal. Chaque subscribe réinitialise la profondeur de tous les carnets d’ordres de la session ; omis, il retombe à 5. C’est le nombre de niveaux de prix par côté (achats et ventes) ; valeurs acceptées : 1 à 5.
  • L’abonnement est tout ou rien. Si un seul canal de la liste est invalide, aucun n’est appliqué et l’erreur dit pourquoi. Un abonnement partiel laisserait croire que vous recevez ce que vous avez demandé. Les noms de canaux sont insensibles à la casse : trades.futures.btcusdt revient sous la forme trades.futures.BTCUSDT.
  • Dès qu’un abonnement à un carnet d’ordres est accepté, l’état courant est envoyé immédiatement, sans attendre le tick suivant.

mode, facultatif sur subscribe : frame (par défaut) envoie un message par trame, comme décrit ci-dessous ; batch envoie, à chaque tick, toutes les trames en attente dans UN message {"op": "batch", "frames": [...]} — moins de messages, les mêmes trames dedans. Un client en mode batch doit lire frames au lieu d’ignorer tout message qui porte op. Comme depth, il s’applique à la connexion : chaque subscribe le réinitialise, et omis il revient à frame. La description machine du flux (canaux, trames, messages de contrôle, codes de fermeture) est le contrat AsyncAPI.

#Les cinq canaux

CanalNature et cadence
trades.futures.<SYMBOL>Événements. Au plus un paquet par tick de 100 ms, absent s’il n’y a rien à dire. Sous charge, les paquets s’espacent, parfois de plus d’une seconde : un silence de quelques secondes n’est pas une panne.
trades.spot.<SYMBOL>Événements, même cadence.
liquidations.<SYMBOL>Événements, même cadence.
orderbook.spot.<SYMBOL>État : le carnet d’ordres spot, consolidé sur les seuls carnets spot cotés en USDT (une place qui ne cote l’actif que contre le dollar en est absente). Diffusé à chaque tick, même sans changement.
orderbook.futures.<SYMBOL>État : le carnet d’ordres perpétuel, consolidé sur les places perpétuelles, perpétuels cotés en USD ou en USDC compris. Même cadence.

Toutes les paires perpétuelles de GET /v1/symbols sont disponibles sur les cinq canaux. Les deux stablecoins n’existent qu’en spot : ils sont servis sur trades.spot et orderbook.spot, et s’abonner à leur flux futures, à leur carnet perpétuel ou à leurs liquidations renvoie une erreur explicite, jamais un canal vide en silence.

Les canaux et l’offre

Chaque canal appartient à une famille de données de marché, la même que par REST et MCP : trades.futures, trades.spot, orderbook.spot et orderbook.futures relèvent de la microstructure, liquidations des dérivés. Une famille absente de votre offre l’est sur tous les canaux, flux compris. Concrètement : Free n’a pas du tout le flux (403 à la poignée de main), et tout le flux est ouvert à partir de Traders. Le temps réel n’est pas la famille des ticks bruts, ouverte à partir de l’offre Financial, qui désigne l’historique tick par tick servi par REST.

Un canal hors offre reçoit {"op": "error", "server_time": "…", "error": {"code": "plan_family", "message": "Channel 'orderbook.futures.BTCUSDT' is not in your plan: the 'microstructure' data family is not included in your subscription.", "param": "channels"}} et, comme pour tout abonnement, aucun canal de la trame n’est appliqué. Le nombre de connexions simultanées par compte dépend aussi de l’offre : 3 en Traders, 10 en Financial, 20 en Startup. Voir les tarifs.

#Format des messages

Chaque message poussé porte trois clés communes.

ChampTypeDescription
channelstringLe canal d’origine.
server_timestringLe tick du serveur qui a construit le message, en ISO 8601 UTC à la milliseconde — la même clé et le même format que l’enveloppe REST. L’état initial du carnet porte le tick de sa photo, donc parfois antérieur au subscribed qui le précède. Chaque transaction et chaque liquidation porte son propre time dans data.
seqintegerNuméro de séquence, monotone par canal. Sa valeur de départ est arbitraire : n’attendez ni 0 ni 1. Seul un saut entre deux messages consécutifs signale des messages perdus.

Transactions

trades
{
  "channel": "trades.futures.BTCUSDT",
  "server_time": "2026-10-14T09:25:26.408Z",
  "data": [
    { "time": "2026-10-14T09:25:26.176Z", "id": "3cf0988ca613980c", "side": "buy", "price": 83283.9, "quantity": 0.24, "value_usd": 19988.136 },
    { "time": "2026-10-14T09:25:26.184Z", "id": "4b408a64af719aa6", "side": "sell", "price": 83268.6, "quantity": 0.002, "value_usd": 166.5372 }
  ],
  "seq": 443379
}

side est le côté du preneur : buy signifie qu’un acheteur a consommé l’offre. Une ligne est une exécution : un ordre preneur qui balaie plusieurs ordres de même taille au même prix produit plusieurs lignes identiques, et c’est normal. Les transactions sont regroupées par tick, mais aucune n’est perdue : chacune garde son horodatage d’origine dans data[].time, distinct du server_time du message, et les transactions d’un message sont triées selon cet horodatage, de la plus ancienne à la plus récente. Cet ordre ne vaut qu’à l’intérieur d’un message : un message suivant peut porter une transaction plus ancienne que la plus récente déjà reçue (une place qui la rapporte en retard, en général de moins d’une seconde, parfois jusqu’à 1,5 s environ). Pour construire des bougies, rangez sur data[].time et clôturez une période environ deux secondes après sa fin.

Chaque transaction porte id, le même identifiant que le tick de /v1/trades/futures ou /v1/trades/spot pour cette transaction (une empreinte opaque : elle ne dit ni la place ni son identifiant natif), et value_usd = price × quantity. Servez-vous d’id pour dédoublonner le flux en direct contre l’historique REST.

Liquidations

liquidations
{
  "channel": "liquidations.BTCUSDT",
  "server_time": "2026-10-10T09:11:51.652Z",
  "data": [
    { "time": "2026-10-10T09:11:51.576Z", "price": 83168.0, "quantity": 0.081, "side": "short", "value_usd": 6736.608 }
  ],
  "seq": 7
}

side est ici le côté de la position liquidée : long signifie qu’une position acheteuse a été liquidée. Toutes les places n’ont pas cette convention ; la normalisation est faite pour vous. Comme pour les transactions, les liquidations d’un message sont triées par time. Une place peut rapporter une liquidation avec quelques secondes de retard : filtrez et fenêtrez sur time, jamais sur l’instant de réception. Une liquidation ancienne qu’une place republie plus de 60 s après son time n’est pas relayée (/v1/liquidations/events garde tous les événements).

Carnet d’ordres

book
{
  "channel": "orderbook.futures.BTCUSDT",
  "server_time": "2026-10-14T09:25:26.304Z",
  "data": {
    "bids": [[83271.0, 67.65684], [83270.0, 4.85424], [83269.0, 15.31266]],
    "asks": [[83272.0, 17.0135], [83273.0, 11.33315], [83274.0, 5.34624]],
    "avg_mid_price": 83271.75,
    "avg_spread_bps": 0.036
  },
  "seq": 627302
}

Chaque ligne est [prix, quantité], la quantité étant la somme des places à ce niveau de prix. Les bids sont décroissants, les asks croissants. Les prix sont ramenés à une grille commune par actif, sans quoi chaque place tomberait dans ses propres paliers et le carnet consolidé aurait plus de lignes, chacune plus mince.

Le pas de cette grille, dans la monnaie de cotation : BTC 1; ETH, BNB, TAO, ZEC, BCH 0.1; SOL, HYPE, XMR, LTC, QNT, AAVE, ETC, VVV, DASH, BSV 0.01; LINK, UNI, GRAM, AVAX, NEAR, DOT, MORPHO, ICP, RENDER, ATOM, FIL, INJ, LIT, ZRO, CAKE, TRUMP, PENDLE, LDO 0.001; XRP, TRX, SUI, XLM, ADA, USDCUSDT, USDTUSDC, ENA, ONDO, WLD, ASTER, WLFI, ARB, ALGO, POL, JUP, AERO, APT, STX, TIA, CRV, FET, VIRTUAL, GRASS, XPL, SPX, XTZ 0.0001; DOGE, HBAR, CC, SKY, JST, KAS, STABLE, AKE, NIGHT, PYTH, STRK, SEI, FF 0.00001; PUMP, VET, PENGU 0.000001.

Il y a un carnet par marché : orderbook.spot ne fusionne que les carnets spot, orderbook.futures que les carnets perpétuels. Les deux ne sont jamais mélangés, car leurs prix diffèrent de la base. Au sein d’un marché, une place dont le prix médian s’écarte de plus de 5 points de base de la médiane des autres (dès que trois places au moins sont présentes) est écartée de la fusion jusqu’à ce qu’elle revienne dans la norme : une place isolée ne peut pas déformer le carnet consolidé.

#À savoir

Le carnet consolidé est une profondeur, pas le carnet d’une place

Entre plusieurs places d’un même marché, il n’y a pas de moteur d’appariement commun : leurs prix médians diffèrent de quelques points de base, alors que l’écart achat/vente d’une place sur BTC est d’environ 0,1 point de base. Additionnés à leur prix brut, leurs niveaux croisaient le carnet consolidé dans la quasi-totalité des messages.

Depuis le 08/10/2026, chaque place est recentrée sur avg_mid_price avant la fusion : tous ses niveaux sont décalés de avg_mid_price moins son propre prix médian. Les quantités sont intactes, la distance de chaque niveau au prix médian de sa place est conservée, et le carnet consolidé n’est jamais croisé : bids[0][0] est toujours sous asks[0][0]. Une place qui n’a, à cet instant, qu’un côté de carnet n’y contribue pas.

Le nombre de transactions ne mesure pas l’activité

Certaines places fractionnent leurs exécutions à l’extrême, jusqu’à des ordres d’un satoshi, une fraction de centime. Dans le flux consolidé, le nombre de transactions est donc dominé par de minuscules exécutions. Pour mesurer l’activité, additionnez quantity ou quantity × price, ne comptez jamais les lignes.

Un saut de seq signifie que vous avez perdu des messages

La file d’envoi de chaque client est bornée à 512 trames, environ huit ticks à pleine charge (800 ms d’à-coup absorbable). Un client qui ne consomme pas assez vite voit ses messages les plus anciens abandonnés ; il se dégrade seul, sans affecter les autres.

Sur les canaux d’événements, un message abandonné est définitivement perdu ; c’est le saut de seq qui vous le dit. Sur le carnet d’ordres, seul le dernier état est gardé : un saut n’y a aucune conséquence, l’état suivant remplace entièrement le précédent.

L’identifiant est vérifié à la poignée de main, pas dans une trame

La clé (ou le jeton) est vérifiée à la poignée de main, avant que la connexion ne s’ouvre : une offre qui n’inclut pas le flux reçoit un 403 et rien n’est établi. Depuis un serveur, elle voyage dans l’en-tête X-API-KEY ; un navigateur, qui ne peut pas poser d’en-tête sur un WebSocket, utilise un jeton éphémère dans l’URL (?token=), pour que la clé elle-même ne quitte jamais votre serveur.

La trame {"op": "auth"} est toujours acceptée et répond authenticated, mais elle n’est plus nécessaire : la session s’ouvre déjà authentifiée. ?token= et ?api_key= ne sont acceptés que sur la poignée de main WebSocket, jamais sur les routes REST, et la ligne de requête de /ws n’est pas journalisée par la passerelle.

Le nombre de connexions simultanées dépend de l’offre

Chaque offre borne le nombre de connexions ouvertes en même temps, pour tout le compte, toutes clés confondues. La connexion de trop est fermée aussitôt avec le code 4003 et le plafond dans la raison. Ne la confondez pas avec un 503 à la poignée de main, qui dit que le service lui-même est plein, quelle que soit l’offre.

Une déconnexion libère immédiatement sa place : il n’y a rien à attendre ni rien à réinitialiser.

Le flux ne nomme jamais une place

Aucun message ne dit de quelle place vient une transaction, une liquidation ou un niveau du carnet, ni combien de places l’ont alimenté : le flux sert le marché consolidé. Une place muette depuis plus de dix secondes sort automatiquement du carnet, et une place au carnet vide n’y est jamais fusionnée.

#Limites et fermetures

LimiteEn cas de dépassement
Clé ou jeton absent, invalide ou déjà utilisé401 à la poignée de main, X-Deny-Reason: missing_api_key / invalid_api_key
Flux absent de l’offre (Free)403 à la poignée de main, X-Deny-Reason: plan_websocket
Simultaneous connections per account: 3 on Traders, 10 on Financial, 20 on Startupfermeture 4003, le plafond dans la raison
Capacité du service de flux, tous comptes confondus503 à la poignée de main, réessayer plus tard
Canal d’une famille hors offreop: error, aucun canal de la trame n’est appliqué
Ouvertures : 1 par seconde, rafale de 10429 à la poignée de main
File d’envoi de 512 tramesles plus anciennes abandonnées, seq saute
10 débordements tolérésfermeture 4005, raison « client too slow »
depth entre 1 et 5erreur, l’abonnement n’est pas appliqué
Code de fermetureRaison
4003 (plafond)Plafond de connexions simultanées de l’offre atteint, pour tout le compte. Une déconnexion libère aussitôt sa place.
4005 (client trop lent)File saturée de façon prolongée. Un client qui perd huit cents millisecondes de flux dix fois de suite ne rattrapera pas son retard.
4008Plus de 20 messages par seconde envoyés par le client : les messages de contrôle sont limités par connexion.
4010Session expirée : une connexion est fermée au bout de 6 heures ; reconnectez-vous et abonnez-vous à nouveau (la clé et l’offre sont revérifiées).
1011Délai du ping de maintien dépassé : le serveur envoie un ping toutes les 20 secondes et ferme si aucun pong ne revient dans les 20 secondes. Les bibliothèques clientes répondent seules ; reconnectez-vous.
4001N’est plus envoyé en pratique : la clé est vérifiée à la poignée de main (401), et une trame auth sur une session ouverte répond authenticated.
4002Session jamais authentifiée en 5 s. Résiduel, puisque la clé est vérifiée à la poignée de main.

Toute demande impossible reçoit une erreur nommée, jamais un silence : symbole inconnu, canal inconnu, marché futures demandé sur un stablecoin, depth hors bornes, abonnement avant authentification, message illisible. L’erreur dit ce qui était attendu.

Une trame d’erreur a les parties d’une erreur REST : {"op": "error", "server_time": …, "error": {"code": …, "message": …, "param": …, "details": …}'}, param étant le champ fautif de votre message. Toute trame de contrôle (subscribed, unsubscribed, authenticated) porte server_time, et {"op": "ping"} répond {"op": "pong", "server_time": …}.

#Se reconnecter

Une connexion peut tomber (un déploiement, le réseau). Reconnectez-vous avec une attente croissante (0,5 s, doublée jusqu’à 30 s, avec une part aléatoire), puis renvoyez votre subscribe : les abonnements ne survivent pas à la connexion. Ne relancez pas un 401 ou un 403 au handshake, ni une fermeture 4003 pour le plafond : la même réponse reviendrait. seq continue d’une reconnexion à l’autre tant que le service tourne, mais repart d’une valeur arbitraire quand il redémarre : prenez le premier message après une reconnexion comme un nouveau départ. Avec une offre qui comprend les trades individuels, ceux manqués entre-temps se relisent sur /v1/trades/spot ou /v1/trades/futures avec since_ms : chacun porte le même id que sur le flux, dédoublonnez sur lui. Les SDK se reconnectent et se réabonnent pour vous.

#Exemple complet

Authentification, abonnement, détection des pertes et lecture du carnet.
import asyncio
import json
import os

import websockets  # pip install websockets


async def main() -> None:
    # From a server, the key goes in the X-API-KEY header, checked at the
    # handshake: the session opens already authenticated. A refused key gets 401
    # before it opens, a plan without the stream 403, a service at capacity 503
    # (InvalidStatus on the websockets side). In a web page: ?token= instead.
    headers = {"X-API-KEY": os.environ["BYTNODE_API_KEY"]}
    async with websockets.connect("wss://api.bytnode.com/ws", additional_headers=headers) as ws:
        await ws.send(json.dumps({
            "op": "subscribe",
            "channels": ["trades.futures.BTCUSDT", "orderbook.futures.BTCUSDT"],
            "depth": 5,
        }))
        print(await ws.recv())

        expected: dict[str, int] = {}

        async for raw in ws:
            msg = json.loads(raw)
            if "op" in msg:  # a control message (error, pong...), not data
                print(msg)
                continue
            channel, seq = msg["channel"], msg["seq"]

            # The first seq of a channel is arbitrary: only a jump between two
            # messages counts. It signals lost messages: the client is not
            # consuming fast enough.
            if channel in expected and seq != expected[channel]:
                print(f"!! {seq - expected[channel]} message(s) lost on {channel}")
            expected[channel] = seq + 1

            if channel.startswith("orderbook."):
                # The book is a consolidated depth centred on avg_mid_price, not one
                # venue's book: use the per-venue average spread to trade.
                spread = msg["data"]["avg_spread_bps"]
                if spread is not None:
                    print(f"{channel} mid={msg['data']['avg_mid_price']} spread={spread:.3f} bps")
            else:
                print(f"{channel} {len(msg['data'])} event(s)")


asyncio.run(main())

Pour écrire ce client avec un agent IA de code, l’Agent Skill bytnode-realtime-stream lui apprend cette page : jetons pour navigateur, bougies en direct depuis les transactions, messages perdus et reconnexion.