← Back to API docs

Realtime push

Scores, events, statistics, commentary and animation data over WebSocket (bidirectional), SSE (one-way, simplest) or MQTT. The data is the same on all three.

Access and channels

Key rule: the API key exists only on your server. Browsers and apps never get the key: your server exchanges it for a one-time ticket (valid for 60 seconds, single use) and hands that to the front end, which connects the WebSocket with the ticket alone. SSE and MQTT need the key, so use them on servers only.

  • Realtime requires a monthly realtime subscription on the key; otherwise 403.
  • After connecting you first get a subscribed ack, then a snapshot of live matches (snapshot: true), then live changes.
  • Messages are JSON: {"type": channel, "data": {...}}; the match id is data.match_id, the same id as in REST.
  • Scope by channels, matches (match ids) and leagues (league names); empty means everything.
ChannelDescription
matchScore, status, minute and the 7-tuple counts [score, half, red, yellow, corner, extra time, penalties] per side (retained)
incidentGoals, cards, substitutions, VAR and other events, as they happen
statsTechnical statistics of a match (retained)
tliveText commentary. Has to be enabled separately: keys without it do not receive this channel (text commentary is also available over REST)
animationLive animation data: ball position, attack momentum, event line
odds / odds_market / propsOnly for keys with the matching data domain

WebSocket

URL
wss://api.sports.csap.ai/ws?ticket=<ticket>
Auth
First POST https://api.sports.csap.ai/api/v1/realtime/ticket with X-API-Key to get a one-time ticket, valid for 60 seconds and a single connection. The key never appears in the WebSocket URL.
Subscribe
Send JSON after connecting: {"action": "set", "channels": ["match"], "matches": ["4230"]}. action is set (replace), subscribe (add), unsubscribe (remove) or all (everything). The URL also accepts &channels=&matches=&leagues=.
// 1) ON YOUR SERVER (Node 18+): exchange the key for a one-time ticket (valid 60 s, single use)
const API_KEY = process.env.API_KEY
const r = await fetch("https://api.sports.csap.ai/api/v1/realtime/ticket", {
  method: "POST",
  headers: { "X-API-Key": API_KEY },
})
const { data } = await r.json()

// 2) IN THE BROWSER / APP, or on the server: connect with the ticket only (hand data.ws_url to the front end; never the key). The initial subscription goes in the URL, so the snapshot is filtered too
const ws = new WebSocket("wss://api.sports.csap.ai" + data.ws_url + "&channels=match,incident")
// change it later with: ws.send(JSON.stringify({ action: "set", channels: ["match"], matches: ["4230"] }))
ws.onmessage = (ev) => {
  const msg = JSON.parse(ev.data)
  if (msg.type === "match") console.log(msg.data.match_id, msg.data.home_scores, msg.data.away_scores)
}
// on close: get a new ticket and reconnect; the snapshot after reconnecting brings you up to date (see "Disconnects, reconnects and catching up" below)

Sample messages (captured live)

{"type":"subscribed","filtered":false,"rules":[],"available_channels":["match","incident","odds","animation","tlive","stats","odds_market","props"]}

{
  "type": "match",
  "snapshot": true,
  "data": {
    "match_id": "4230",
    "gmid": "4230",
    "league": "Mongolian Premier League",
    "home_team": "Khaan Khuns-Erchim FC",
    "away_team": "Khovd Western",
    "status": "inprogress",
    "status_id": 2,
    "minute": "16",
    "home_scores": [0, 0, 0, 0, 0, null, 0],
    "away_scores": [0, 0, 0, 0, 0, null, 0],
    "incidents": [{ "type": "match_started", "time": null, "is_home": false, "player_name": null }],
    "stats": [{ "key": "goals", "name": "Goals", "home": "0", "away": "0", "home_num": 0, "away_num": 0 }],
    "version": 31
  }
}

Live test

Disconnects, reconnects and catching up

