Skip to main content

Connection

Authentication

Pass your API key via:
  • Query string (recommended for browser clients): ?x-api-key=YOUR_API_KEY
  • Header: x-api-key: YOUR_API_KEY

Sport Selection

Optionally set the x-sport header during connection to scope live-scores to a specific sport (default: football).
x-sport must be sent as a connection header — it is not read from the query string. Passing ?x-sport=... in the URL is ignored and the connection stays scoped to football (the welcome frame echoes the applied sport, so check it). If your client cannot set headers, subscribe to match:{matchId} instead — that channel is sport-agnostic and works for any sport.
Supported values: football, basketball, ice-hockey, tennis, rugby, handball, volleyball, baseball, american-football, cricket, futsal, mma, motorsport, darts, snooker, badminton, table-tennis, beach-volley, cycling, esports, bandy, aussie-rules, floorball, minifootball, waterpolo.

Message Format

All messages are JSON. Send actions to subscribe/unsubscribe from channels:

Available Channels

There is no global or sport-wide odds channel. Odds are per-match only — live-odds, odds:{sport}, and live-odds:{sport} are not valid channels and return an error frame. To follow odds across a sport, subscribe to live-scores (or live-scores:{sport}) to track which matches are live, then fan out one match:{matchId}:odds subscription per match on the same connection. A single socket handles many concurrent match subscriptions.
match:{matchId}:odds is a single-provider relay and is not sub-second. Verified in production:
  • Provider: the channel relays provider 1 (bet365) only — the upstream call is odds/1/all. There is no provider selector, no multi-provider WebSocket stream, and a provider query parameter is ignored. Multi-bookmaker odds are REST-only on the V4 feed (GET /v4/football/match/{matchId}/odds, per-bookmaker offers across a 179-book catalogue).
  • Market set: the same markets as GET /api/match/{matchId}/odds/all — typically 11–24 markets: Full time 1X2 (live and pre-match variants), Double chance, Draw no bet, Both teams to score, Match goals, Asian handicap, Corners 2-way, Cards in match, Next goal. Player props are not available on WebSocket or REST today.
  • Frames are complete snapshots, never deltas. Every snapshot and update carries the full { markets[], eventId, liveStreamUrl } state — replace your local state wholesale.
  • Cadence: ~300ms after a match event (goal, card, period change) via the NATS-triggered fetch; otherwise a change-gated ~30s refresh. Measured inter-frame gaps on live top-tier fixtures: 24s, 27s, 30s, 30s, and 63–149s where prices had not moved. Worst case for a pure price move with no match event is about 30 seconds — treat these odds as reference data, not as a live bet-acceptance gate.
  • timestamp is our server’s fetch/emit time in epoch milliseconds (receipt skew measured at 11–30ms). It is not a bookmaker-side fetch time; the upstream feed does not expose one.
Suspension and closure have no dedicated frame. Each market object carries suspended: true|false, so a suspension arrives as the next full snapshot with that market flagged. A market that disappears from markets[] is closed. Because of the cadence above, a suspension can reach you up to ~30s late unless a match event triggers the fast path.
Missed-update recovery: there is no sequence number, cursor, or replay buffer — and because every frame is a full snapshot, you don’t need one. On reconnect, resubscribe and you immediately receive a snapshot with current state that supersedes anything you missed. Recommended pattern: track lastFrameAt per match and, if it exceeds ~90s while the match is live, drop and re-establish the subscription rather than waiting. WebSocket traffic is unmetered, so aggressive resubscription is free.
WebSocket traffic does not count against your quota. The handshake and every frame you receive — subscriptions, snapshots, and updates — are unmetered, including the internal NATS-triggered fetches behind :odds, :incidents, and :stats. Only REST calls consume quota.

Delivery Modes

  • NATS push — Data is pushed directly from the real-time feed with no delay
  • NATS-triggered fetch — When any match update is detected, detailed data is fetched instantly (no waiting for poll cycle)
  • Polling — Data is fetched at regular intervals (used for lineups which rarely change)

