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": "MDE4ZjJjNGEtOWIzMS03ZTAyLWE1ZDctM2MxZjg4YjQwZTE5LjE3ODMwMTUyNjAuaHFKSWtWNUJBV2xXeUdTV2xwTG14OFA3YTFtTUlEaWQtM0tscjhDWFNINA",
"expires_at": "2026-07-02T18:01:00Z"
}
}The ticket is an opaque base64url string, not a JWT — don't try to parse it or read claims out of it. Treat it as a bearer credential with a 60-second lifetime.
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. A ticket is not single-use — it stays valid for its full 60 seconds, so a retried connection attempt inside that window can reuse it. Mint a fresh one once it expires.
Authorization: Bearer is not accepted on the WebSocket handshake — browsers can't set headers
on a WebSocket connection, which is exactly why tickets exist. (?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. Absent on connection-level frames (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, then one subscribed ack. Ids are de-duplicated; an unknown match is skipped silently rather than erroring. |
unsubscribe | { "match_ids": ["…"] } | Stop; the server acks with unsubscribed. |
pong | — | Optional app-level keep-alive reply. Answering the server's native WebSocket ping (which most clients do automatically) is equally accepted — see Keep-alive. |
An unknown type, or malformed JSON, is ignored — never fatal to the connection, and never
answered with an error frame. Inbound frames are capped at 8 KiB, which is sized for a
many-id subscribe; exceeding it closes the connection.
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. Always seq 0. |
update | { "odds": [ … ] } or { "match": { … } } | An incremental change: moved odds, or a score/status change. seq increments. |
subscribed / unsubscribed | { "match_ids": ["…"] } | Subscription acknowledgements. seq 0. |
error | { "code": "…", "message": "…" } | A coded error, followed immediately by the socket closing. Today the only code is connection_limit, and it can only occur at connect time — see Metering & connection cap. |
A snapshot re-baselines your local state; apply an update on top of the last snapshot.
A snapshot can arrive unsolicited. When the server's internal data feed reconnects, every live
subscription is re-baselined with a fresh snapshot — you didn't ask for it, and it isn't an error.
Handle snapshot as "replace this match's state" wherever it arrives and this is free. Handle
messages 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 'error':
// Always followed by a close. `connection_limit` means back off — don't reconnect immediately.
console.warn(frame.data.code, frame.data.message)
break
}
})There is no keep-alive branch to write: the server uses native WebSocket pings, which browsers and every mainstream client library answer for you.
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, reconnect with exponential backoff and jitter, re-send your
subscribe, and rebuild state from the resulting snapshot. Reuse your ticket if it's still inside
its 60-second window; mint a fresh one otherwise. 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.
Keep-alive
The server sends a native WebSocket ping every 54 seconds and closes any connection it hasn't
heard from in 60 seconds. There is no JSON heartbeat message — don't write a handler for one.
Browsers and mainstream client libraries reply to a native ping automatically, so in most clients
this needs no code at all. If your library doesn't, or if you're behind an intermediary that strips
pings, send {"type":"pong"} yourself at least once a minute — it refreshes the same read deadline.
A closed connection frees its slot against your connection cap immediately, and the ping traffic also stops 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.
To set expectations honestly: today the channel carries ingestion-cadence updates, at the speed our own collection runs, which for the market line has averaged around 665 line writes an hour across all open markets over the last week. Sub-second tick frequency depends on a separate match-data service that ships in a later release. The connection path, the envelope and the sequencing are all final — only the tick rate changes when that lands, so anything you build against this protocol keeps working.
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. This is the onlyerrorframe the server sends, and it only ever arrives at connect time; an established connection is never terminated by one. - The WebSocket costs no request quota. Minting a ticket (
POST /v1/{game}/ws-token), opening the connection, and every message pushed over it are all free of your monthly and trial allowances. Tickets expire after 60 seconds, so a reconnecting client mints a new one each time — that reconnection is free too. The ticket mint does still draw on the per-second rate limit, which is why reconnecting with backoff matters. - Disconnecting frees the slot immediately, including a keep-alive 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.