WebSockets
Live data is delivered through socket.io rooms bridged to the orchestrator's Redis pub/sub. Subscribe to one or more rooms, receive typed events as they fire. Score changes are pushed as our poller detects them; while a match is live that poll cycle completes about every 12 seconds, and every event carries the timestamps you need to measure it yourself — see Measuring latency. Requires the Pro plan or higher.
connect_error reading “Real-time WebSocket access requires the Pro plan or higher.” This is a WebSocket handshake, not an HTTP request, so there is no status code and no plan_required body to inspect — read err.message.Connecting
import { io } from 'socket.io-client';
const socket = io('https://api.bigballsdata.com', {
path: '/live', // required — see below
auth: { apiKey: 'bbs_live_a1b2c3d4e5f6...' },
transports: ['websocket'],
});
socket.on('connect', () => {
socket.emit('join', 'sport:football'); // a string, not an object
});
socket.on('connect_error', (err) => {
// "missing API key" | "invalid API key" | the plan-gate message
console.error(err.message);
});path: '/live' is not optional. socket.io-client defaults to /socket.io, which this API does not serve — omit it and the connection fails before authentication is ever reached.
Pass the API key as auth.apiKey, or as an x-api-key header; both work and the header matches REST. Rejection arrives as a socket.io connect_error whose message is one of missing API key, invalid API key, or the plan-gate message — there is no HTTP status code on this path to read.
transports: ['websocket'] is supported and is what we recommend for latency-sensitive clients: it skips socket.io’s HTTP long-polling handshake and goes straight to a real WebSocket. Omitting it is also fine — socket.io then starts on polling and upgrades — but you pay the first round trip on polling either way. Plain ws, or any other RFC-6455 client, can drive this endpoint directly too: connect to /live/?EIO=4&transport=websocket and speak the engine.io/socket.io packet format yourself.
RSV1 must be clear / WS_ERR_UNEXPECTED_RSV_1? That error names compression and is misleading — it means your client received an HTTP response on a socket that had already been upgraded. It was our bug, fixed 2026-09-07: the gateway answered the upgrade with 101 Switching Protocols and then wrote an HTTP/1.1 400 onto the same socket, so no RFC-6455 client could survive the handshake while socket.io’s default transports quietly fell back to polling and kept working. If you still hit it, you are reaching a build from before that date — drop the transports option as a temporary workaround, and please tell us, because we want to know.Room model
Three room scopes; join as many as you need, and joining the same room again is harmless. The server adds you to the room and does nothing else, so a repeat join neither duplicates your subscription nor silences it. league: and match: take the same uuids REST returns, not slugs:
| Room | Scope | Example |
|---|---|---|
| sport:<sport> | All matches in a sport | sport:football |
| league:<leagueId> | All matches in a league | league:64184273-6963-… |
| match:<matchId> | A single match | match:91056403-2c0d-… |
Joining a broader room (sport:*) does not auto-deliver events that would also match a narrower room, each emit hits every room it matches, but the client only receives an event once per connection.
Heartbeat
Every sport: room emits a heartbeat every 30 seconds, whether or not anything is playing. It is the answer to "is this feed alive, or just quiet?" — a question a live-score socket otherwise cannot answer, because a working feed on a quiet afternoon and a broken one look identical.
socket.emit('join', 'sport:football');
socket.on('heartbeat', (hb) => {
// {
// sport: 'football',
// seq: 412, // monotonic per sport, resets on our redeploy
// emitted_at: '2026-09-06T18:15:00.000Z',
// worker_started_at: '2026-09-06T17:00:00.000Z',
// last_tick_at: '2026-09-06T18:14:45.000Z',
// last_tick_age_seconds: 15, // how long since we last polled upstream
// last_event_at: '2026-09-06T18:12:30.000Z',
// last_event_age_seconds: 150, // how long since this sport last changed
// live_matches: 2,
// interval_seconds: 30
// }
});How to read it:
- Beats arriving,
live_matches: 0— healthy and quiet. Nothing is playing in that sport. This is the case that used to be indistinguishable from a fault. - No beat for 90 seconds (two missed intervals) — treat the feed as down and reconcile over REST. That is our signal to fix something, so tell us.
- Beats arriving but
last_tick_age_secondsclimbing — we are connected to you but our upstream poll is stalled. Scores may be stale. seqjumped backwards — a new publisher process, not lost frames;worker_started_atwill have changed too. A gap inseqwithout a change toworker_started_atis a genuinely dropped frame.
Heartbeats go to sport: rooms only — the signal is identical for every match in a sport, so sending it per match would multiply traffic to say the same thing. If you subscribe to match: or league: rooms, join the matching sport: room alongside them for liveness. Sports we do not run a live feed for emit no heartbeat, because there is nothing alive to report.
Connected, but receiving nothing
This is the most common report, and in almost every case the socket is working. A connection on its own delivers nothing — there is no default subscription, and no error is sent to tell you so. If you are joined to a sport: room, the heartbeat above settles it in 30 seconds. Otherwise, check these three in order:
- Did you join a room?
connectalone puts you in no rooms. Until youemit('join', …)you will receive nothing, indefinitely and silently. This is by design, not a fault. - Did you join with a valid room string?
socket.emit('join', 'sport:football')is correct;socket.emit('join', { room: 'sport:football' })is not, and neither is a bare name likesocket.emit('join', 'football')orsocket.emit('join', 'nfl'), which are perfectly good strings but carry none of the three prefixes. The server checks the argument is a non-empty string matchingsport:/league:/match:before doing anything else, and if it is not, it returns with no error, no rejection event, nothing on the wire — this is a different failure than the plan/sport case below, which does emitjoin_rejected. A socket that connects cleanly and then goes quiet forever is the symptom of this one; log what you actually pass tojoinand confirm itstypeofis'string'before assuming the server or your plan is at fault. - Is anything live right now? Events are emitted when our poller detects a change in a live match, so a room for a sport with no match in progress is correctly silent. Confirm with REST before suspecting the socket:An emptybashbash
curl -H 'x-api-key: YOUR_KEY' \ 'https://api.bigballsdata.com/v1/matches?sport=football&status=live'dataarray means there is nothing to push. Once a match is live, expect a frame within roughly 15 seconds. - Does your plan cover that sport? A join for a sport outside your plan is refused with a
join_rejectedevent rather than silently ignored — listen for it, because without a handler it looks identical to silence:typescripttypescriptsocket.on('join_rejected', ({ room, code, message }) => { // code: 'sport_not_included' // message: "Your plan covers football. This room is for 'tennis', which is not included." console.error(room, code, message); });
Worth restating because it catches people testing an integration: connecting with a valid Pro key and waiting is expected to produce no match events when nothing is playing. It is not evidence that your key, your plan or the feed is broken — and you no longer have to take that on trust. Join a sport: room and the heartbeat above arrives within 30 seconds regardless.
Event types
The event name is the type, and your handler receives the payload directly — there is no { type, data } wrapper around it.
socket.on('score_update', (data) => {
// data: { match_id, sport, league_id, status, period, clock, linescore,
// polled_at, emitted_at }
// linescore is per-period: { home: [0, 1], away: [0, 0] }
// polled_at / emitted_at are ISO-8601 UTC — see "Measuring latency" below.
});
socket.on('match_start', (data) => {
// same shape; fired when a match transitions to status 'live'
});
socket.on('match_end', (data) => {
// same shape; fired when a match transitions to status 'finished'
});Those three are the live score feed, plus the heartbeat described above and player_stats_update below. The transport also carries odds_move, lineup_confirmed, goal, card and substitution, which are accepted and routed but have no publisher behind them yet — subscribing to them is valid and currently silent. We would rather say that than list them as though they were live.
player_stats_update — live box scores
Per-player numbers as they move, one frame per match per poll, carrying only the cells that changed on that tick. The poll is every 15 seconds for basketball, baseball and ice hockey; American football is faster — 2 seconds for the NFL and 6 for college — because it runs on a different publisher against a different upstream. Its REST twin is GET /v1/live-stats/{sport}/{matchId}/players, which returns the whole current box score on demand; this is the same data pushed instead of polled.
socket.on('player_stats_update', (data) => {
// data: { match_id, sport, league_id, snapshot,
// player_count, stat_count, players, polled_at, emitted_at }
//
// players: [{ player_id, name, team_id, stats: [{ field, value, display }] }]
//
// snapshot === true -> `players` is everything we hold for this match
// (first frame of a match, or after a publisher restart)
// snapshot === false -> `players` is a DELTA. A field that is absent is
// UNCHANGED, not missing. Apply it onto your last state.
for (const p of data.players) {
for (const s of p.stats) {
// s.value is null for composites like "6-12" (made-attempted) or a
// clock — read s.display for those rather than parsing a number out.
board[p.player_id][s.field] = s.value ?? s.display;
}
}
});Stats are an array, not an object. {field, value, display} per cell rather than {points: 20}. It costs a few more bytes and buys one thing worth having: on the basketball, baseball and ice-hockey feeds a stat we have not named canonically yet still arrives, under its raw lowercased spelling, instead of taking the whole frame down. The American football publisher is stricter and drops a stat it has no canonical name for rather than coining one — so a new field there shows up as an absence, not as an odd-looking name.
Which sports, honestly. Frames are published for basketball, baseball, ice_hockey and american_football — but the four are not equivalent and listing them as though they were is the thing this paragraph exists to avoid. Only baseball has broad proven coverage: 1,290 matches carry live per-player stats as of 2026-09-07, against 8 for basketball and 9 for ice hockey, whose seasons were largely over while this matured. Treat those two as unproven rather than absent.
american_football is newer still: the publisher ships before its first game. Per-player coverage is NFL and NCAAF (FBS), beginning at the 2026 season opener, 2026-09-10 00:20 UTC, and nothing has been measured in play before then — so if you subscribe today you will correctly hear nothing. NCAAF FCS matches are not polled: subscribing to one is valid and will stay silent, which is worth saying here rather than letting you find out by waiting. NFL season totals and play-by-play do not depend on this feed and have worked all along: /v1/nfl/players/{playerId}/stats, /v1/nfl/players/{playerId}/game-log and /v1/nfl/games/{gameId}/plays (those take the 00-0000000 league id format from /v1/nfl/rosters, not a Big Balls UUID). The live box score is also readable over REST at /v1/live-stats/{sport}/{matchId}/players with sport=american_football, whose meta.stale and meta.as_of tell you whether it is moving.
Telling quiet from broken. This feed only speaks while a game is in progress and only when a number moves, so no frames is the normal state most of the time. To check your connection without waiting for a game, join a sport: room and confirm the heartbeat arrives within 30 seconds. A heartbeat and no player stats during a live game means nothing has changed since the last frame; no heartbeat at all means the problem is your connection or ours, not the box score.
A frame arriving always means something changed — we do not re-send an unchanged box score every 15 seconds. That makes silence ambiguous on its own, which is exactly what the heartbeat is for: join the sport: room alongside your match: rooms and you can tell a quiet game from a broken feed.
Measuring latency
Every match event carries two timestamps, both ISO-8601 UTC with an explicit Z and millisecond precision. They are readings of our clock, taken by our code, and they exist so you can measure our segment instead of estimating it.
polled_at— when our upstream HTTP response arrived. Stamped when the response resolves, before its body is read, so parsing, reconciling and writing the match all land after it.emitted_at— when we published the frame to our internal bus. Gateway relay and socket delivery happen after this.
socket.on('score_update', (data) => {
const polled = Date.parse(data.polled_at);
const emitted = Date.parse(data.emitted_at);
emitted - polled // our pipeline, exactly
Date.now() - emitted // publish + relay + your transport, on your clock
// Anything before polled_at is upstream. Treat it as an upper bound.
});What we will not give you is a fabricated event time. Our upstream for live scores is a poll, not a push, so the segment before polled_at contains both the upstream’s own refresh cadence and our poll gap, and we have measured only the first of those. Publishing a single “the goal happened at” field would make that segment look solved when it is not. If you are timing us against a market, subtract what you can measure and treat the rest as a bound — that is the honest shape of the number.
Unsubscribing
socket.emit('leave', 'league:64184273-6963-428d-834a-66a11f169708');
socket.disconnect();Sockets carry no server-side state across reconnects. After a reconnect, re-issue every join you need. The connect handler is the right place to do this.
Delivery guarantees
- At-most-once. A momentary disconnect during emit is a missed event, webhooks are the right transport if loss is unacceptable.
- No ordering guarantee across rooms. Within a room, events arrive in the order the orchestrator emitted them.
- No replay. There is no backfill of events sent while disconnected. Use the REST endpoints to reconcile state on reconnect.
Quota
Nothing on this transport counts against the request quota — not received events, and not join / leave either. The rate limiter sits on the HTTP path and this one never reaches it.