Tiempo real

Flujo WebSocket en tiempo real para desarrolladores

Un relé. Los eventos de los mercados cubiertos se normalizan y se fusionan en una sola salida, enviada en cada tick de 100 ms. Es la única transformación que se hace: entra una operación, sale una operación.

No es una fuente de métricas (ni ventana móvil, ni contador, ni media), y no hay historial: recibes lo que ocurre desde tu conexión. El pasado lo sirve REST, y a los agentes de IA, MCP.

Para un agente de IA, el canal es el servidor MCP, no el flujo: un agente de IA no mantiene una conexión abierta, pregunta cuando lo necesita. La herramienta MCP get_pair_data lee bajo demanda el libro de órdenes y las últimas operaciones de un par; el flujo es para el código que escucha de forma continua.

#Conexión

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

El mismo flujo se sirve en wss://ws.bytnode.com/ws, su propio servidor: misma clave, mismos canales, mismas tramas. Usa cualquiera de las dos direcciones.

Desde un servidor, la clave va en la cabecera X-API-KEY del handshake; desde una página web, un token de corta duración va en la URL (?token=, emitido por POST /v1/ws/token: 60 segundos, una conexión). Ambos se comprueban en el handshake, antes de que se abra la conexión. Es la misma clave que la de REST y el servidor MCP (?api_key= en la URL se sigue aceptando). En el handshake hay dos rechazos posibles: clave o token ausente, inválido, caducado o ya usado, un 401 (X-Deny-Reason: missing_api_key / invalid_api_key, y WWW-Authenticate como en REST); plan sin el flujo, como es el caso de Free, un 403 (X-Deny-Reason: plan_websocket). Un 503 en el handshake significa otra cosa: el servicio de flujo ha alcanzado su capacidad, sumando todas las cuentas; reinténtalo un poco más tarde. Una vez abierta, la conexión que supera el límite del plan se cierra con el código 4003.

La sesión se abre por tanto ya autenticada: el primer mensaje puede ser la suscripción. La antigua trama op: auth sigue aceptándose para los clientes escritos antes de la verificación en el handshake: en una conexión ya autenticada se ignora y responde {"op": "authenticated", "authenticated_by": "handshake"}, sea cual sea la clave que lleve.

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

#Suscribirse

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

Cancelar la suscripción es simétrico: op: unsubscribe, respuesta op: unsubscribed.

  • La respuesta enumera todos los canales activos de la conexión, ordenados, no solo los de la petición. Es un estado, no un acuse de recibo.
  • depth se aplica a la conexión, no a un canal. Cada subscribe reinicia la profundidad de todos los libros de órdenes de la sesión; si se omite, vuelve a 5. Es el número de niveles de precio por lado (compras y ventas); valores aceptados: de 1 a 5.
  • La suscripción es todo o nada. Si un solo canal de la lista es inválido, no se aplica ninguno y el error explica por qué. Una suscripción parcial te haría creer que recibes lo que pediste. Los nombres de canal no distinguen mayúsculas: trades.futures.btcusdt se devuelve como trades.futures.BTCUSDT.
  • En cuanto se acepta una suscripción a un libro de órdenes, el estado actual se envía de inmediato, sin esperar al siguiente tick.

mode, opcional en subscribe: frame (por defecto) envía un mensaje por trama, como se describe abajo; batch envía, en cada tick, todas las tramas pendientes en UN mensaje {"op": "batch", "frames": [...]} — menos mensajes, las mismas tramas dentro. Un cliente en modo batch debe leer frames en lugar de ignorar todo mensaje que lleve op. Como depth, se aplica a la conexión: cada subscribe lo reinicia y, si se omite, vuelve a frame. La descripción legible por máquina del flujo (canales, tramas, mensajes de control, códigos de cierre) es el contrato AsyncAPI.

#Los cinco canales

