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.