Stream de WebSocket
El envío en tiempo real. Emite un ticket, conecta, y el servidor transmite las oportunidades a las que tu plan tiene derecho — sin huecos, con heartbeat y recuperación al reconectar.
El stream de WebSocket es la mitad en tiempo real de la API. Envía cada oportunidad en el instante en que se abre o cambia, sin sondeo (polling) y sin desactualización. Funciona en Basic y Pro. Una clave Free puede leer la instantánea REST pero no puede emitir un ticket de stream.
El stream habla el protocolo Centrifugo, así que te conectas con el cliente centrifuge para tu lenguaje en lugar de un socket en bruto. Conectar es un handshake de dos pasos: emites un ticket de corta duración por REST, y luego abres el socket con él.
1. Emite un ticket
Llama a POST /v1/stream/token con tu clave de API. El ticket es válido por unos 120 segundos y es de un solo uso para una conexión.
curl -s -X POST "https://api.dino.markets/v1/stream/token" \
-H "Authorization: Bearer sk_live_..."
{
"ticket": "eyJhbGciOi...",
"ws_url": "wss://stream.dino.markets/connection/websocket",
"expires_in": 120,
"allowed": {
"channels": ["markets:all", "status"],
"max_conns": 3
}
}
allowed refleja lo que tu plan otorga: los canales a los que el servidor te suscribirá, y tu límite de conexiones concurrentes. Una clave Free o vencida recibe un 402 aquí con un enlace para hacer upgrade, nunca un ticket vacío en silencio.
2. Conecta
Abre el socket a ws_url y pasa el ticket en los datos de conexión bajo t. No te suscribes a canales a mano. El servidor lee tu plan desde el ticket y te suscribe exactamente a los canales a los que tienes derecho, así que las publicaciones llegan en cuanto te conectas.
import { Centrifuge } from "centrifuge";
const { ticket, ws_url } = await mintTicket();
const centrifuge = new Centrifuge(ws_url, {
data: { t: ticket },
});
centrifuge.on("publication", (ctx) => {
console.log(ctx.channel, ctx.data);
});
centrifuge.connect();El ticket es de corta duración a propósito. Emite uno nuevo para cada conexión nueva. El cliente mantiene la conexión viva una vez establecida, así que solo vuelves a emitir cuando te reconectas desde cero.
Canales
El servidor elige tu conjunto de canales según tu nivel. Recibes un subconjunto de estos:
| Canal | Plan | Lleva |
|---|---|---|
markets:all | Basic, Pro | El stream del feed emparejado: cada mercado co-listado en todos los deportes activos, como frames de estado y de precio. Este es el canal canónico de v2. Recibes esto cuando tu alcance es todos los deportes. |
markets:{sport} | Basic, Pro | El stream del feed emparejado para un deporte amplio, como markets:baseball. Recibes estos en lugar de markets:all cuando tu alcance es un subconjunto. |
status | Basic, Pro | El heartbeat. Ver El heartbeat. |
quote:{sport} | Pro | Cambios de cotización en bruto de la punta del libro por deporte. |
candidates:{sport} | Pro | Candidatos en banda de revisión por deporte, las coincidencias provisionales antes de confirmarse. Ver Coincidencia y confianza. |
org:{id} | Basic, Pro | El canal de presencia de tu organización. Cada conexión se une a él para que tus conexiones concurrentes puedan contarse contra el límite. No publicarás ni leerás datos en él. |
Los deportes usan claves amplias: baseball, basketball, football, hockey, soccer, tennis, lol, dota2, valorant, cs2, y mma. Un deporte puede abarcar varias ligas (baseball cubre MLB, KBO, y NPB), pero el canal es por deporte, no por liga. Pregunta a /v2/leagues por el conjunto en temporada ahora mismo.
Mensajes
Lo que llega depende de la familia de canal. Los canales markets:* llevan frames planos market y price. El canal status lleva el heartbeat. Cada mensaje es JSON con un campo type.
En los canales de markets
Los canales markets:* son el stream canónico de v2, y llevan el objeto de mercado. Los frames son planos: el campo type los discrimina, y no hay envoltorio data, ni clave channel, ni clave schema_version. Llegan dos tipos de frame.
Un frame market se envía en un cambio de estado del par: un par nuevo se activa, un par se retracta, o su signal cambia. Es el objeto de mercado canónico completo con el discriminador type añadido; cuando signal es "arb" también lleva los libros de profundidad por resultado. En un frame market, priced_at es el instante en que se construyó el frame. El mismo campo en una respuesta REST refleja el updated_at del par (todavía no existe una columna de precio en vivo), así que el stream lleva la marca de tiempo más reciente.
{
"type": "market",
"id": "dino_8f3a1c92-47e8-4c3f-b9d2-f1a8e6c4d5f2",
"...": "the rest of the market object"
}
Un frame price se envía en un cambio de solo precio. Es un delta compacto indexado por la key estable del resultado, y lleva cada escalar de nivel superior derivado del precio, así que un objeto market cacheado nunca queda desactualizado:
{
"type": "price",
"id": "dino_8f3a1c92-47e8-4c3f-b9d2-f1a8e6c4d5f2",
"priced_at": "2026-07-01T22:55:02+00:00",
"spread_pts": 2.0,
"potential_arb_pct": 1.83,
"outcomes": [
{ "key": "NYY", "kalshi": 0.52, "polymarket": 0.54 },
{ "key": "BOS", "kalshi": 0.50, "polymarket": 0.46 }
]
}
Cuando el signal del par es arb, cada resultado en el frame price lleva además sus objetos de profundidad kalshi_book y polymarket_book, y el frame lleva un max_stake de nivel superior: la profundidad más pequeña en el ask consumido (el más barato) entre los resultados, así que un arb en stream nunca queda ciego respecto al tamaño. max_stake es null cuando cualquier pierna consumida no tiene lectura de profundidad.
Los frames se publican por duplicado en markets:{sport} y markets:all, y la entrega es al menos una vez: tras una publicación parcial, la siguiente pasada reenvía, así que es posible un frame duplicado poco frecuente. Los frames se describen a sí mismos y son idempotentes, así que aplicar un duplicado es inofensivo.
Un mensaje status es el heartbeat, cubierto a continuación.
El heartbeat
El feed está en silencio la mayor parte del tiempo, porque un arb es un evento poco frecuente. Un socket silencioso es ambiguo: podría significar que no hay arbs abiertos, o podría significar que el feed está caído. El canal status elimina esa ambigüedad. El cerebro publica un heartbeat en un temporizador, haya o no algún arb abierto.
{
"type": "status",
"schema_version": 1,
"channel": "status",
"ts": "2026-06-25T21:00:00+00:00",
"data": {
"state": "ok",
"active_pairs": 312,
"open_opportunities": 0
}
}
| Campo | Significado |
|---|---|
state | ok cuando el feed está vivo y disparando, degraded durante un calentamiento de arranque en frío o una ventana de recuperación cuando los arbs se retienen. |
active_pairs | Cuántos partidos emparejados está observando el cerebro en este momento. |
open_opportunities | Cuántos arbs están abiertos ahora mismo. Un heartbeat con ok y 0 significa que el feed está saludable y simplemente no hay nada sobre lo que actuar. |
Trata un hueco en los heartbeats, no un feed de arb en silencio, como la señal de que algo anda mal.
Límites de conexión
Cada plan tiene un límite de conexiones concurrentes: Basic permite 3, Pro permite 10, y Free no tiene ninguna. El límite se cuenta del lado del servidor desde el canal de presencia de tu organización, así que se mantiene a través de procesos y máquinas. Una conexión que supera tu límite es rechazada. El límite es la métrica de valor, así que si ejecutas estrategias en paralelo o quieres una línea de staging junto a producción, eso es lo que estás pagando al subir de nivel.
Arranque desde una instantánea
Para empezar con una imagen completa y luego mantenerte en vivo, lee GET /v2/markets?signal=arb primero para cargar cada arb abierto, y luego conecta el stream. La instantánea REST no lleva un campo de offset de stream, así que no hay un punto de reanudación exacto que entregarle al stream. Trata la lectura REST y la conexión del stream como dos vistas independientes. En la práctica: carga la instantánea, conecta inmediatamente después, y espera una pequeña ventana en la que un mensaje que ya tienes en la instantánea también llegue en el stream. Deduplica por el id del mercado.
Recuperación tras una desconexión
Los canales del stream mantienen un historial breve, unos 100 mensajes durante 5 minutos. El cliente centrifuge registra el offset del último mensaje que vio, así que al reconectarse le pide al servidor que reproduzca lo que se haya perdido desde ese punto. Para una caída breve, esto es automático y sin huecos.
Si estás desconectado más tiempo que la ventana del historial, la recuperación no puede llenar el hueco. El cliente te indica que la recuperación falló, y lo correcto es arrancar de nuevo desde una instantánea REST fresca en lugar de confiar en un stream parcial.