WebSocket stream
The real-time push. Mint a ticket, connect, and the server streams the opportunities your plan is entitled to — gapless, with a heartbeat and reconnect recovery.
The WebSocket stream is the real-time half of the API. It pushes each opportunity the instant it opens or changes, with no polling and no staleness. It runs on Basic and Pro. A Free key can read the REST snapshot but cannot mint a stream ticket.
The stream speaks the Centrifugo protocol, so you connect with the centrifuge client for your language rather than a raw socket. Connecting is a two-step handshake: mint a short-lived ticket over REST, then open the socket with it.
1. Mint a ticket
Call POST /v1/stream/token with your API key. The ticket is valid for about 120 seconds and is single-use for one connection.
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 echoes what your plan grants: the channels the server will subscribe you to, and your concurrent-connection cap. A Free or lapsed key gets a 402 here with a link to upgrade, never a silent empty ticket.
2. Connect
Open the socket to ws_url and pass the ticket in the connect data under t. You do not subscribe to channels by hand. The server reads your plan from the ticket and subscribes you to exactly the channels you are entitled to, so publications arrive as soon as you connect.
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();The ticket is short-lived on purpose. Mint a fresh one for each new connection. The client keeps the connection alive once it is established, so you only mint again when you reconnect from scratch.
Channels
The server picks your channel set from your tier. You receive a subset of these:
| Channel | Plan | Carries |
|---|---|---|
markets:all | Basic, Pro | The matched-feed stream: every co-listed market across all active sports, as state and price frames. This is the v2-canonical channel. You get this when your scope is all sports. |
markets:{sport} | Basic, Pro | The matched-feed stream for one broad sport, such as markets:baseball. You get these instead of markets:all when your scope is a subset. |
status | Basic, Pro | The heartbeat. See The heartbeat. |
quote:{sport} | Pro | Raw top-of-book quote changes per sport. |
candidates:{sport} | Pro | Review-band candidates per sport, the provisional matches before they are confirmed. See Matching & confidence. |
org:{id} | Basic, Pro | Your org's presence channel. Every connection joins it so your concurrent connections can be counted against the cap. You will not publish or read data on it. |
Sports use broad keys: baseball, basketball, football, hockey, soccer, tennis, lol, dota2, valorant, cs2, and mma. A sport can span several leagues (baseball covers MLB, KBO, and NPB), but the channel is per sport, not per league. Ask /v2/leagues for the set in season right now.
Messages
What arrives depends on the channel family. The markets:* channels carry flat market and price frames. The status channel carries the heartbeat. Every message is JSON with a type field.
On the markets channels
The markets:* channels are the v2-canonical stream, carrying the market object. Frames are flat: the type field discriminates them, and there is no data wrapper, no channel key, and no schema_version key. Two frame types arrive.
A market frame ships on a pair-state change: a new pair goes active, a pair retracts, or its signal flips. It is the full canonical market object with the type discriminator added; when signal is "arb" it also carries the per-outcome depth books. On a market frame, priced_at is the instant the frame was built. The same field on a REST response reads the pair's updated_at (no live-price column exists yet), so the stream carries the fresher timestamp.
{
"type": "market",
"id": "dino_8f3a1c92-47e8-4c3f-b9d2-f1a8e6c4d5f2",
"...": "the rest of the market object"
}
A price frame ships on a price-only change. It is a compact delta keyed on the stable outcome key, carrying every price-derived top-level scalar so a cached market object never goes stale:
{
"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 }
]
}
When the pair's signal is arb, each outcome in the price frame additionally carries its kalshi_book and polymarket_book depth objects, and the frame carries a top-level max_stake: the smallest depth at the consumed (cheaper) ask across the outcomes, so a streaming arb is never size-blind. max_stake is null when any consumed leg has no depth reading.
Frames are dual-published to markets:{sport} and markets:all, and delivery is at-least-once: after a partial publish the next sweep re-sends, so a rare duplicate frame is possible. Frames are self-describing and idempotent, so applying a duplicate is harmless.
A status message is the heartbeat, covered next.
The heartbeat
The feed is quiet most of the time, because an arb is a rare event. A silent socket is ambiguous: it could mean no arbs are open, or it could mean the feed is down. The status channel removes that ambiguity. The brain publishes a heartbeat on a timer whether or not any arb is open.
{
"type": "status",
"schema_version": 1,
"channel": "status",
"ts": "2026-06-25T21:00:00+00:00",
"data": {
"state": "ok",
"active_pairs": 312,
"open_opportunities": 0
}
}
| Field | Meaning |
|---|---|
state | ok when the feed is live and firing, degraded during a cold-start warmup or a recovery window when arbs are held back. |
active_pairs | How many matched games the brain is currently watching. |
open_opportunities | How many arbs are open right now. A heartbeat with ok and 0 means the feed is healthy and there is simply nothing to act on. |
Treat a gap in heartbeats, not a quiet arb feed, as the signal that something is wrong.
Connection limits
Each plan has a cap on concurrent connections: Basic allows 3, Pro allows 10, and Free has none. The cap is counted server-side from your org's presence channel, so it holds across processes and machines. A connection past your cap is refused. The cap is the value metric, so if you run parallel strategies or want a staging line alongside production, that is what you are paying for when you move up a tier.
Bootstrapping from a snapshot
To start with a complete picture and then stay live, read GET /v2/markets?signal=arb first to load every open arb, then connect the stream. The REST snapshot does not carry a stream-offset field, so there is no exact resume point to hand the stream. Treat the REST read and the stream connect as two independent views. In practice: load the snapshot, connect immediately after, and expect a small window where a message you already have in the snapshot also arrives on the stream. Dedupe on the market id.
Recovery after a disconnect
The stream channels keep a short history, about 100 messages over 5 minutes. The centrifuge client records the offset of the last message it saw, so when it reconnects it asks the server to replay anything it missed from that point. For a brief drop this is automatic and gapless.
If you are disconnected longer than the history window, recovery cannot fill the gap. The client tells you recovery failed, and the right move is to bootstrap again from a fresh REST snapshot rather than trust a partial stream.