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; that poll runs every 15 seconds while a match is live. 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.
Room model
Three room scopes; join as many as you need. 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.
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 }
// linescore is per-period: { home: [0, 1], away: [0, 0] }
});
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 what the live score feed emits today. 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.
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.