---
title: WebSockets overview
description: The two live channels, authentication, subscription protocol, limits and close codes.
badge: addon
---

# WebSockets

Push-based live data over WebSocket. One addon ($3/mo at
[/addons/](/addons/)) unlocks both channels; the token is the same one you
use for REST. It also includes [Arena 3D](/docs/websocket/arena3d/), the 3D
match viewer, at no extra cost.

Hands-on guide with working clients:
[Connect to live WebSockets](/docs/guides/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](/docs/websocket/football/) ·
[Tennis channel](/docs/websocket/tennis/).

The same pitch-level stream behind the football channel's `action` frames also
drives [Arena 3D](/docs/websocket/arena3d/) — 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):

```js
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`:

```json
{"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 WebSocket
- `websocket_plus: true` — full per-action coverage (`action` frames 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](/websocket/debug/).
