Live data & WebSocket
Subscribe to CS2 score and odds updates over a WebSocket channel.
Alongside the REST API, a WebSocket channel pushes updates — score changes and moving odds —
so you don't have to poll /v1/{game}/matches and /v1/{game}/odds on a timer. A single
connection multiplexes many matches: you say which matches you care about with a subscribe
message rather than opening a socket per match.
Connecting
Connecting is a two-step handshake so your raw API key never appears in a socket URL. First, your backend mints a short-lived (60s) connection ticket:
curl -X POST -H "Authorization: Bearer YOUR_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/ws-token"{
"data": {
"token": "eyJ...",
"expires_at": "2026-07-02T18:01:00Z"
}
}Then open the WebSocket to /v1/ws with that ticket:
const ws = new WebSocket(`wss://api.esportsodds.gg/v1/ws?token=${token}`)The key's status is re-checked at handshake time, so a revoked key can't connect on an old ticket.
(?apiKey=YOUR_API_KEY on the handshake still works as a deprecated fallback for
backend-to-backend callers; it will be removed — mint tickets instead.)
As with REST, keep your key server-side. For browser clients, have your backend mint the ticket and hand only the ticket to the client — exactly what the two-step flow above is for.
The message envelope
Every frame, in both directions, is a single JSON object with the same shape:
{ "type": "…", "match_id": "…", "seq": 0, "data": { } }| Field | Meaning |
|---|---|
type | The message kind (see below). |
match_id | The match the message concerns. Empty/absent on connection-level frames (heartbeat, error, subscription acks). |
seq | A per-(connection, match) sequence number on server→client match messages. A snapshot is seq 0; each following update increments (1, 2, 3…). Use it to detect a missed message — a gap means re-sync. |
data | The type-specific payload. |
Subscribing
Right after the socket opens, tell the server which matches you want:
ws.send(JSON.stringify({ type: 'subscribe', data: { match_ids: ['018f...e2a1'] } }))The server answers each subscribe with a snapshot for every match — the full current state,
the baseline every later update is a delta against — and then streams incremental update
messages as things change. Stop with unsubscribe:
ws.send(JSON.stringify({ type: 'unsubscribe', data: { match_ids: ['018f...e2a1'] } }))Client → server messages
type | data | Meaning |
|---|---|---|
subscribe | { "match_ids": ["…"] } | Begin receiving updates for these matches. The server replies with a snapshot per match. |
unsubscribe | { "match_ids": ["…"] } | Stop; the server acks with unsubscribed. |
pong | — | Optional reply to a server heartbeat (a native WebSocket pong frame is equally accepted). |
Snapshots and updates
Server → client messages
type | data | Meaning |
|---|---|---|
snapshot | { "match": { … }, "odds": [ … ] } | The full current state for a match — sent on subscribe and on every re-sync. The baseline for later updates. |
update | { "odds": [ … ] } or { "match": { … } } | An incremental change: moved odds, or a score/status change. seq increments. |
subscribed / unsubscribed | { "match_ids": ["…"] } | Subscription acknowledgements. |
heartbeat | { "ts": "…" } | Server keep-alive (see below). |
error | { "code": "…", "message": "…" } | A coded protocol/auth/cap error, in the same shape as REST errors. A fatal one (e.g. a revoked key or the connection cap) precedes the socket closing. |
A snapshot re-baselines your local state; apply an update on top of the last snapshot. Handle
them like this:
ws.addEventListener('message', (event) => {
const frame = JSON.parse(event.data)
switch (frame.type) {
case 'snapshot':
// frame.data.match + frame.data.odds — replace this match's local state
setMatch(frame.match_id, frame.data.match, frame.data.odds)
break
case 'update':
// frame.data.odds — moved odds; frame.data.match — a score/status change
if (frame.data.odds) applyOdds(frame.match_id, frame.data.odds)
if (frame.data.match) applyMatch(frame.match_id, frame.data.match)
break
case 'heartbeat':
ws.send(JSON.stringify({ type: 'pong' }))
break
case 'error':
console.warn(frame.data.code, frame.data.message)
break
}
})Sequence numbers & reconnection
seq exists so you never have to assume no message was missed. Track the last seq you saw per
match; if the next update skips a number, treat your local state as stale and re-sync — the
simplest way is to subscribe to that match again, since every subscribe begins with a fresh
snapshot.
Networks drop. On any disconnect, mint a fresh ticket (they're single-use), reconnect with
exponential backoff and jitter, re-send your subscribe, and rebuild state from the resulting
snapshot. There is no server-side replay buffer — you cannot resume from your last seq, and
you don't need to: the snapshot makes a reconnect correct, not merely tolerable.
Heartbeats
The server sends a heartbeat (or a native ping) about every 30 seconds. Reply with a pong frame
(or let your client answer the native ping automatically). A connection that misses several
heartbeats without a reply is considered dead and closed, freeing its slot against your connection
cap. Heartbeats also stop intermediaries from idling the connection out.
What the channel carries
The odds a snapshot or update carries are the derived lines only — the eo_market
de-vigged aggregate (combined from multiple bookmakers and exchanges) and, once it clears
validation, the eo_model line — exactly as with REST. Contributing books are never named and no
per-book price is ever sent.
Updates are pushed as the underlying data changes — driven by match ingestion, not a fixed clock — so judge a value's freshness by when you received it, not by an assumed interval. The channel signals that data is fresh; it is not a countdown or a prompt to act. (True sub-second in-play ticking arrives with a later release; today the channel carries ingestion-cadence updates.)
Metering & connection cap
WebSocket access is a tier-gated feature, not per-message metered:
- Concurrent-connection cap. On the standard plan a key may hold 5 concurrent connections.
Opening a sixth is refused with an
errorframe (code: "connection_limit") followed by a close — not a queue. - Minting a ticket is one metered request (it's an ordinary authenticated REST call against
POST /v1/{game}/ws-token). The messages pushed over an open connection are not billed — an idle or busy subscription accrues no per-message or per-heartbeat charges for its lifetime. - Disconnecting frees the slot immediately, including a heartbeat-timeout close.
Reconnect with backoff rather than in a tight loop, and reuse one connection across many matches
(that's what subscribe is for) rather than opening one per match.