How-to

Connect a WebSocket

A working client, from ticket to subscription, with the mistakes pre-removed.

Polling for changes costs a request every interval, forever. One WebSocket costs one request to mint a ticket and then pushes changes for as long as it stays open.

The handshake

Mint a ticket from your backend. This is an ordinary authenticated REST call and the only metered part.

curl -X POST -H "Authorization: Bearer $ESPORTSODDS_API_KEY" \
  "https://api.esportsodds.gg/v1/cs2/ws-token"
{ "data": { "token": "MDE4ZjJjNGEt…", "expires_at": "2026-07-26T09:14:20.481293Z" } }

The token is an opaque base64url string — not a JWT, so don't try to read claims from it. It is valid for 60 seconds and is not single-use: a retried connection inside that window can reuse it.

Open the socket with the ticket in the query string.

const ws = new WebSocket(`wss://api.esportsodds.gg/v1/ws?token=${token}`)

Authorization: Bearer is not accepted here — browsers can't set headers on a WebSocket, which is exactly why tickets exist. Keep the raw key server-side and hand the client only a ticket.

Subscribe to the matches you care about. One connection multiplexes many.

ws.addEventListener('open', () => {
  ws.send(JSON.stringify({ type: 'subscribe', data: { match_ids: [matchId] } }))
})

Each id yields a snapshot, then one subscribed ack.

Handling frames

ws.addEventListener('message', (event) => {
  const frame = JSON.parse(event.data)
  switch (frame.type) {
    case 'snapshot':
      // Full state — replace, don't merge. Can arrive unsolicited on a server-side resync.
      setMatch(frame.match_id, frame.data.match, frame.data.odds)
      break
    case 'update':
      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
  }
})

Three things people get wrong:

There is no heartbeat message. The server sends native WebSocket pings every 54 seconds and closes anything it hasn't heard from in 60. Browsers and mainstream libraries answer automatically, so there's usually no code to write. A case 'heartbeat' branch will never fire.

A snapshot can arrive unsolicited. When our internal feed reconnects, every live subscription is re-baselined. Treat snapshot as "replace this match's state" wherever it arrives.

connection_limit is the only error frame, and it only occurs at connect time. An established connection is never terminated by one.

Limits

Five concurrent connections per key on the standard plan. A sixth gets an error frame then a close — it isn't queued. Reuse one connection across many matches rather than opening one per match.

Messages are not billed. An idle or busy subscription accrues no per-message charge for its lifetime; only the ticket mint counts.

Reconnecting

Networks drop. Reconnect with exponential backoff and jitter, re-send your subscribe, and rebuild from the resulting snapshot. Reuse the ticket if it's still inside its 60 seconds; mint a fresh one otherwise. See recovering from disconnects.

The full protocol reference is on Live data & WebSocket.

On this page