The server keeps no message sequence and no replay buffer: nothing sent while you were offline is sent again. Catching up rests on two things: the snapshot the server pushes again after a reconnect, and de-duplication in the client by the keys below. The SDKs already follow these rules; follow them too if you write your own client. The SDKs do this for you →

  1. Every connection needs a new ticket: a ticket is valid for 60 seconds and a single use, so call POST /api/v1/realtime/ticket again before each reconnect.
  2. Space the reconnects with exponential backoff and random jitter: start at 1 second, double each time, 30 seconds at most; start again from 1 second once data flows.
  3. Re-send every subscription once connected: set for the first rule, subscribe for the others. A connection without a subscription in its URL is given up to 1 second to send its first subscription message, and the snapshot is filtered by it; rules added later do not trigger another snapshot, so their data arrives with the next change or the next reconnect.
  4. A snapshot (snapshot: true) has exactly the structure of a live change. A match snapshot carries the whole event list incidents and the statistics stats, so goals and cards from the time you were offline can be recovered from it.
  5. The incident channel gets no snapshot, and single events from the offline period are not re-sent. If you need them one by one, reconcile against incidents in the match snapshot.
  6. Do not reconnect in these cases: the ticket exchange returns 401 / 403 (key invalid or disabled, no realtime push, IP not on the key's allow-list); the ticket exchange returns 429 because the daily quota is used up (no ticket today; connect again the next day); the WebSocket upgrade returns 403 (IP not on the allow-list; a browser cannot see this status and can only keep retrying); the server closes with code 1008 (it re-checks the key's realtime access every 30 seconds and closes this way when it has been withdrawn). Retry in every other case: per-minute rate limit, 5xx or network error on the ticket exchange; 401 (ticket expired or already used), 503 (connection limit) or a timeout on the upgrade.

What to de-duplicate by

The snapshot after a reconnect repeats what you already received, and the server may re-send a message at other times too. Drop only repeats whose content is identical; do not cut by version or sequence number alone:

ChannelSnapshot on connectKey and rule
matchYesPer match_id: drop a message with a version lower than the highest seen; with an equal version, drop it only when its content is identical to the last one you kept; deliver a higher version. No version: deliver
incidentNoPer dkey: drop an exact repeat (identical content); the same dkey with different content is delivered, as a revision or a second event (the SDKs flag it revised). No dkey: deliver
statsNo (inside the match snapshot)Deliver everything; each message replaces the previous one for that match
animationYesKind ball: drop a frame only when it is identical to the previous frame of that match (seq is the scene code, not a counter). Other kinds: deliver everything
odds / odds_market / propsYesDeliver everything; each message replaces the previous one for that match
  • "Identical content" means the message's data is deeply equal as a JSON value (key order in objects does not matter, 1 equals 1.0); the snapshot flag sits outside data and is not compared.
  • match.version goes up only when the score, status or minute changes. When only events or statistics were added, the server pushes the match again with the same version, so "equal version, drop" would lose data.
  • The seq of an animation ball frame is the scene code, not a counter; do not order or de-duplicate by it.
  • Two events of the same kind for the same side in the same minute (a double substitution, say) share a dkey; so does one event pushed again with more fields filled in. The SDKs hand you such a message flagged revised, and it is up to you to tell by the content (compare the players, or go by the whole event list in the match message).
  • A match message in the connection snapshot sometimes carries the score only, without the event list. It differs from the same-version message you had, so it is delivered. When you merge state, do not let a same-version snapshot without incidents clear the event list you already have.

Four pitfalls in subscription messages

  • unsubscribe removes rules by channel only. It removes the rules whose channels intersect channels; a rule without channels (all channels) is not removed; matches and leagues play no part in the matching. With none of channels, matches and leagues, it unsubscribes everything.
  • Receiving everything, then unsubscribing one channel, leaves you with nothing. On a connection that receives everything (all, or no subscription sent yet), unsubscribe results in "filtered, zero rules", not "everything minus that channel". To receive all but one channel, list the channels you want with set / subscribe.
  • The server ignores channel names it does not know. set / subscribe with only misspelt channels means all channels; unsubscribe with only misspelt channels unsubscribes everything. The SDKs refuse names outside the channel list locally; validate them yourself in your own client.
  • A subscription message may not exceed 8192 bytes (UTF-8). Beyond that the server closes with code 1009; reconnecting and re-sending the same message gets closed again, in a loop. About 700 match ids are enough to exceed it, so split them into several rules and subscribe each. The SDKs refuse an oversized message locally.