CanalNaturaleza y cadencia
trades.futures.<SYMBOL>Eventos. Como mucho un paquete por tick de 100 ms, ausente si no hay nada que decir. Con carga, los paquetes se espacian, a veces más de un segundo: un silencio de unos segundos no es una caída.
trades.spot.<SYMBOL>Eventos, misma cadencia.
liquidations.<SYMBOL>Eventos, misma cadencia.
orderbook.spot.<SYMBOL>Estado: el libro de órdenes spot, consolidado solo sobre los libros spot cotizados en USDT (un mercado que solo cotiza el activo contra el dólar no aparece). Se emite en cada tick, aunque no haya cambios.
orderbook.futures.<SYMBOL>Estado: el libro de órdenes perpetuo, consolidado sobre los mercados perpetuos, incluidos los perpetuos cotizados en USD o USDC. Misma cadencia.

Todos los pares perpetuos de GET /v1/symbols están disponibles en los cinco canales. Las dos stablecoins solo existen en spot: se sirven en trades.spot y orderbook.spot, y suscribirse a su flujo de futuros, a su libro perpetuo o a sus liquidaciones devuelve un error explícito, nunca un canal vacío en silencio.

Los canales y el plan

Cada canal pertenece a una familia de datos de mercado, la misma que por REST y MCP: trades.futures, trades.spot, orderbook.spot y orderbook.futures son de microestructura, liquidations de derivados. Una familia que falta en tu plan falta en todos los canales, incluido el flujo. En concreto: Free no tiene el flujo en absoluto (403 en el handshake), y todo el flujo está abierto a partir de Traders. El tiempo real no es la familia de ticks brutos, abierta a partir del plan Financial, que designa el historial tick a tick servido por REST.

Un canal fuera del plan recibe {"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"}} y, como en cualquier suscripción, no se aplica ningún canal de la trama. El número de conexiones simultáneas por cuenta también depende del plan: 3 en Traders, 10 en Financial, 20 en Startup. Ver los precios.

#Formato de los mensajes

Cada mensaje enviado lleva tres claves comunes.

CampoTipoDescripción
channelstringEl canal de origen.
server_timestringEl tick del servidor que construyó el mensaje, en ISO 8601 UTC con milisegundos: la misma clave y el mismo formato que el sobre REST. El estado inicial del libro lleva el tick de su foto, así que puede ser anterior al subscribed que lo precede. Cada operación y cada liquidación lleva su propio time en data.
seqintegerNúmero de secuencia, monótono por canal. Su valor inicial es arbitrario: no esperes 0 ni 1. Solo un salto entre dos mensajes consecutivos indica mensajes perdidos.

Operaciones

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 es el lado del taker: buy significa que un comprador consumió la oferta. Una fila es una ejecución: una orden taker que barre varias órdenes maker del mismo tamaño al mismo precio produce varias filas idénticas, y es normal. Las operaciones se agrupan por tick, pero no se pierde ninguna: cada una conserva su marca de tiempo original en data[].time, distinta del server_time del mensaje, y las operaciones de un mensaje van ordenadas por esa marca de tiempo, de la más antigua a la más reciente. Ese orden vale solo dentro de un mensaje: un mensaje posterior puede traer una operación más antigua que la más reciente ya recibida (un mercado que la reporta con retraso, normalmente de menos de un segundo, a veces hasta unos 1,5 s). Para construir velas, agrupa por data[].time y cierra un periodo unos dos segundos después de su fin.

Cada operación lleva id, el mismo identificador que el tick de /v1/trades/futures o /v1/trades/spot para esa operación (una huella opaca: no indica ni el mercado ni su identificador nativo), y value_usd = price × quantity. Usa id para eliminar duplicados entre el flujo en directo y el historial REST.

Liquidaciones

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 es aquí el lado de la posición liquidada: long significa que se liquidó una posición larga. No todos los mercados usan esta convención; la normalización se hace por ti. Como con las operaciones, las liquidaciones de un mensaje van ordenadas por time. Un mercado puede reportar una liquidación con unos segundos de retraso: filtra y agrupa por time, nunca por el momento de recepción. Una liquidación antigua que un mercado vuelve a publicar más de 60 s después de su time no se retransmite (/v1/liquidations/events conserva todos los eventos).