No updates received?

If the connection opens and the welcome / subscribed frames arrive but no update frames follow, work through this list before assuming an outage:
  1. Confirm the match is actually in progress with GET /v2/{sport}/live. This is the authoritative liveness source. The schedule endpoint lists a whole day of fixtures with a status snapshot, so a fixture you read there may already have finished — a finished match emits nothing on match:{matchId} or on live-scores.
  2. Check the sport in the welcome frame. live-scores is scoped to it. Connecting on /v2/{sport}/ws sets the scope automatically; the subscribed frame confirms it as live-scores:{sport}.
  3. Expect quiet gaps. Frames are emitted on change only. Between deliveries, at a wicket break, at half-time or during a rain delay, a genuinely live match can be silent for a minute or more. Test against a sport-wide live-scores subscription rather than one fixture.
  4. Check natsConnected in the welcome frame. If it is false, push channels fall back to their safety-net poll cadence.
  5. De-duplicate on changes.changeTimestamp. A single change can arrive two to three times on match:{matchId}, plus once more on live-scores — that is expected, not a bug.

Detecting match end

Status changes arrive on live-scores and match:{matchId} as partial updates with dotted keys, e.g. data["status.code"], not data.status.code. A match has finished when data["status.type"] === "finished". In normal time that means status.code 100 (“Ended”); after extra time or penalties the code differs.
Not every live match receives WebSocket frames. Some matches, mostly in lower-tier competitions, can play out with no updates at all, so no finished frame is ever sent for them. Pair the WebSocket with a /v2/football/api/live check every 30-60 seconds: when a match you show as live drops out of that list, call /v2/football/api/match/{id} once and mark it finished.
Fallback signal: match:{matchId}:incidents is also refreshed by a 15-second safety-net fetch, so it reaches you even when score/status frames don’t. Treat an incident with incidentType: "period" and text: "FT" as finished. In matches that go to extra time or penalties, wait for the final "AET" / "AP" marker instead, because FT there only ends normal time.
Missed frames are not replayed. If a match is past 90’ and silent for ~3 minutes, confirm with one GET /v2/football/api/match/{id} call.

Live match minute

The minute is not sent as a number and there is no per-minute tick. You compute it from the period clock, which keeps it accurate to the second. When a period starts (kick-off, 2nd half, extra time), the update for that match carries the clock as dotted keys:
Score-only updates do not repeat these fields, so keep the last values you received. For a match already in progress, call GET /v2/football/match/{matchId} once: match.time has the same fields. The /live list does not include them.
Show HT when status.description is "Halftime" (stop the clock) and FT when status.type is "finished". Penalty shoot-outs have no running clock. Run a local one-second timer and replace the stored values whenever an update arrives with time.* fields.

What’s delivered on :incidents

The match:{matchId}:incidents channel covers every discrete event the live feed emits:
Non-carded fouls are not emitted as incidents. If you need foul counts, subscribe to match:{matchId}:stats — it carries the aggregate per-team foul count, updated in real time. There is no per-player foul stream.
The welcome payload surfaces "natsConnected": true while the NATS bridge is healthy. If it ever reports false, channels fall back to their safety-net poll cadence (15s for :incidents, 30s for :stats) — your code keeps working without changes.

Response Types

welcome

Sent immediately on connection:

subscribed

Confirms a channel subscription:

snapshot

Last known data sent immediately upon subscribing (if available):

update

New data pushed in real-time:

pong

Response to a ping (for keep-alive):

Quick Start Examples

Best Practices

Each subscription consumes server resources. Only subscribe to channels your application actively uses.
Use match:{id}:incidents for real-time match events rather than polling the REST endpoint.
When you subscribe, you immediately get the last known state so your UI is never blank.
Send {"action":"ping","channel":"live-scores"} every 30s to keep the connection alive.
If disconnected, reconnect with exponential backoff (1s, 2s, 4s, 8s…).
Unsubscribe from match channels when the user navigates away.
Last modified on October 20, 2026