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
subscribedack, then a snapshot of live matches (snapshot: true), then live changes. - Messages are JSON:
{"type": channel, "data": {...}}; the match id isdata.match_id, the same id as in REST. - Scope by channels, matches (match ids) and leagues (league names); empty means everything.
| Channel | Description |
|---|---|
| match | Score, status, minute and the 7-tuple counts [score, half, red, yellow, corner, extra time, penalties] per side (retained) |
| incident | Goals, cards, substitutions, VAR and other events, as they happen |
| stats | Technical statistics of a match (retained) |
| tlive | Text commentary. Has to be enabled separately: keys without it do not receive this channel (text commentary is also available over REST) |
| animation | Live animation data: ball position, attack momentum, event line |
| odds / odds_market / props | Only 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/ticketwith 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 →
- Every connection needs a new ticket: a ticket is valid for 60 seconds and a single use, so call
POST /api/v1/realtime/ticketagain before each reconnect. - 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.
- Re-send every subscription once connected:
setfor the first rule,subscribefor 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. - A snapshot (
snapshot: true) has exactly the structure of a live change. A match snapshot carries the whole event listincidentsand the statisticsstats, so goals and cards from the time you were offline can be recovered from it. - The
incidentchannel gets no snapshot, and single events from the offline period are not re-sent. If you need them one by one, reconcile againstincidentsin the match snapshot. - 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:
| Channel | Snapshot on connect | Key and rule |
|---|---|---|
| match | Yes | Per 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 |
| incident | No | Per 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 |
| stats | No (inside the match snapshot) | Deliver everything; each message replaces the previous one for that match |
| animation | Yes | Kind 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 / props | Yes | Deliver everything; each message replaces the previous one for that match |
- "Identical content" means the message's
datais deeply equal as a JSON value (key order in objects does not matter,1equals1.0); thesnapshotflag sits outsidedataand is not compared. match.versiongoes 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
seqof an animationballframe 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 flaggedrevised, 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
incidentsclear the event list you already have.
Four pitfalls in subscription messages
unsubscriberemoves rules by channel only. It removes the rules whose channels intersectchannels; a rule without channels (all channels) is not removed;matchesandleaguesplay no part in the matching. With none ofchannels,matchesandleagues, 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),unsubscriberesults in "filtered, zero rules", not "everything minus that channel". To receive all but one channel, list the channels you want withset/subscribe. - The server ignores channel names it does not know.
set/subscribewith only misspelt channels means all channels;unsubscribewith 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
subscribeeach. 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:
setmeans "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'ssetremoves 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 answerssubscribed.
// 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: pingcomment 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", …}}
: pingLive test
MQTT
- URL
wss://api.sports.csap.ai/mqttMQTT 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/+/+/4230for 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
}
}