WebSockets#
Push-based live data over WebSocket. One addon ($3/mo at /addons/) unlocks both channels; the token is the same one you use for REST. It also includes Arena 3D, the 3D match viewer, at no extra cost.
Hands-on guide with working clients: Connect to live WebSockets.
Channels#
| URL | Sports | Notes |
|---|---|---|
wss://sports.bzzoiro.com/live/football/ |
Football | Recommended. Unified channel: score/stats, ball position, odds; on fully-covered matches also per-action events with pitch coordinates ("WS+") |
wss://sports.bzzoiro.com/ws/live/ |
Football + tennis | Legacy multi-sport channel; tennis subscriptions go here with "sport": "tennis" |
Frame references: Football channel · Tennis channel.
The same pitch-level stream behind the football channel's action frames also
drives Arena 3D — watch a match in 3D, or embed the
widget on your own site with a key from your dashboard.
Authentication#
Same API token, two transports:
wss://sports.bzzoiro.com/live/football/?token=YOUR_API_KEY
or the token subprotocol (preferred — keeps keys out of URL logs):
new WebSocket("wss://sports.bzzoiro.com/live/football/", ["token", "YOUR_API_KEY"]);
On failure the server accepts the socket, sends one error frame, then
closes:
| Close code | Error code |
Meaning |
|---|---|---|
4401 |
auth_required |
Missing/invalid token |
4402 |
subscription_required |
No active WebSocket addon |
4404 |
not_found |
Unknown WebSocket path |
Client → server protocol#
All frames are JSON:
| Frame | Effect |
|---|---|
{"action":"subscribe","event_id":N} |
Follow a match |
{"action":"subscribe","event_id":N,"bookmaker_slug":"…"} |
Also stream one bookmaker's odds for it |
{"action":"subscribe","event_id":N,"sport":"tennis"} |
Tennis (on /ws/live/ only) |
{"action":"unsubscribe","event_id":N} |
Stop following |
{"action":"ping"} |
→ {"type":"pong"} |
Limit: 10 concurrent subscriptions per socket (match and bookmaker
subscriptions counted separately; football and tennis share the budget on
/ws/live/).
Error frames#
{"type":"error","code":…,"message":…} with codes: bad_frame, bad_json,
bad_action, bad_event_id, not_tracked (match has no live coverage),
limit (subscription cap), bad_slug, unknown_bookmaker. Only the three
4xxx cases above close the socket.
event_id on error frames. When the error concerns one match, the frame
also carries event_id, so a failure can be attributed with several subscribes
in flight instead of parsing it out of message:
{"type":"error","code":"not_tracked","message":"Event 999999999 not available for live stream","event_id":999999999}
It is echoed exactly as received, so bad_event_id returns the value you sent
({"event_id":"abc"} → "abc", and a subscribe with none at all → null).
Errors about no particular match — bad_json, bad_frame, bad_action,
auth_required — omit the key entirely, so "event_id" in frame is a reliable
test. Adding it is backward-compatible: the three original keys are unchanged.
A failed subscribe holds no slot. A subscribe rejected with not_tracked,
bad_event_id or limit never joins the subscription set, so it consumes
nothing against the cap of 10 and there is nothing to reclaim. Subscribing to
uncovered matches will therefore never produce limit, however many you send.
Which matches are covered#
Check the REST live list: GET /api/v2/events/live/ →
live_websocket: true— subscribable on the WebSocketwebsocket_plus: true— full per-action coverage (actionframes with coordinates)
Subscribing to an uncovered match returns not_tracked.
Operational notes#
- Server pings every 25 s; unanswered pings drop the connection (~20 s timeout). Standard clients handle this automatically.
- Subscriptions don't survive reconnects — re-subscribe after reconnecting.
- No rate limit on the socket itself; connection read timeout is 1 hour of silence.
- Try real frames without code at the debug console.