← 返回接口文档

实时推送

比分、事件、统计、文字直播、动画数据三种方式推送:WebSocket(双向,可随时改订阅)、SSE(单向,最简单)、MQTT(适合已有 MQTT 基础设施)。三种方式数据相同。

开通与频道

密钥规则:API Key 只存在于你的服务端。浏览器和 App 不拿 Key:由你的服务端用 Key 换一次性票据(60 秒内有效、只能用一次)交给前端,前端只用票据连接 WebSocket。SSE 和 MQTT 要带 Key,所以只在服务端用。

  • 实时推送需要 key 开通包月实时权限;未开通时返回 403「实时推送需包月订阅」。
  • 连上后先收到一条 subscribed 回执,接着是当前进行中比赛的快照(snapshot: true),之后是实时变动。
  • 消息都是 JSON:{"type": 频道名, "data": {...}},比赛 id 在 data.match_id(与 REST 接口的比赛 id 相同)。
  • 订阅范围:频道(channels)、比赛(matches,比赛 id)、联赛(leagues,联赛名);都不填就是全部。
频道说明
match比分、状态、比赛分钟,主客队 7 元组 [比分, 半场, 红牌, 黄牌, 角球, 加时, 点球](保留最新一条)
incident进球、红黄牌、换人、VAR 等事件,发生即推
stats本场技术统计(保留最新一条)
tlive文字直播。需单独开通:没开通的 key 收不到这个频道(文字直播也可以用 REST 接口取)
animation动画直播数据:球的位置、进攻势能、事件线
odds / odds_market / props需要 key 开通对应数据域

WebSocket

地址
wss://api.sports.csap.ai/ws?ticket=<ticket>
鉴权
先 POST https://api.sports.csap.ai/api/v1/realtime/ticket(带 X-API-Key)换一次性票据,60 秒内用它连接,用过即失效。key 不会出现在 WebSocket 地址里。
订阅
连接后发送 JSON:{"action": "set", "channels": ["match"], "matches": ["4230"]}。action 可选 set(替换)、subscribe(追加)、unsubscribe(去掉)、all(恢复全部)。也可以在地址上带 &channels=&matches=&leagues=。
// 1) 在你的服务端(Node 18+):用 key 换一次性票据(60 秒内有效,只能用一次)
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) 在浏览器 / App 里,或在服务端:只用票据连接(把 data.ws_url 交给前端,不要给 key)。初始订阅写在地址上,连上时推的快照也会按它过滤
const ws = new WebSocket("wss://api.sports.csap.ai" + data.ws_url + "&channels=match,incident")
// 之后要改订阅: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)
}
// 断线后重新取票据再连;重连后的快照会把断线期间的变化补上(见下面「断线、重连与补齐」)

消息样例(实测)

{"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
  }
}

试连

断线、重连与补齐

服务端没有消息序号,也没有重放缓冲:断线期间的消息不会补发。补齐靠两件事:重连后服务端再推一遍的快照,和客户端按下面的键去重。各语言的 SDK 已经按这套规则实现,自己写客户端时请照着做。 用 SDK 就不用自己实现 →

  1. 每次连接都要一张新票据:票据 60 秒内有效、只能用一次,重连前重新调 POST /api/v1/realtime/ticket。
  2. 重连间隔建议用指数退避加随机抖动:1 秒起、每次翻倍、最长 30 秒;重新收到数据后从 1 秒重新算。
  3. 连上后重发全部订阅:第一条规则用 set,其余用 subscribe。没在地址上带订阅的连接,服务端最多等 1 秒收第一条订阅消息,再按它推快照;之后追加的规则不会再触发快照,对应的数据要等下一次变动或下次重连。
  4. 快照(snapshot: true)和实时变动的结构完全一样。比赛快照带整场事件表 incidents 和统计 stats,断线期间的进球、红黄牌都能从快照里对出来。
  5. 事件频道 incident 不推快照,断线期间的单条事件不会补发。需要逐条事件时,用比赛快照里的 incidents 对账。
  6. 这些情况不要重连:换票据返回 401 / 403(key 无效或已停用、没开实时推送、IP 不在 key 的白名单);换票据返回 429 且是每日配额用完(当天换不到票据,次日再连);WebSocket 升级返回 403(IP 不在白名单;浏览器看不到这个状态码,只能照常重试);服务端以关闭码 1008 断开(服务端每 30 秒复核一次 key 的实时权限,权限被收回时这样断开)。其余情况都重试:换票据遇到每分钟限速、5xx、网络错误;升级返回 401(票据过期或已用过)、503(连接数满)、连接超时。

按什么去重

重连后的快照会把你已经收到过的内容再推一遍,服务端平时也可能重发同一条。只丢弃「内容完全相同」的重复,不要按版本号或序号一刀切:

