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, "ts": "2026-09-18T11:00:00.123Z", "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. |
ts | When the server read the state this frame carries (UTC, RFC 3339). Present on snapshot and update only. It never decreases on a connection, so it orders frames across matches, and your receive time minus ts is the delivery lag. It is not an event time — for "how old is this score", read match.live_state.as_of (see Freshness). |
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 builds on — and then streams update messages as things change.
When you subscribe to many matches at once, a match's updates can start as soon as its own snapshot
has arrived, before the subscribed ack that follows the last snapshot. 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; a match's updates can arrive between its snapshot and the 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": [ … ], "data_available": { … } } | 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": { … }, "data_available": { … }, "changed": [ … ] } | A change: the match's current served odds, or its whole current match object. 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.
Match updates: changed and live_state
A match update always carries the whole current match object, so you can replace your copy
rather than patch it. Two things tell you what actually moved:
changed— the names of the match fields that changed, e.g.["status", "score_a", "winner_team_id"]when a series ends. The possible names arestatus,score_a,score_b,winner_team_id,scheduled_at,started_at,ended_at,stage,format,result_type,team_a_id,team_b_idandtournament_id— plusdata_available, when a data layer (maps, stats, rounds and so on) arrives for the first time and its flag turns true. To detect the end of a series, look forstatusinchangedandmatch.status === "completed".match.live_state— the match's live state: which map (map_number,map_name), its round score (round_score_a,round_score_b,rounds_played), the series as seen live (map_score_a,map_score_b), aphase, and two timestamps (as_of,observed_at).match.score_a/score_bare the series (map) score, so a live best-of-one is legitimately0–0there until the map ends — read the round score fromlive_state. Anullround score means unknown, never 0–0. A running round score we have not been able to refresh for 3 minutes is also sent asnull(the phase staysin_progress) instead of an old number.live_state.phase—in_progresswhilemap_numberis being played;map_overonce it has ended, when the round score is that map's final and stays until the next map begins (a newer round score can show the next map first, or we can confirm the next map has begun before any round score exists for it, which isin_progresswithnullround scores; a finished map'smap_overstate is then never sent);series_overonce the series has ended, until the match itself is markedcompleted. So a match whosestatusis stilllivecan be showing a finished map's final —map_score_a/_bcount that map before the match's ownscore_a/score_bdo.between_mapsmeansmap_numberhas ended but we do not yet know its final round score: both round scores arenullandmap_score_a/_balready count the map. It is used when we know a map that was still showing as in progress has ended (for example, the series score we hold has caught up with it), and it gives way tomap_overor to the next map's state. The set of phases is open: handle a value you do not know as "not being played right now".
live_state is null once a match is completed or cancelled, and before play begins; it can appear
shortly before status turns live, since the two are collected separately. Where our round-level
feed covers a match, its first state of a map usually turns the match live in the same update. For
live matches our round-level feed doesn't reach, it is either null or names the map being played
with null round scores; for some of those a newer round score arrives on its own, with the map name null.
A live_state change arrives as a match update without a changed key — the match fields
themselves didn't move, live_state did: its score, its phase, or its as_of moves on, so a live
map also sends the odd frame between score changes. When a match's live state is
removed, the update carries "live_state": null. So: changed absent ⇒ compare live_state;
changed present ⇒ those fields moved. You will not receive identical frames on a timer.
{
"type": "update",
"match_id": "019f3c1e-7a44-7d10-9a52-0c5de1b2a001",
"seq": 14,
"ts": "2026-09-18T11:42:07.518Z",
"data": {
"changed": ["status", "score_a", "winner_team_id", "ended_at"],
"match": {
"id": "019f3c1e-7a44-7d10-9a52-0c5de1b2a001",
"status": "completed",
"format": "bo3",
"score_a": 2,
"score_b": 1,
"winner_team_id": "019e88a0-11f2-7c3b-8e0d-5b7a90c4d002",
"ended_at": "2026-09-18T11:36:00Z",
"changed_at": "2026-09-18T11:42:07.431Z",
"live_state": null
}
}
}(The match object is abbreviated here — on the wire it is the same full object
GET /v1/{game}/matches/{id} returns.)
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 our collection, not a fixed clock. The channel signals that data is fresh; it is not a countdown or a prompt to act.
Freshness: what "live" means here
Be clear-eyed about this before you build on it. We are not connected to game servers and this is not a tick-level or sub-second feed. The WebSocket removes your polling delay; it cannot make the data newer than our own collection. Typical end-to-end age of what a frame carries, from our own measurements over the 24 days to 15 October 2026 (the figures as they stand today are in the measured numbers below):
| Data | Typical age when you receive it |
|---|---|
Current-map round score (live_state) | Carried on the score itself: live_state.as_of is when the score is of, so its age is the time now minus as_of. For most scores that is the round-level feed's own timestamp, and that feed follows the official broadcast, which runs behind play — seconds behind at some LAN events, minutes behind for online ones — so no single figure describes it. For some of the matches it covers, a second source supplies a newer round score: it reached us about 11 to 13 seconds behind play in the seven games we measured on 14 October 2026 (one day, not a promise), and as_of is then the time we received the score less a fixed 4 or 10 seconds (it depends on how we read the score; the allowance is an assumed upper bound on the source's own delay, so the age you compute from as_of can be too large, never too small). Our own part of the age is small: we re-read each covered match at least every 10.5 seconds (about 5 seconds of waiting on average), and a score is pushed to your socket within a few tens of milliseconds of our read. A map's final score and the between-maps state come from the round-level feed or, for some of the matches it covers, sooner from the second source: in the 17 map and series ends we could time on 16 October 2026 that source was ahead of the round-level feed by a median of about 2 minutes (30 to 142 seconds). Either way they arrive well before the series score below, and they can be skipped when a newer score already shows the next map (map_score_a / _b then count the finished map). Only for the subset of tournaments we have a round-level feed for. |
Series score, match status, started_at / ended_at | A median of about 12 minutes after the match ends, and 9 in 10 within about 22 minutes. A few take hours. On the matches the round feed covers, live_state.phase turns series_over sooner; for some of them the second source shows the series over a median of about 9½ minutes before the match's own status changed (our first seven series ends, 16 October 2026). |
| Completed-map results | A median of about 14 minutes after the match ends; about 1 in 10 takes 6 hours or more. |
| Per-map player stats | A median of about 1½ hours after the match ends; about 1 in 10 takes 9 hours or more. |
| Round-by-round data | A median of about a day and a half after the match ends; about 1 in 10 takes two weeks or more. |
Odds (eo_market) | Re-priced about once a minute while a market is open; a line is only written when it moves. |
The measured numbers
The table above was measured on 15 October 2026. For the figures as they stand today, call
GET /v1/{game}/coverage: every capability carries a
freshness object with p50_seconds, p90_seconds and p99_seconds over a recent window, plus
the sample behind them and a basis naming exactly which two instants were measured.
Read it with three things in mind:
- It is an observation, not a service level. The percentiles describe matches that have already finished. There is no latency SLA on any plan, and nothing is promised about the next match.
- A null is a real answer. Percentiles are null — never 0 — whenever the window holds nothing
yet, and
notesays why. Some of these figures are derived from our own change history, which starts when we began recording it, so they populate over the days after a capability is added rather than appearing complete on day one.window_daystells you how much is actually covered. - It errs slow. A late correction or a repair that rewrites a row inside the window moves the
figure up. We do not exclude late arrivals to flatter the tail, so
p99_secondsis real. cadence_secondson the same row is a different number — how often a collector runs, not how long anything waited. Do not read one as the other.
Live round score is the one capability with no percentile. Each score already carries its own age
in live_state.as_of, but we keep only a match's current live state, not a history of past ones,
so there is no window of samples to take a percentile over. That row says so in its note.
You can also measure it yourself: live_state.as_of is when a score is of,
live_state.observed_at is when we last read it, ts is when the server read the state a frame
carries, match.changed_at is when we recorded a match change, and captured_at on an odds line is
when that price was captured.
Beyond as_of, there are no upstream timestamps for individual rounds or kills, and there is no
in-round event stream (kills, plants, economy as it happens) on any surface today. Round-by-round
and per-player data is available after each map, over REST. The connection path, the envelope
and the sequencing are stable: if collection gets faster, frames simply arrive sooner and nothing
you built changes.
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 request allowance. 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.