guide

"Stream CS2 match updates over WebSocket"

Jul 10, 2026

Bottom line: Instead of polling the CS2 API on a timer, you can hold one WebSocket connection and be notified the moment a match's data changes. The handshake is two steps: mint a short-lived ticket over REST, then open the socket with it. The push message is deliberately thin — it carries only the match_id and which table changed, not the new scores — so the pattern is "get the tap, then re-fetch that resource over REST." Built on the EsportsOdds CS2 data API.

Push beats polling

Polling means asking "anything new?" every few seconds, and almost always hearing "no." A WebSocket flips that: you open one connection and the server tells you when something actually changed, so you do work only when there's work to do.

Polling issues a request every few seconds, most of which return unchanged data. A single push connection lets you act only when a match actually changes.

This is the efficient way to keep the live-scores dashboard current without hammering the endpoint.

The handshake: ticket, then socket

You don't put your API key in the WebSocket URL. Instead you exchange it, over an ordinary authenticated REST call, for a short-lived signed ticket, then connect with that.

The streaming pattern: POST for a 60-second ticket, connect the WebSocket with it, receive a change signal carrying a match_id, then re-fetch that resource over REST.

First, mint the ticket:

import os
import requests

BASE = "https://api.esportsodds.gg"
KEY = os.environ["ESPORTSODDS_API_KEY"]

def get_ticket() -> str:
    resp = requests.post(
        f"{BASE}/v1/cs2/ws-token",
        headers={"Authorization": f"Bearer {KEY}"},
        timeout=10,
    )
    resp.raise_for_status()
    return resp.json()["data"]["token"]

The response is {"data": {"token": "…", "expires_at": "…"}}. The ticket is valid for 60 seconds — long enough to open the connection, not long enough to be worth leaking. Mint one right before you connect.

Connecting and reading the stream

Connect to wss://api.esportsodds.gg/v1/ws?token=<ticket>. Once connected, the server streams change signals. Each one looks like this:

{ "match_id": "0191f2c8-8a1e-7c3a-9f5e-2b6d4e8a1c00", "table": "matches" }

The push message contains only a match id and the name of the table that changed — no score and no prices. You re-fetch over REST to read the new values.

That's the whole message. There's no score in it, no line, no payload — just which match changed and which table. The table field (matches, odds_lines) tells you what to re-fetch. This keeps the stream tiny and means you always read canonical data over REST rather than trusting a fragment pushed down the socket.

import json
from websockets.sync.client import connect

def stream(on_change):
    url = f"{BASE.replace('https', 'wss')}/v1/ws?token={get_ticket()}"
    with connect(url) as ws:
        for raw in ws:
            msg = json.loads(raw)
            on_change(msg["match_id"], msg["table"])

Re-fetch what you care about

The connection delivers change signals for matches across the feed, so filter for the ones you're tracking and re-fetch only those. Keep a set of match IDs you care about, and when a signal arrives for one, pull its fresh state:

WATCHING = {"0191f2c8-8a1e-7c3a-9f5e-2b6d4e8a1c00"}
HEADERS = {"Authorization": f"Bearer {KEY}"}

def refetch_match(match_id: str):
    r = requests.get(f"{BASE}/v1/cs2/matches/{match_id}", headers=HEADERS, timeout=10)
    r.raise_for_status()
    return r.json()["data"]

def handle(match_id: str, table: str):
    if match_id not in WATCHING:
        return
    match = refetch_match(match_id)
    print(f"{match_id} updated ({table}): {match['score_a']}{match['score_b']}")

stream(handle)

Remember that GET /matches/{id} is the detail endpoint, so it returns IDs, not names (see the quickstart). If you're keeping a scoreboard, you already have the names from the list — merge the fresh score into the row you're holding.

What the stream is, and isn't

Be clear with yourself about the granularity. This is a change-notification channel: the socket taps you when the underlying data is refreshed, so you replace polling with a push and stop wasting calls. It is not a play-by-play broadcast of every in-round event — the freshness of any given field is bounded by how often that data is updated upstream. For a scoreboard, a results bot, or a line-movement tracker, that's exactly the right tool. Design your UI around "this figure is current," and keep the presentation calm — a steady freshness cue, never a countdown.

Two operational notes: reconnect when the socket drops (mint a fresh ticket each time — they expire), and add a small backoff between reconnect attempts so a flapping network doesn't spin.

Next steps

Mint a ticket, open the socket, filter the signals, re-fetch over REST. The endpoint reference documents the ticket endpoint and every resource you'll re-fetch.