Libro de órdenes

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
}

Cada fila es [precio, cantidad], siendo la cantidad la suma de los mercados en ese nivel de precio. Los bids son decrecientes, los asks crecientes. Los precios se llevan a una rejilla común por activo; sin ella, cada mercado caería en sus propios niveles y el libro consolidado tendría más filas, cada una más fina.

El paso de esa cuadrícula, en la moneda de cotización: 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.

Hay un libro por mercado: orderbook.spot solo fusiona los libros spot, orderbook.futures solo los libros perpetuos. Los dos nunca se mezclan, porque sus precios difieren en la base. Dentro de un mismo tipo de mercado, una plataforma cuyo precio medio se aleja más de 5 puntos básicos de la mediana de las demás (cuando hay al menos tres) queda fuera de la fusión hasta que vuelve a la normalidad: una plataforma aislada no puede deformar el libro consolidado.

#Lo que hay que saber

El libro consolidado es una profundidad, no el libro de una plataforma

Entre varias plataformas de un mismo tipo de mercado no hay un motor de emparejamiento común: sus precios medios difieren en unos pocos puntos básicos, mientras que el diferencial compra/venta de una plataforma en BTC es de unos 0,1 puntos básicos. Sumados a su precio bruto, sus niveles cruzaban el libro consolidado en casi todos los mensajes.

Desde el 08/10/2026, cada plataforma se recentra en avg_mid_price antes de la fusión: todos sus niveles se desplazan en avg_mid_price menos su propio precio medio. Las cantidades no cambian, la distancia de cada nivel al precio medio de su plataforma se conserva, y el libro consolidado nunca está cruzado: bids[0][0] siempre está por debajo de asks[0][0]. Una plataforma que en ese momento solo tiene un lado de su libro no contribuye.

El número de operaciones no mide la actividad

Algunos mercados fraccionan sus ejecuciones al extremo, hasta órdenes de un satoshi, una fracción de céntimo. En el flujo consolidado, el número de operaciones está por tanto dominado por ejecuciones diminutas. Para medir la actividad, suma quantity o quantity × price; nunca cuentes las filas.

Un salto en seq significa que has perdido mensajes

La cola de envío de cada cliente está limitada a 512 tramas, unos ocho ticks a plena carga (800 ms de sacudida absorbible). Un cliente que no consume lo bastante rápido ve descartados sus mensajes más antiguos; se degrada solo, sin afectar a los demás.

En los canales de eventos, un mensaje descartado se pierde definitivamente; el salto en seq es lo que te lo indica. En el libro de órdenes solo se guarda el último estado: un salto allí no tiene consecuencias, el siguiente estado sustituye por completo al anterior.

La credencial se comprueba en el handshake, no en una trama

La clave (o el token) se comprueba en el handshake, antes de que se abra la conexión: un plan que no incluye el flujo recibe un 403 y no se establece nada. Desde un servidor viaja en la cabecera X-API-KEY; un navegador, que no puede poner cabeceras en un WebSocket, usa un token de corta duración en la URL (?token=), para que la clave nunca salga de tu servidor.

La trama {"op": "auth"} se sigue aceptando y responde authenticated, pero ya no es necesaria: la sesión se abre ya autenticada. ?token= y ?api_key= solo se aceptan en el handshake de WebSocket, nunca en las rutas REST, y la pasarela no registra la línea de petición de /ws.

El número de conexiones simultáneas depende del plan

Cada plan limita el número de conexiones abiertas a la vez, para toda la cuenta, sumando todas las claves. La conexión que sobra se cierra de inmediato con el código 4003 y el límite en el motivo. No la confundas con un 503 en el handshake, que indica que el propio servicio está lleno, sea cual sea el plan.

Una desconexión libera su plaza al instante: no hay nada que esperar ni que reiniciar.

El flujo nunca nombra un mercado

