Delivery · WebSocket
Hold one socket, hear every change
Instead of asking for the live board every few seconds, open a WebSocket and let us tell you what moved: a goal, a set, a red card, a price. You subscribe to channels, receive the full state once, and from then on only the differences, each numbered so you can prove nothing went missing.
- Free: not included
- Starter: not included
- Pro: not included
- Business: included
Swap your key for a ticket
Your API key never appears in a socket URL. You trade it, over ordinary HTTPS, for a short-lived ticket and connect with that.
Call POST /v2/stream/ticket with your Bearer key and no body. The answer holds a url on api.datasportsapi.com with the ticket already in its query string. Open that URL.
- A ticket works once and only for 30 seconds. Mint a new one for every connection and every reconnect.
- Minting is a normal
/v2call: it is authenticated, rate-limited and counted against your quota like any other request. The frames that then arrive on the socket are not requests. - On a plan without the stream the ticket call answers 403ws_not_available.
curl -X POST "https://api.datasportsapi.com/v2/stream/ticket" \
-H "Authorization: Bearer $DATASPORTSAPI_API_KEY"{
"data": {
"ticket": "8373e1…",
"url": "wss://api.datasportsapi.com/?ticket=8373e1…",
"expiresInSeconds": 30
}
}A session, start to finish
Connect, wait for the welcome, subscribe. Each channel opens with a snapshot and continues with small, numbered frames.
- You send:
POST /v2/stream/ticket
plain HTTPS, with your Bearer key
- You receive:
{ ticket, url, expiresInSeconds: 30 }
url is wss://api.datasportsapi.com/?ticket=…
- You send:
open url
once, within 30 seconds
- You receive:
welcome
protocol · heartbeatMs · maxChannels · replay
- You send:
subscribe "livescores"
repeat for each channel you want
- You receive:
snapshot · seq 1000
everything the channel holds right now
- You receive:
fixture · delta · seq 1001, 1002 …
only what changed, one seq per frame
- connection drops
- You send:
new ticket → subscribe, lastSeq 1002
within five minutes of the gap
- You receive:
missed frames → subscribed
replayed tells you how many
On the wire
What a livescores subscription looks like once it is running. Each delta names one fixture and carries only the fields that changed. The log below is driven by our match simulator, not the API.
- Received:
{ "type": "welcome", "protocol": 4, "heartbeatMs": 30000, "maxChannels": 50, "replay": true } - Sent:
{ "type": "subscribe", "channel": "livescores" } - Received:
{ "type": "snapshot", "channel": "livescores", "seq": 1762790400000, "ts": 1762790400000, "data": [ /* 12 fixtures */ ] } - Received:
{ "type": "delta", "channel": "livescores", "seq": 1762790400001, "ts": 1762790401533, "id": 481004, "sport": "basketball", "changes": { "homeScore": { "current": 90 } } } - Received:
{ "type": "delta", "channel": "livescores", "seq": 1762790400002, "ts": 1762790402632, "id": 481903, "sport": "handball", "changes": { "homeScore": { "current": 18 } } } - Received:
{ "type": "delta", "channel": "livescores", "seq": 1762790400003, "ts": 1762790406602, "id": 481004, "sport": "basketball", "changes": { "awayScore": { "current": 86 } } } - Received:
{ "type": "delta", "channel": "livescores", "seq": 1762790400004, "ts": 1762790408594, "id": 481388, "sport": "ice-hockey", "changes": { "awayScore": { "current": 4 } } } - Received:
{ "type": "delta", "channel": "livescores", "seq": 1762790400005, "ts": 1762790409730, "id": 481903, "sport": "handball", "changes": { "awayScore": { "current": 16 } } } - Received:
{ "type": "delta", "channel": "livescores", "seq": 1762790400006, "ts": 1762790415529, "id": 481777, "sport": "cricket", "changes": { "awayScore": { "current": 96 } } }
Seven kinds of channel
A channel is a name you subscribe to. Four of them are lists of live matches at different zoom levels; three follow a single thing.
| Channel | Snapshot on subscribe | Then |
|---|---|---|
livescoresEvery match in play, across all 24 sports. | Fixture[]every fixture in progress right now. | A fixture frame when a match joins (it kicks off, or the server sees it for the first time), then deltas for the matches you hold. A match that leaves gets one final delta that says why. |
sport:{slug}One sport, by its slug (football, tennis, ice-hockey …). | Fixture[]that sport's matches in progress. | Same as livescores, narrowed to the sport.Unknown id → error unknown_channel |
league:{id}One competition, by our league id. | Fixture[]that league's matches in progress. | Fixture frames as matches join, deltas for the ones you hold.Unknown id → error unknown_league |
team:{id}One team, home or away, by our team id. | Fixture[]the team's matches in progress. | The league channel's frames, filtered to one team.Unknown id → error unknown_team |
fixture:{id}One match, in depth. | Fixture | nullthe match, or null when it is unavailable. | Deltas, plus whole events, statistics and lineups frames and, for some sports, play by play, momentum, shot map, win probability and AI commentary.Unknown id → error unknown_fixture |
odds:{id}One match's betting markets, by fixture id. | Oddsthe markets, exactly as GET /v2/fixtures/{id}/odds returns them. | Odds frames carrying each changed market whole, plus the keys of markets that were withdrawn. No other channel carries prices.Unknown id → error unknown_fixture |
race:{id}One motorsport or cycling race. | RaceSnapshot{ race, session, results }: the race, the running (or last finished) session and its classification. | A race frame when a session starts or ends or the race changes; a raceResults frame when the running session's order changes.Unknown id → error unknown_race |
Race sports have no live feed, so race channels are fed by polling: the race is re-read every 60 seconds while a session runs or is about to start within 15 minutes, the running session's results every 30 seconds, and a finished session's results every 60 seconds for half an hour afterwards, since fastest laps and winning times are often filled in after the flag. On odds:{id}, price moves arrive within about a second, while suspensions, new lines and settlement depend on a re-read that runs every 30 seconds in play and every two minutes before kick-off.
Extra frames for one match
Subscribe to fixture:{id} and, on top of the deltas, you receive the rest of the match centre as it changes, so you can stop polling those endpoints.
| Frame | Sports | When it comes | What to do with it |
|---|---|---|---|
events | All, while in play | Re-read about 20 s and 45 s after every score, red card or status change, and every 30 s besides. | The whole keyed list: replace your copy. changes names what was added, updated or removed, so a goal ruled out by VAR shows up under removed. |
statistics | All, while in play | Polled every 30 s, once more after full time. Typically 30 to 60 s behind the pitch. | The whole statistics object: replace. |
lineups | All | Before kick-off, a delta flips lineupsAvailable or lineupsConfirmed the moment the source announces them and this frame follows within seconds. In play, polled every 30 s until confirmed and unchanged for 10 minutes. | Both sides, per-player statistics included: replace. Substitutions arrive as events. |
playByPlay | Football, basketball | Polled every 20 s, once more after full time. | Truncate your list to its first start entries, then append the new ones. When start is past the end of what you hold, fetch the REST list again. |
momentum | Football | Polled every 30 s; one point per minute, so roughly a frame a minute. | The whole object: replace. |
shotmap | Football | Polled every 30 s. Typically 30 to 60 s behind the pitch. | The whole list of shots: replace. |
winProbability | Football | When any outcome moves by at least one percentage point. | model and point: the latest estimate and why it moved. Draw the curve from the REST endpoint and use this to keep it current. |
commentary | Football | Each time a new AI commentary entry is written. | entry: append it. Its own entry.seq is not the channel seq. |
These frames are built by re-reading the matching REST endpoints on our side, and are only sent when the content actually changed. Read each endpoint once when you subscribe, then let the frames keep it current. Play by play, momentum and shot map frames are produced for at most 20 fixtures at a time, the ones with the most subscribers.
Four things you can send
Everything you send is a small JSON object with a type.
{ "type": "subscribe", "channel": "livescores" }answered with snapshot
Starts a channel. Add lastSeq with the last seq you hold and you get the missed frames instead, closed by a subscribed frame. Subscribing twice to the same channel just answers subscribed with replayed 0.
{ "type": "unsubscribe", "channel": "sport:tennis" }answered with unsubscribed
Stops a channel and frees its slot. Answers error not_subscribed if the connection was not on it.
{ "type": "resync", "channel": "livescores" }answered with snapshot
Requests a new snapshot of a channel you are already subscribed to. Use it when you spot a gap, or now and then to re-anchor.
{ "type": "ping" }answered with pong
An application-level ping, handy for measuring round trips. The server answers with its time in ts.
Everything the server sends
Switch on type. Field-by-field definitions of every frame are in the data reference.
| type | Where | What your client does |
|---|---|---|
welcome | Once, right after connecting | Read protocol, heartbeatMs, maxChannels and replay, then send your subscribes. |
snapshot | On subscribe and resync, or when a gap is too old to replay | Replace everything you hold for that channel. Its seq is the new baseline. |
fixture | livescores, sport, league and team channels | Add the match to your list. Deltas for it follow. |
delta | Every channel that carries fixtures | Deep-merge changes into the fixture with that id. |
subscribed | When a lastSeq replay finishes, or when you subscribe to a channel again | Take its seq as current; replayed says how many frames were resent. |
unsubscribed | After an unsubscribe | Forget that channel's state. |
eventsstatisticslineupsmomentumshotmap | fixture:{id} | Replace your copy: each frame is complete. |
playByPlay | fixture:{id} | Truncate to start, then append entries. |
winProbabilitycommentary | fixture:{id}, football | Update the current estimate; append the commentary entry. |
odds | odds:{id} | Replace or add each market by key; delete the keys in removed. |
raceraceResults | race:{id} | Replace the race; replace that session's classification. |
pingpong | Every heartbeatMs, and in reply to your ping | Nothing to do for ping. pong carries the server time. |
error | When something you sent is refused | Read code. The connection stays open. |
Shapes of every frame: WebSocket frames in the data reference.
Merging deltas
A delta's changes is a partial Fixture with the same field names and nesting. Merge it; never replace the fixture with it.
The rule fits in one line:
objects merge key by key; every other value replaces.
So homeScore, clock or cricket innings are patched field by field, while a number, a string, an array or an explicit null overwrites what you had. A null is information, such as a clock that stopped at full time, so do not skip it.
The changes of a webhook event use exactly the same shape, so one merge function serves both.
// You hold (from the snapshot):
{ "id": 480112, "homeScore": { "current": 1, "periods": { "period1": 1 } }, "awayScore": { "current": 1, … }, … }
// A delta arrives:
{ "type": "delta", "channel": "livescores", "seq": 1762790400213, "ts": 1762790463120,
"id": 480112, "sport": "football",
"changes": { "homeScore": { "current": 2, "periods": { "period2": 1 } } } }
// After a deep merge: period1 survives, period2 and current are new
{ "id": 480112, "homeScore": { "current": 2, "periods": { "period1": 1, "period2": 1 } }, … }- Scores that go down
- ≤ 1.5 s late · football ≤ 5 s
A disallowed goal or corrected basket is held back briefly, together with its lowered period scores, because the source sometimes repeats an older score for a moment. Those flickers are filtered out.
- Exactness
- resync now and then
A REST read of the fixture can be a few seconds newer than the stream. If you need the record exact, resync a channel occasionally or re-read the fixture after full time.
Sequence numbers and replay
Every frame on a channel carries seq, and each one is exactly one more than the last. That is how you know you have seen everything.
The number is opaque. It starts from a server clock, not from zero, so never assume a value; only compare with the previous frame on the same channel. Snapshots and subscribed frames set the baseline, and the next frame is baseline + 1.
When the next number is not the one you expected, a frame went missing. You have two ways back:
- On an open connection: send
resyncfor that channel and rebuild from the snapshot. - After a drop: mint a new ticket, reconnect, and subscribe with
lastSeqset to the last number you hold. The server replays what you missed and then confirms withsubscribed.
The server keeps five minutes of frames per channel. Ask for a gap older than that and you simply get a fresh snapshot, which your snapshot handler already deals with.
- 1000snapshot
- 1001delta
- 1002delta
- 1003lost
- 1004delta
1004 ≠ 1002 + 1 → send resync, or reconnect and subscribe with lastSeq: 1002 to have 1003 replayed.
Connections, channels and heartbeats
- Concurrent connections
- 5 per account
Shared by every key. A sixth connection is closed with 1013. Hold the socket on your server and fan out to your users from there.
- Channels per connection
- maxChannels, from welcome
Read it from the welcome frame instead of hard-coding it. Going over answers too_many_channels.
- Frames you may send
- 60 per 10 seconds
Per connection. Above that you get rate_limited and frames are dropped until the window resets.
- Heartbeat
- every heartbeatMs
The server sends a protocol-level ping, plus a ping frame. Browsers and most WebSocket libraries answer protocol pings automatically; a connection that stops answering is dropped.
- Replay window
- 5 minutes per channel
Long enough to ride out a deploy or a network blip without a fresh snapshot.
- Ticket
- 30 s · single use
Minting one is one request against your quota and per-minute rate.
Errors and close codes
A problem with something you sent comes back as an error frame and the connection stays up. Only two situations close it.
1008
When connecting: the ticket is invalid, has expired or was already used — mint a new one. On a connection that is already open: your key was revoked, your account was suspended or your plan changed, and the close reason tells you which. “plan changed; reconnect” means a new ticket will do.
1013
Your account already holds as many connections as the plan allows. Close one, or back off and try again later.
| code | Meaning |
|---|---|
bad_frame | The frame was not JSON, not a shape we know, or missing a field. message says which field, e.g. "channel: required". |
unknown_channel | Not a channel we serve, or a sport slug that does not exist. |
channel_forbidden | Your plan does not include that channel. |
unknown_league | The id in league:{id} is not one we issued. Take league ids from /v2/leagues or from a fixture's league. |
unknown_team | The id in team:{id} is not one we issued. Take team ids from /v2/teams or from a fixture's home and away. |
unknown_fixture | The id in fixture:{id} or odds:{id} is not a fixture we issued. The attempt does not use up a channel slot. |
unknown_race | race:{id} is not a race: perhaps a season or session id. It does not use up a channel slot either. |
not_subscribed | You sent resync or unsubscribe for a channel this connection is not on. |
too_many_channels | This connection already holds maxChannels channels. Unsubscribe from one, or open another connection. |
snapshot_failed | We could not build the snapshot just then. Send resync to try again; for league:{id}, subscribe again. |
rate_limited | More than 60 frames from you in 10 seconds. Further frames are dropped until the window resets. |
{ "type": "error", "code": "unknown_fixture", "channel": "fixture:999999999999",
"message": "no fixture with that id", "ts": 1762790431000 }A complete client
About a hundred lines that mint tickets, subscribe, merge, detect gaps, resync and reconnect with replay. No dependencies.
// live.ts: a merged, self-healing view of every match in play.
// Runs on Node 22+, Deno and Bun, which ship a global WebSocket.
const API = "https://api.datasportsapi.com";
const KEY = process.env.DATASPORTSAPI_API_KEY!;
const CHANNELS = ["livescores"];
type Json = Record<string, unknown>;
type Fixture = Json & { id: number };
type Frame = Json & { type: string; channel?: string; seq?: number; ts: number };
const fixtures = new Map<number, Fixture>(); // what your UI renders
const lastSeq = new Map<string, number>(); // per channel: gap checks and replay
const resyncing = new Set<string>(); // channels waiting for a fresh snapshot
let backoff = 1_000;
const isObject = (v: unknown): v is Json => typeof v === "object" && v !== null && !Array.isArray(v);
/** Plain objects are merged field by field; anything else (null and arrays too) overwrites. */
function merge(target: Json, patch: Json) {
for (const [key, value] of Object.entries(patch)) {
const current = target[key];
if (isObject(value) && isObject(current)) merge(current, value);
else target[key] = value;
}
}
async function mintTicket(): Promise<string> {
const res = await fetch(`${API}/v2/stream/ticket`, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}` },
});
const body = await res.json();
if (!res.ok) throw new Error(`ticket refused: ${res.status} ${body.error.code}`);
return body.data.url; // wss://api.datasportsapi.com/?ticket=…, single use, valid for 30 seconds
}
function send(ws: WebSocket, frame: Json) {
ws.send(JSON.stringify(frame));
}
function onFrame(ws: WebSocket, f: Frame) {
// Frames after the baseline must arrive as seq + 1 on their channel.
const ordered = f.channel && f.seq !== undefined && f.type !== "snapshot" && f.type !== "subscribed";
if (ordered) {
if (resyncing.has(f.channel!)) return; // a snapshot is on its way
const prev = lastSeq.get(f.channel!);
if (prev !== undefined && f.seq !== prev + 1) {
resyncing.add(f.channel!);
send(ws, { type: "resync", channel: f.channel });
return;
}
}
if (f.channel && f.seq !== undefined) lastSeq.set(f.channel, f.seq);
switch (f.type) {
case "welcome":
backoff = 1_000;
for (const channel of CHANNELS) {
const seq = lastSeq.get(channel);
// After a drop, lastSeq asks for the missed frames instead of a new snapshot.
send(ws, seq === undefined ? { type: "subscribe", channel } : { type: "subscribe", channel, lastSeq: seq });
}
break;
case "snapshot": // one channel here, so the snapshot is the whole picture
resyncing.delete(f.channel!);
fixtures.clear();
for (const fx of f.data as Fixture[]) fixtures.set(fx.id, fx);
break;
case "fixture": // a match kicked off, or the server saw it for the first time
fixtures.set(f.id as number, f.data as Fixture);
break;
case "delta": {
const fx = fixtures.get(f.id as number);
if (!fx) break;
merge(fx, f.changes as Json);
// The last delta of a match that leaves the channel says why.
const status = fx.status as { type?: string } | undefined;
if (status?.type === "finished" || status?.type === "canceled") fixtures.delete(fx.id);
break;
}
case "error":
console.warn(`stream error ${f.code}: ${f.message}`);
if (f.code === "snapshot_failed" && f.channel) send(ws, { type: "resync", channel: f.channel });
break;
}
}
function retry(wait = backoff) {
backoff = Math.min(backoff * 2, 30_000);
setTimeout(start, wait);
}
async function connect() {
const ws = new WebSocket(await mintTicket());
ws.onmessage = (ev) => onFrame(ws, JSON.parse(String(ev.data)));
ws.onclose = (ev) => {
// 1013: every connection your account may hold is open. Wait longer.
// 1008: bad ticket, or the key or plan changed; ev.reason says which.
resyncing.clear();
retry(ev.code === 1013 ? 60_000 : backoff);
};
}
function start() {
connect().catch((err) => {
console.error(err); // a 401 or 403 on the ticket will not heal itself: alert a human
retry();
});
}
start();Prefer not to write this yourself? Each of the official SDKs ships a stream client that mints tickets, reconnects with replay and merges deltas for you, and the dsa CLI can watch a channel from your terminal.