频道连上推快照去重键和规则
match是按 match_id:version 比已见最大值小的丢弃;相等时,只有内容和上一条保留的完全相同才丢弃;更大的照常处理。没有 version 的不丢
incident否按 dkey:完全重复(内容相同)的丢弃;dkey 相同但内容不同的照常处理,它是补全 / 更正,或同一分钟的另一个事件(SDK 标 revised)。没有 dkey 的不丢
stats否(在 match 快照里)都处理;每条覆盖这场的上一条
animation是kind 为 ball:只有和这场上一帧完全相同才丢弃(seq 是场面码,不是序号)。其它 kind 都处理
odds / odds_market / props是都处理;每条覆盖这场的上一条
  • 「内容相同」指消息的 data 作为 JSON 值深度相等(对象不看键的顺序,1 和 1.0 相等);snapshot 标记在 data 外面,不参与比较。
  • match.version 只在比分、状态、分钟变化时才加一。只补了事件或统计时,服务端会用同一个 version 再推一次,所以不能「version 相等就丢」。
  • 动画 ball 帧的 seq 是场面码,不是递增序号,不能拿它排序或去重。
  • 同一分钟同一方的两个同类事件(比如一次换两个人)dkey 相同;同一事件后来补全字段再推一次,dkey 也相同。SDK 把这种情况标成 revised 交给你,由你按内容判断(比较球员,或以比赛消息里的整场事件表为准)。
  • 连接快照里的比赛消息有时只有比分、没有事件表,和之前收到的同版本消息内容不同,会照常给你。合并状态时,不要用缺少 incidents 的同版本快照清空已有的事件表。

订阅消息的四个坑

  • unsubscribe 只按频道去规则。它去掉「频道与 channels 有交集」的规则;没写频道(全频道)的规则不会被去掉;matches、leagues 不参与匹配。channels、matches、leagues 都不带 = 全退订。
  • 先收全量、再退订一个频道 = 什么都收不到。连接是全量(all,或一直没发过订阅)时发 unsubscribe,结果是「已过滤、零条规则」,不是「全量减去这个频道」。想收除某个频道以外的全部,用 set / subscribe 把要的频道列出来。
  • 服务端会忽略不认识的频道名。set / subscribe 里的频道全写错 = 全频道;unsubscribe 里的频道全写错 = 全退订。SDK 会在本地拒绝不在频道清单里的名字;自己写客户端时先校验。
  • 一条订阅消息不能超过 8192 字节(UTF-8)。超过时服务端以关闭码 1009 断开;重连后重发同一条又被断,变成死循环。大约 700 个比赛 id 就会超限,请拆成多条规则分别 subscribe。SDK 会在本地拒绝超长的消息。

心跳,和一个容易踩的坑

  • 服务端每 30 秒发一次 WebSocket ping,70 秒内收不到任何回应就断开。浏览器和大多数 WebSocket 库会自动回 pong,不用自己处理。
  • 浏览器里看不到 ping 帧,没有比赛时又可能很久没有数据。想主动发现已经断掉的连接,可以定时发一条订阅消息当心跳:每条订阅消息服务端都会回一条 subscribed,发出后 10 秒内什么都没收到,就断开重连。
  • 注意:set 的意思是「把全部规则替换成这一条」。连接上只有一条规则时,重发它没有副作用,也不会重推快照;有两条及以上规则时,重发第一条规则的 set 会把其余规则全部去掉。这时心跳要改发下面第二条消息:它只带一个不存在的比赛 id、不带频道,服务端什么都不会去掉,照常回 subscribed。
// 连接上只有一条规则:重发它没有副作用
{"action": "set", "channels": ["match"], "matches": ["4230"]}

// 两条及以上规则:发一条不改变订阅的消息,服务端照常回 subscribed
{"action": "unsubscribe", "matches": ["__heartbeat__"]}

SSE(Server-Sent Events)

地址
GET https://api.sports.csap.ai/sse?channels=match,incident&matches=&leagues=
鉴权
请求头 X-API-Key。SSE 要带 key,所以适合服务端,或者由你的服务端转发给浏览器;浏览器直接看实时数据请用 WebSocket 加票据。浏览器的 EventSource 不能带请求头,只能写 ?key=:仍可用,但不建议,key 会留在地址和日志里。
订阅
由地址参数决定:channels、matches、leagues,逗号分隔;要改订阅就重新连接。服务器每 25 秒发一次 : ping 注释行保活。
curl -N "https://api.sports.csap.ai/sse?channels=match,incident" -H "X-API-Key: YOUR_API_KEY"

消息样例(实测)

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

试连

MQTT

地址
wss://api.sports.csap.ai/mqtt MQTT over WebSocket(3.1.1);暂不开放 TCP 1883。
鉴权
用户名和密码都填你的 key;clientId 自定,每个连接唯一。只能订阅,不能发布。
主题
football/<频道>/<联赛名>/<比赛 id>,联赛名里的空格、/、+、# 换成下划线,没有联赛时为 _。例:football/match/# 订阅全部比分,football/+/+/4230 只订一场比赛。
规划中
兼容主题 sports/football/match.v5:规划中,现在可以订阅但还不会收到消息。
// 在你的服务端运行(MQTT 要带 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)
})

消息样例(实测)

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
  }
}

试连