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": { } }
FieldMeaning
typeThe message kind (see below).
match_idThe match the message concerns. Empty/absent on connection-level frames (heartbeat, 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.
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 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

typedataMeaning
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.
pongOptional reply to a server heartbeat (a native WebSocket pong frame is equally accepted).

Snapshots and updates

Server → client messages

typedataMeaning
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 error frame (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.

On this page