Heartbeat, and one pitfall

  • The server sends a WebSocket ping every 30 seconds and closes the connection when nothing comes back for 70 seconds. Browsers and most WebSocket libraries answer with a pong by themselves.
  • A browser cannot see ping frames, and with no match in play there may be no data for a long time. To notice a dead connection yourself, send a subscription message as a heartbeat at intervals: the server answers every subscription message with subscribed; if nothing at all arrives within 10 seconds of sending it, drop the connection and reconnect.
  • Careful: set means "replace all rules with this one". With a single rule on the connection, re-sending it has no side effect and does not push the snapshot again; with two or more rules, re-sending the first rule's set removes all the others. In that case send the second message below as the heartbeat: it names one match id that does not exist and no channel, so the server removes nothing and still answers subscribed.
// one rule on the connection: re-sending it is harmless
{"action": "set", "channels": ["match"], "matches": ["4230"]}

// two or more rules: a message that changes nothing and still gets a "subscribed" reply
{"action": "unsubscribe", "matches": ["__heartbeat__"]}

SSE(Server-Sent Events)

URL
GET https://api.sports.csap.ai/sse?channels=match,incident&matches=&leagues=
Auth
X-API-Key header. SSE needs the key, so it suits servers, or a relay through your own server to the browser; for realtime data straight in a browser use WebSocket with a ticket. The browser EventSource cannot send headers and would need ?key=: still accepted but discouraged, because the key ends up in URLs and logs.
Subscribe
Set by URL parameters channels, matches, leagues (comma separated); reconnect to change. A : ping comment is sent every 25 seconds.
curl -N "https://api.sports.csap.ai/sse?channels=match,incident" -H "X-API-Key: YOUR_API_KEY"

Sample messages (captured live)

retry: 3000
: connected

data: {"type":"subscribed","filtered":true,"rules":[{"channels":["match","incident"],"scope":"*"}],"available_channels":["match","incident","odds","animation","tlive","stats","odds_market","props"]}

data: {"type":"match","snapshot":true,"data":{"match_id":"4230","gmid":"4230","status":"inprogress","status_id":2,"minute":"16", …}}

: ping

Live test

MQTT

URL
wss://api.sports.csap.ai/mqtt MQTT over WebSocket (3.1.1); TCP 1883 is not open.
Auth
Use your key as both username and password; pick a unique clientId per connection. Subscribe only.
Topics
football/<channel>/<league>/<match id>; spaces, /, + and # in league names become underscores, _ when there is no league. E.g. football/match/# for all scores, football/+/+/4230 for one match.
Planned
Compatibility topic sports/football/match.v5: planned — subscribing works but no messages are published yet.
// runs on your server (MQTT needs the API key)
// npm i mqtt
import mqtt from "mqtt"

const API_KEY = "YOUR_API_KEY"
const client = mqtt.connect("wss://api.sports.csap.ai/mqtt", {
  username: API_KEY,
  password: API_KEY,
  clientId: "my-app-" + Math.random().toString(16).slice(2, 10),
  protocolVersion: 4,
})
client.on("connect", () => client.subscribe("football/match/#"))
client.on("message", (topic, payload) => {
  const msg = JSON.parse(payload.toString())
  console.log(topic, msg.data.home_scores, msg.data.away_scores)
})

Sample messages (captured live)

topic: football/match/Mongolian_Premier_League/4230
{
  "type": "match",
  "snapshot": true,
  "data": {
    "match_id": "4230",
    "gmid": "4230",
    "league": "Mongolian Premier League",
    "home_team": "Khaan Khuns-Erchim FC",
    "away_team": "Khovd Western",
    "status": "inprogress",
    "status_id": 2,
    "minute": "16",
    "home_scores": [0, 0, 0, 0, 0, null, 0],
    "away_scores": [0, 0, 0, 0, 0, null, 0],
    "incidents": [{ "type": "match_started", "time": null, "is_home": false, "player_name": null }],
    "stats": [{ "key": "goals", "name": "Goals", "home": "0", "away": "0", "home_num": 0, "away_num": 0 }],
    "version": 31
  }
}

Live test