Live Match WebSocket
Subscribe to any live match and receive ball positions, situations, score and stats as they happen. Sub-5 second latency. One socket, many matches.
Why is this a paid addon?
The REST API and MCP server are free and stay free. The live WebSocket runs on a different data tier:
- Premium real-time source. The live ball-position and situation feed comes from a higher-cost provider — well above what we pay for the rest of the platform.
- Always-on infrastructure. Each subscribed match keeps a polling job alive on our side, multiplexed across subscribers.
- Sponsor-style billing. $3 per month, paid via PayPal. Cancel by simply not renewing — we don't auto-charge.
What you receive
event Match snapshot
Re-pushed every ~30s and on every score / status change. Stats block is the same shape as /api/v2/events/{id}/stats/ — all 40+ live counters plus tactical metrics (attack / dangerous-attack / ball-safe and their *_pct), computed pass accuracy, and one xg number per side (sum of per-shot xG from the live shotmap).
{
"type": "event",
"event_id": 204849,
"home": {"id": 4823, "name": "Kyoto Sanga",
"short_name": "Kyoto"},
"away": {"id": 4825, "name": "Gamba Osaka",
"short_name": "Gamba"},
"score": {"home": 1, "away": 1},
"time": {"minute": 90, "period": "penalties",
"status": "penalties",
"kickoff_at": "2026-05-02T11:00:00+00:00"},
"stats": {
"home": {
"ball_possession": 52, "total_shots": 14,
"shots_on_target": 5, "shots_off_target": 6,
"shots_inside_box": 9, "blocked_shots": 3,
"corner_kicks": 7, "fouls": 11,
"yellow_cards": 2, "red_cards": 0,
"passes": 412, "accurate_passes":
{"value": 348, "total": 412, "pct": 84.5},
"pass_accuracy_pct": 84.5,
"big_chances": 2, "big_chances_missed": 1,
"dribbles": 8, "interceptions": 11,
"tackles": 14, "tackles_won": 9,
"duels": 78, "ground_duels": 65,
"aerial_duels": 13, "recoveries": 22,
"long_balls": 28, "crosses": 16,
"throw_ins": 14, "free_kicks": 9,
"goal_kicks": 6, "offsides": 2,
"clearances": 18, "dispossessed": 12,
"goalkeeper_saves": 4, "total_saves": 4,
"high_claims": 1, "punches": 0,
"hit_woodwork": 1, "touches_in_penalty_area": 24,
"final_third_entries": 38,
"fouled_in_final_third": 4,
"attack": 67, "ball_safe": 64,
"dangerous_attack": 37,
"attack_pct": 33, "ball_safe_pct": 41,
"dangerous_attack_pct": 26,
"xg": 1.42
},
"away": { /* same shape */ }
}
}
livedata Ball / situation tick
Pushed every ~5s — one frame per upstream timeline event (ball trajectory, corner, free kick, goal, shot, etc.).
{
"type": "livedata",
"event_id": 204849,
"uts": 1777745495516,
"side": "home",
"situation": "attack",
"coordinates": [
{"x": 65.5, "y": 42.0},
{"x": 68.2, "y": 44.5},
{"x": 71.0, "y": 47.0},
{"x": 73.8, "y": 49.5}
]
}
side: "home" / "away" / null. situation values include attack, goal, corner, free_kick, goal_kick, throw_in, penalty, substitution, periodscore, etc.
odds Bookmaker consensus (default)
Pushed every ~30s and only when the consensus actually changes (no redundant frames). Consensus is the arithmetic mean across 11–12 bookmakers; the line is refreshed every 3 min for live + imminent kickoffs.
{
"type": "odds",
"event_id": 205764,
"odds": {
"match_winner": {
"home": 2.07, "draw": 4.08, "away": 2.87
},
"over_under": {
"over_15": 1.06, "under_15": 7.96,
"over_25": 1.26, "under_25": 3.61,
"over_35": 1.84, "under_35": 1.89
},
"btts": {"yes": 1.34, "no": 3.08}
},
"updated_at": "2026-05-06T08:36:23+00:00"
}
Decimal odds. Any market not present in the consensus is omitted (the key may be null). Use updated_at for staleness checks; the same value is repeated until upstream moves the line.
odds Markets exposed
| Group | Outcomes |
|---|---|
match_winner | home, draw, away |
over_under | over_15, under_15, over_25, under_25, over_35, under_35 |
btts | yes, no |
Consensus is the true arithmetic mean across the books that quote the line. Goals only — over/under is the match total, not per-team or cards/corners. For a per-bookmaker breakdown or other markets call /api/v2/events/{id}/odds/comparison/.
Per-bookmaker odds subscription
Pass bookmaker_slug in the subscribe message and you will only receive that book's quotes for the match — and only when those specific quotes change. Both the slug (pinnacle, bet365) and the display name (Pinnacle, Bet365) are accepted; the server canonicalises to the slug. Use /api/v2/bookmakers/ to list the available bookmakers — each entry is {slug, name} so you can show the display name in your UI and pass either back here. Unknown bookmakers return a clear error frame (code: "unknown_bookmaker") instead of silently sending nothing.
Subscribe
// Default (consensus)
ws.send(JSON.stringify({
action: "subscribe",
event_id: 205764
}));
// Per-bookmaker — only Pinnacle quotes for this match
ws.send(JSON.stringify({
action: "subscribe",
event_id: 205764,
bookmaker_slug: "pinnacle"
}));
// Stop the per-book stream (event/livedata stays)
ws.send(JSON.stringify({
action: "unsubscribe",
event_id: 205764,
bookmaker_slug: "pinnacle"
}));
A client can keep both subscriptions open at once: the consensus odds frame still arrives every ~30 s, and on top of it you get one odds_book frame per change for each book you've asked for.
odds_book Per-bookmaker frame
Re-emitted only when a quote from this specific book moves. Same market shape as odds; missing markets are null.
{
"type": "odds_book",
"event_id": 205764,
"bookmaker_slug": "pinnacle",
"bookmaker_name": "Pinnacle",
"odds": {
"match_winner": {"home": 2.04, "draw": 4.10, "away": 2.84},
"over_under": {
"over_15": 1.06, "under_15": 8.50,
"over_25": 1.285, "under_25": 3.70,
"over_35": 1.78, "under_35": 1.96
},
"btts": {"yes": 1.31, "no": 3.20}
},
"updated_at": "2026-05-06T10:18:42+00:00"
}
Live data fields
Two streams reach you: an event snapshot with the running scoreboard / per-side stats, and continuous livedata ticks describing what's happening on the pitch right now.
event Per-side stats stats.home / stats.away
| Field | Meaning |
|---|---|
ball_possession | % of in-play time on the ball |
total_shots · shots_on_target · shots_off_target · shots_inside_box · shots_outside_box · blocked_shots · hit_woodwork | full shot-volume breakdown |
corner_kicks · fouls · yellow_cards · red_cards · offsides | standard set-piece + discipline counters |
passes · accurate_passes · pass_accuracy_pct · long_balls · crosses · through_balls | passing — ratio fields are {value, total, pct} |
big_chances · big_chances_missed · big_saves | chance-quality counters |
tackles · tackles_won · total_tackles · interceptions · clearances · dispossessed · recoveries | defensive actions |
duels · ground_duels · aerial_duels | duel counters |
dribbles · final_third_entries · final_third_phase · fouled_in_final_third · touches_in_penalty_area | attacking-phase counters |
goalkeeper_saves · total_saves · high_claims · punches | goalkeeper actions |
throw_ins · free_kicks · goal_kicks | set-piece counts |
xg | cumulative xG — sum of per-shot xg values from the live shotmap, recomputed every frame so it updates the moment a shot is added. The REST /stats/ endpoint additionally exposes an upstream post-match aggregate as xg.actual; that one is almost always null during a live match, so the WS frame drops it. |
| Premium momentum counters — refreshed every ~2 min | |
attack | attacking phases entered (cumulative) |
dangerous_attack | dangerous attacks — phases entering the final third with goal threat |
ball_safe | safe possession phases (mid/own third, low threat) |
attack_pct | % of total attack phases that were this side's |
dangerous_attack_pct | % of dangerous attacks that were this side's |
ball_safe_pct | % of safe possession that was this side's |
The eight base fields appear once the upstream stats feed publishes them (expected_goals typically around minute 5–10). The six premium counters arrive on a 2 min cadence while the match is in play and reach the final values once it ends.
livedata situation values
Pair with side (home / away / null) to know which team triggered the situation. Counting "dangerous" situations over a rolling window gives you the equivalent of a "dangerous attacks" stat.
| Group | Values |
|---|---|
| Phase of play | possession, attack, dangerous, safe |
| Set pieces | corner, freekick, throwin, goalkick, penalty |
| Shots | shotontarget, shotofftarget, shotoffwoodwork, goalkeeper_saved |
| Match events | goal, offside, injury, substitution, periodscore |
| Lifecycle | match_about_to_start, match_started, kickoff, possible_event |
Build your own counters from the stream
Most bookmaker-style live indicators are derivable client-side. Some examples:
// "Dangerous attacks (home, last 3 min)"
const dangerous = {home: 0, away: 0};
ws.onmessage = (e) => {
const f = JSON.parse(e.data);
if (f.type === "livedata" && f.situation === "dangerous" && f.side) {
dangerous[f.side]++;
}
if (f.type === "livedata" && f.situation === "shotontarget" && f.side) {
// shots-on-target counter, set-piece momentum, etc.
}
};
Protocol
Authenticate by passing your API token in the URL query string (or as the WebSocket subprotocol). One socket can subscribe to up to 10 matches concurrently.
Client → server
{"action": "subscribe", "event_id": 204849}
{"action": "unsubscribe", "event_id": 204849}
{"action": "ping"}
Server → client
| type | When |
|---|---|
odds | ~30s tick — consensus across 11–12 bookmakers; re-emitted only when the line actually moves |
odds_book | ~30s tick — single bookmaker's quotes (only when you opt in via bookmaker_slug); re-emitted only on change |
subscribed | Confirms subscription; embeds initial event, the last 30 livedata frames, and the latest odds snapshot (when available) |
event | ~30s tick + on score/status change |
livedata | ~5s tick — one per new upstream timeline event |
unsubscribed | After your unsubscribe |
pong | Reply to ping |
error | not_tracked (event has no live mapping), limit (max subs reached), bad_action, bad_event_id |
Connect in 30 seconds
Browser (JavaScript)
const ws = new WebSocket(
"wss://sports.bzzoiro.com/ws/live/?token=YOUR_TOKEN"
);
ws.onopen = () => ws.send(JSON.stringify({
action: "subscribe", event_id: 204849
}));
ws.onmessage = (e) => {
const f = JSON.parse(e.data);
if (f.type === "livedata") drawTick(f);
if (f.type === "event") updateScoreboard(f);
};
Python (websocket-client)
import json, websocket
ws = websocket.create_connection(
"wss://sports.bzzoiro.com/ws/live/?token=YOUR_TOKEN"
)
ws.send(json.dumps({"action": "subscribe", "event_id": 204849}))
while True:
print(json.loads(ws.recv()))
Limits & details
Activate live WebSocket — $3.00/month
One-time PayPal payment. No auto-renew, no surprises. The REST API and MCP server stay free.
Log in to activate Create account