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": { } }
FieldMeaning
typeThe message kind (see below).
match_idThe match the message concerns. Absent on connection-level frames (error, subscription acks).
seqA 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.
tsWhen 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).
dataThe 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

typedataMeaning
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

typedataMeaning
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 are status, score_a, score_b, winner_team_id, scheduled_at, started_at, ended_at, stage, format, result_type, team_a_id, team_b_id and tournament_id — plus data_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 for status in changed and match.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), a phase, and two timestamps (as_of, observed_at). match.score_a / score_b are the series (map) score, so a live best-of-one is legitimately 0–0 there until the map ends — read the round score from live_state. A null round score means unknown, never 0–0. A running round score we have not been able to refresh for 3 minutes is also sent as null (the phase stays in_progress) instead of an old number.
  • live_state.phase — in_progress while map_number is being played; map_over once 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 is in_progress with null round scores; a finished map's map_over state is then never sent); series_over once the series has ended, until the match itself is marked completed. So a match whose status is still live can be showing a finished map's final — map_score_a / _b count that map before the match's own score_a / score_b do. between_maps means map_number has ended but we do not yet know its final round score: both round scores are null and map_score_a / _b already 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 to map_over or 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):

DataTypical 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_atA 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 resultsA median of about 14 minutes after the match ends; about 1 in 10 takes 6 hours or more.
Per-map player statsA median of about 1½ hours after the match ends; about 1 in 10 takes 9 hours or more.
Round-by-round dataA 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 note says 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_days tells 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_seconds is real.
  • cadence_seconds on 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 error frame (code: "connection_limit") followed by a close — not a queue. This is the only error frame 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.

On this page