Football. Live.
Ball tracking, match state, xG and odds pushed over one persistent connection. Basic source updates every ~5s; Full source pushes every on-pitch action with x/y coordinates in ~100ms.
Connect
Append your API token as a query parameter. After the connection opens, send JSON subscribe messages. You can subscribe to multiple matches on the same socket.
wss://sports.bzzoiro.com/live/football/?token=YOUR_TOKEN
Subscribe / Unsubscribe
Send these JSON messages over the open socket to control which matches you receive frames for.
// subscribe to a match { "action": "subscribe", "event_id": 204849 } // unsubscribe { "action": "unsubscribe", "event_id": 204849 } // keepalive — send if your socket might sit idle >60s { "action": "ping" }
Find live event IDs at /api/v2/events/live/. On subscribe you immediately receive the latest snapshot: a subscribed frame containing the current event, livedata and odds.
Ping / Pong
The server does not send unsolicited pings. If your connection may be idle for more than ~60s (e.g. no live matches subscribed), send a ping to keep the socket alive. The server replies immediately with a pong frame:
// you send: { "action": "ping" } // server replies: { "type": "pong" }
Basic vs Full source
The subscribed frame includes a source field telling you which data quality is available for this match.
Basic
- Ball x/y position (~5s)
- Match score, minute, period
- Live odds every ~30s
- xG, shots, possession counters
- No
actionframes
Full
- Everything in Basic
actionframes per on-pitch event- x/y coordinates for every action
- Player & team attribution per action
- Qualifiers array (head, freekick, etc.)
subscribed
Sent immediately after a successful subscribe. Contains the full current state so you can render a complete view without waiting for the next tick.
{
"type": "subscribed",
"event_id": 204849,
"source": "basic", // "basic" | "full"
"event": { /* latest event frame */ },
"livedata": [ /* up to 30 recent livedata frames */ ],
"history": [], // reserved
"odds": { /* latest odds frame */ }
}
| Field | Type | Description |
|---|---|---|
| source | string | "basic" or "full" — determines whether action frames will be sent |
| event | object|null | Current event snapshot (same shape as event frame) |
| livedata | array | Up to 30 recent livedata frames, oldest first |
| odds | object|null | Current odds snapshot (same shape as odds frame) |
event
Match snapshot. Sent every ~30s while the match is live, and on any status/score change.
{
"type": "event",
"event_id": 204849,
"home": { "id": 17, "name": "Arsenal" },
"away": { "id": 32, "name": "Chelsea" },
"score": { "home": 1, "away": 1 },
"minute": 67,
"period": "2H", // "1H" | "2H" | "ET1" | "ET2" | "P"
"status": { "name": "live" },
"stats": {
"home": { "shots": 8, "shots_on_target": 3, "xg": 1.24, "possession": 54 },
"away": { "...": "same shape" }
}
}
| Field | Type | Description |
|---|---|---|
| score | object | {"home": N, "away": N} |
| minute | int | Match minute (0-90+) |
| period | string | "1H", "2H", "ET1", "ET2", "P" |
| stats.*.xg | float | Cumulative xG this match |
| stats.*.possession | int | Percentage, 0–100 |
livedata
Ball position update. Sent every ~5s for Basic source; on every action for Full source. x/y are percentages of the pitch (0–100 left-to-right, 0–100 bottom-to-top).
{
"type": "livedata",
"event_id": 204849,
"uts": 1718780400123, // unix timestamp ms
"x": 62.4, // 0–100, left→right
"y": 48.7, // 0–100, bottom→top
"situation": "corner_kick", // see situations list below
"minute": 67,
"period": "2H"
}
Situations
The situation field describes the current game state:
| Value | Description |
|---|---|
| free_kick | Free kick being taken |
| corner_kick | Corner kick |
| goal_kick | Goal kick |
| throw_in | Throw-in |
| penalty | Penalty situation |
| kick_off | Kick-off (start of half / after goal) |
| regular_play | Normal open play |
action Full source only
Per-action event with x/y coordinates. Only sent when source == "full". Arrives within ~100ms of the on-pitch event.
{
"type": "action",
"event_id": 204849,
"action_type": "shot", // see types below
"tid": 4419281, // tracker action ID
"x": 87.3,
"y": 51.2,
"team": "home", // "home" | "away"
"player": { "id": 11843, "name": "Saka" },
"score": { "home": 1, "away": 1 },
"qualifiers": ["on_target", "right_foot"],
"minute": 67,
"second": 34,
"period": "2H",
"ts": 1718780434000 // unix ms
}
action_type values
| Value | Description |
|---|---|
| shot | Shot attempt |
| pass | Pass |
| tackle | Tackle / duel |
| foul | Foul committed |
| yellow_card | Yellow card shown |
| red_card | Red card shown |
| goal | Goal scored |
| save | Goalkeeper save |
| clearance | Clearance |
| interception | Interception |
| corner | Corner awarded |
| substitution | Player substitution |
| offside | Offside flag |
| var_review | VAR check started |
odds
Aggregated best odds across all tracked bookmakers. Sent every ~30s and whenever a significant line movement occurs.
{
"type": "odds",
"event_id": 204849,
"uts": 1718780400000,
"odds": {
"1x2": { "home": 2.10, "draw": 3.40, "away": 3.60 },
"over_15": { "over": 1.18, "under": 4.80 },
"over_25": { "over": 1.90, "under": 1.90 },
"over_35": { "over": 3.20, "under": 1.33 },
"btts": { "yes": 1.72, "no": 2.05 }
}
}
odds_book
Per-bookmaker odds. Sent alongside odds. Use bookmaker_slug to filter to a specific bookmaker.
{
"type": "odds_book",
"event_id": 204849,
"bookmaker_slug": "bet365",
"uts": 1718780400000,
"odds": {
"1x2": { "home": 2.10, "draw": 3.40, "away": 3.60 }
}
}
Error codes
| code | Description |
|---|---|
| auth_failed | Token missing, invalid or expired |
| subscription_required | Token valid but WebSocket addon not active |
| not_tracked | Match exists but has no live tracking data right now |
| limit | 10 concurrent match limit reached; unsubscribe first |
| bad_action | Unknown action value in your message |
| bad_event_id | event_id not found |
{ "type": "error", "code": "not_tracked", "message": "No live tracking for event 204849", "event_id": 204849 }
Limits
- 10 concurrent matches per socket. Mix football and tennis on the same connection.
- Ball / score frames pushed every ~5s (Basic) or per-action (Full)
- Odds + match snapshot every ~30s
- 30 frames replayed on subscribe (latest state)
- One socket per token is recommended; multiple sockets are allowed but each counts against your limit independently
Code examples
JavaScript / TypeScript
const ws = new WebSocket( "wss://sports.bzzoiro.com/live/football/?token=YOUR_TOKEN" ); ws.onopen = () => { ws.send(JSON.stringify({ action: "subscribe", event_id: 204849 })); }; ws.onmessage = ({ data }) => { const frame = JSON.parse(data); switch (frame.type) { case "subscribed": console.log("source:", frame.source); // "basic" | "full" applyEvent(frame.event); applyOdds(frame.odds); break; case "livedata": moveBall(frame.x, frame.y, frame.situation); break; case "event": updateScoreboard(frame.score, frame.minute, frame.period); updateStats(frame.stats); break; case "action": addActionMarker(frame.x, frame.y, frame.action_type, frame.team); break; case "odds": renderOdds(frame.odds); break; case "error": console.error(frame.code, frame.message); break; } }; ws.onerror = err => console.error("WS error", err); ws.onclose = ev => console.log("Closed", ev.code, ev.reason);
Python (asyncio)
import asyncio, json, websockets TOKEN = "YOUR_TOKEN" URL = f"wss://sports.bzzoiro.com/live/football/?token={TOKEN}" async def main(): async with websockets.connect(URL) as ws: await ws.send(json.dumps({"action": "subscribe", "event_id": 204849})) async for message in ws: frame = json.loads(message) if frame["type"] == "livedata": print(f'Ball at ({frame["x"]:.1f}, {frame["y"]:.1f}) — {frame["situation"]}') elif frame["type"] == "event": s = frame["score"] print(f'{frame["home"]["name"]} {s["home"]}–{s["away"]} {frame["away"]["name"]} [{frame["minute"]}\']') asyncio.run(main())
Try it — connect to a live match
Connects to wss://sports.bzzoiro.com/live/football/ directly from your browser.
Get access — $3.00/month
Football + Tennis included · 10 concurrent matches · auto-renewing