Ningún mensaje dice de qué mercado procede una operación, una liquidación o un nivel del libro, ni cuántos mercados lo alimentaron: el flujo sirve el mercado consolidado. Un mercado en silencio durante más de diez segundos sale automáticamente del libro, y un mercado con el libro vacío nunca se fusiona en él.

#Límites y cierres

LímiteAl superarlo
Clave o token ausente, inválido o ya usado401 en el handshake, X-Deny-Reason: missing_api_key / invalid_api_key
Flujo ausente del plan (Free)403 en el handshake, X-Deny-Reason: plan_websocket
Simultaneous connections per account: 3 on Traders, 10 on Financial, 20 on Startupcierre 4003, el límite en el motivo
Capacidad del servicio de flujo, sumando todas las cuentas503 en el handshake, reintentar más tarde
Canal de una familia fuera del planop: error, no se aplica ningún canal de la trama
Aperturas: 1 por segundo, ráfaga de 10429 en el handshake
Cola de envío de 512 tramasse descartan las más antiguas, seq salta
10 desbordamientos toleradoscierre 4005, motivo «client too slow»
depth entre 1 y 5error, la suscripción no se aplica
Código de cierreMotivo
4003 (límite)Se alcanzó el límite de conexiones simultáneas del plan, para toda la cuenta. Una desconexión libera su plaza al instante.
4005 (cliente demasiado lento)Cola saturada durante un periodo prolongado. Un cliente que pierde ochocientos milisegundos de flujo diez veces seguidas no se pondrá al día.
4008Más de 20 mensajes por segundo enviados por el cliente: los mensajes de control están limitados por conexión.
4010Sesión caducada: una conexión se cierra al cabo de 6 horas; reconecta y suscríbete de nuevo (la clave y el plan se vuelven a comprobar).
1011Tiempo de espera del ping de mantenimiento: el servidor envía un ping cada 20 segundos y cierra si no vuelve un pong en 20 segundos. Las bibliotecas cliente responden solas; reconecta.
4001Ya no se envía en la práctica: la clave se comprueba en el handshake (401), y una trama auth en una sesión abierta responde authenticated.
4002Sesión nunca autenticada en 5 s. Residual, ya que la clave se comprueba en el handshake.

Toda petición imposible recibe un error con nombre, nunca un silencio: símbolo desconocido, canal desconocido, mercado de futuros pedido para una stablecoin, depth fuera de rango, suscripción antes de autenticarse, mensaje ilegible. El error dice lo que se esperaba.

Una trama de error tiene las partes de un error REST: {"op": "error", "server_time": …, "error": {"code": …, "message": …, "param": …, "details": …}'}, siendo param el campo erróneo de tu mensaje. Toda trama de control (subscribed, unsubscribed, authenticated) lleva server_time, y {"op": "ping"} responde {"op": "pong", "server_time": …}.

#Reconectarse

Una conexión puede caer (un despliegue, la red). Reconéctese con una espera creciente (0,5 s, duplicada hasta 30 s, con una parte aleatoria) y vuelva a enviar su subscribe: las suscripciones no sobreviven a la conexión. No reintente un 401 o un 403 en el handshake ni un cierre 4003 por el límite: volvería la misma respuesta. seq continúa de una reconexión a otra mientras el servicio funciona, pero vuelve a empezar desde un valor arbitrario cuando se reinicia: tome el primer mensaje tras una reconexión como un nuevo comienzo. Con un plan que incluye los trades individuales, los perdidos mientras tanto se pueden volver a leer en /v1/trades/spot o /v1/trades/futures con since_ms: cada uno lleva el mismo id que en el flujo, deduplique por él. Los SDK se reconectan y vuelven a suscribirse por usted.

#Ejemplo completo

Autenticación, suscripción, detección de pérdidas y lectura del libro.
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())

Para escribir este cliente con un agente de IA de programación, el Agent Skill bytnode-realtime-stream le enseña esta página: tokens para navegador, velas en directo a partir de las operaciones, mensajes perdidos y reconexión.