Skip to content
datasportsAPI

DeliveryWebSocket stream

Documentation

/

Type to filter the docs by title or path.

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
Protocol 45 connections / account5-minute replay
01

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 /v2 call: 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"
Response
{
  "data": {
    "ticket": "8373e1…",
    "url": "wss://api.datasportsapi.com/?ticket=8373e1…",
    "expiresInSeconds": 30
  }
}
02

A session, start to finish

Connect, wait for the welcome, subscribe. Each channel opens with a snapshot and continues with small, numbered frames.

Sequence of a WebSocket session: mint a ticket, connect, receive welcome, subscribe, receive a snapshot and then deltas; after a drop, mint a new ticket and resubscribe with the last sequence number to replay what was missed.
  1. You send:

    POST /v2/stream/ticket

    plain HTTPS, with your Bearer key

  2. You receive:

    { ticket, url, expiresInSeconds: 30 }

    url is wss://api.datasportsapi.com/?ticket=…

  3. You send:

    open url

    once, within 30 seconds

  4. You receive:

    welcome

    protocol · heartbeatMs · maxChannels · replay

  5. You send:

    subscribe "livescores"

    repeat for each channel you want

  6. You receive:

    snapshot · seq 1000

    everything the channel holds right now

  7. You receive:

    fixture · delta · seq 1001, 1002 …

    only what changed, one seq per frame

  8. connection drops
  9. You send:

    new ticket → subscribe, lastSeq 1002

    within five minutes of the gap

  10. 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.

One connection, channel livescoresSample data
  1. Received: { "type": "welcome", "protocol": 4, "heartbeatMs": 30000, "maxChannels": 50, "replay": true }
  2. Sent: { "type": "subscribe", "channel": "livescores" }
  3. Received: { "type": "snapshot", "channel": "livescores", "seq": 1762790400000, "ts": 1762790400000, "data": [ /* 12 fixtures */ ] }
  4. Received: { "type": "delta", "channel": "livescores", "seq": 1762790400001, "ts": 1762790401533, "id": 481004, "sport": "basketball", "changes": { "homeScore": { "current": 90 } } }
  5. Received: { "type": "delta", "channel": "livescores", "seq": 1762790400002, "ts": 1762790402632, "id": 481903, "sport": "handball", "changes": { "homeScore": { "current": 18 } } }
  6. Received: { "type": "delta", "channel": "livescores", "seq": 1762790400003, "ts": 1762790406602, "id": 481004, "sport": "basketball", "changes": { "awayScore": { "current": 86 } } }
  7. Received: { "type": "delta", "channel": "livescores", "seq": 1762790400004, "ts": 1762790408594, "id": 481388, "sport": "ice-hockey", "changes": { "awayScore": { "current": 4 } } }
  8. Received: { "type": "delta", "channel": "livescores", "seq": 1762790400005, "ts": 1762790409730, "id": 481903, "sport": "handball", "changes": { "awayScore": { "current": 16 } } }
  9. Received: { "type": "delta", "channel": "livescores", "seq": 1762790400006, "ts": 1762790415529, "id": 481777, "sport": "cricket", "changes": { "awayScore": { "current": 96 } } }
03

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.

ChannelSnapshot on subscribeThen
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.

04

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.

FrameSportsWhen it comesWhat to do with it
eventsAll, while in playRe-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.
statisticsAll, while in playPolled every 30 s, once more after full time. Typically 30 to 60 s behind the pitch.The whole statistics object: replace.
lineupsAllBefore 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.
playByPlayFootball, basketballPolled 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.
momentumFootballPolled every 30 s; one point per minute, so roughly a frame a minute.The whole object: replace.
shotmapFootballPolled every 30 s. Typically 30 to 60 s behind the pitch.The whole list of shots: replace.
winProbabilityFootballWhen 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.
commentaryFootballEach 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.

05

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.

06

Everything the server sends

Switch on type. Field-by-field definitions of every frame are in the data reference.

typeWhereWhat your client does
welcomeOnce, right after connectingRead protocol, heartbeatMs, maxChannels and replay, then send your subscribes.
snapshotOn subscribe and resync, or when a gap is too old to replayReplace everything you hold for that channel. Its seq is the new baseline.
fixturelivescores, sport, league and team channelsAdd the match to your list. Deltas for it follow.
deltaEvery channel that carries fixturesDeep-merge changes into the fixture with that id.
subscribedWhen a lastSeq replay finishes, or when you subscribe to a channel againTake its seq as current; replayed says how many frames were resent.
unsubscribedAfter an unsubscribeForget that channel's state.
eventsstatisticslineupsmomentumshotmapfixture:{id}Replace your copy: each frame is complete.
playByPlayfixture:{id}Truncate to start, then append entries.
winProbabilitycommentaryfixture:{id}, footballUpdate the current estimate; append the commentary entry.
oddsodds:{id}Replace or add each market by key; delete the keys in removed.
raceraceResultsrace:{id}Replace the race; replace that session's classification.
pingpongEvery heartbeatMs, and in reply to your pingNothing to do for ping. pong carries the server time.
errorWhen something you sent is refusedRead code. The connection stays open.

Shapes of every frame: WebSocket frames in the data reference.

07

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.

Deep merge, by example
// 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.

08

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 resync for that channel and rebuild from the snapshot.
  • After a drop: mint a new ticket, reconnect, and subscribe with lastSeq set to the last number you hold. The server replays what you missed and then confirms with subscribed.

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.

One channel, five frames, one missing
  1. 1000snapshot
  2. 1001delta
  3. 1002delta
  4. 1003lost
  5. 1004delta

1004 ≠ 1002 + 1 → send resync, or reconnect and subscribe with lastSeq: 1002 to have 1003 replayed.

09

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.

10

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.

codeMeaning
bad_frameThe frame was not JSON, not a shape we know, or missing a field. message says which field, e.g. "channel: required".
unknown_channelNot a channel we serve, or a sport slug that does not exist.
channel_forbiddenYour plan does not include that channel.
unknown_leagueThe id in league:{id} is not one we issued. Take league ids from /v2/leagues or from a fixture's league.
unknown_teamThe id in team:{id} is not one we issued. Take team ids from /v2/teams or from a fixture's home and away.
unknown_fixtureThe id in fixture:{id} or odds:{id} is not a fixture we issued. The attempt does not use up a channel slot.
unknown_racerace:{id} is not a race: perhaps a season or session id. It does not use up a channel slot either.
not_subscribedYou sent resync or unsubscribe for a channel this connection is not on.
too_many_channelsThis connection already holds maxChannels channels. Unsubscribe from one, or open another connection.
snapshot_failedWe could not build the snapshot just then. Send resync to try again; for league:{id}, subscribe again.
rate_limitedMore than 60 frames from you in 10 seconds. Further frames are dropped until the window resets.
An error frame
{ "type": "error", "code": "unknown_fixture", "channel": "fixture:999999999999",
  "message": "no fixture with that id", "ts": 1762790431000 }
11

A complete client

About a hundred lines that mint tickets, subscribe, merge, detect gaps, resync and reconnect with replay. No dependencies.

live.ts
// 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.