Skip to content

Live feed (WebSocket)

The live feed pushes prices as they change over one persistent WebSocket connection, instead of you polling the odds endpoints. It carries pre-match and in-play game markets, subject to the same plan rules as REST: in-play fixtures from Growth up, which is also the plan floor for the feed itself. Player props are available through the polling props endpoints; they are not accepted as live-feed subscription markets.

Who gets it

It is an add-on for +$99 a month on the Growth and Scale plans, and included on Business and Enterprise. Free and Starter cannot reach it at all, with or without the add-on. Concurrent connections per API key are currently Growth 1, Scale 2, Business 5, and Enterprise by contract, and these limits may change. The welcome message's connections_available field always tells you how much headroom your key has right now.

Connecting

Open a WebSocket connection to wss://oddslink.app/v1/feed and send your key in the X-API-Key header at handshake time, the same header REST uses. There is no query-string key. A missing or invalid key, a plan below Growth, or a Growth/Scale key without the add-on enabled is refused before the connection is accepted: the server sends the same JSON error envelope REST uses as the WebSocket handshake's own HTTP response (401 MISSING_KEY, 401 INVALID_KEY, or 403 PLAN_REQUIRED naming your current_plan and the required_plan, growth), so a client never has to open a socket and then watch it close to learn why. Only the connection-limit refusal above your plan's cap works differently: the socket is briefly accepted and then closed at once with close code 4003, because the limit can only be judged after your key is known to be entitled at all.

Spread, total and team-total ladders put the main line first, including period markets. Each outcome carries main: true on that line and main: false on alternates. Selection follows the book's featured two-sided line, or the most balanced two-sided line by implied probability when none is designated. Lines whose implied probabilities sum to at most 1.25 take priority over lines above 1.25. A one-sided line is main only if no two-sided line exists. A change to main arrives as an outcome upsert even when its price is unchanged; order updated outcomes by this flag when maintaining a local ladder.

Once accepted, the server sends a welcome message first, then waits for your subscribe.

Subscribing

A connection follows exactly one subscription at a time: a sport, optionally narrowed by league, tournament, specific fixtures, bookmakers or markets. Sending a new subscribe replaces the old one outright and triggers a fresh picture for the new filter, all on the same open connection, with no reconnect needed.

FieldTypeMeaning
sportstring, requiredSame sport key as GET /v1/sports/{sport}/odds, for example nfl or soccer.
leaguestringOne soccer competition, for example epl. Only meaningful when sport is soccer.
tournamentstringOne tennis tournament, for example atp_beijing. Only meaningful when sport is tennis.
event_idsarray of stringsNarrow to specific fixture ids you already have, the same ids the odds endpoints return.
bookmakersarray of stringsNarrow to specific books. Omit it for every book we cover.
marketsarray of stringsGame markets only; player-prop keys are rejected with BAD_SUBSCRIPTION. Defaults to h2h, spreads, totals, same as REST. Naming spreads, totals or team_totals explicitly opts into that market's full alternate ladder, the same rule the markets query parameter uses on REST.
periodsarray of stringsWhich slices of the game to follow. Omitted means the full game only. 1h, 2h, 1q to 4q, 1p to 3p, f5 (baseball first five innings), 1i (baseball first inning), reg (hockey regulation time), 1s to 5s (tennis sets); full asks for the full game beside other periods and all asks for everything. A period market arrives with a period field, exactly as on REST; a full-game market has none.
oddsFormatamerican or decimalDefaults to american.
dateFormatiso or unixDefaults to iso.
confirmationsgrouped or legacyDefaults to grouped. Set legacy on every subscribe to retain individual unchanged entries. Resume tokens retain the format.
{
  "type": "subscribe",
  "sport": "nfl",
  "bookmakers": ["draftkings", "fanduel"],
  "markets": ["h2h", "spreads"]
}

Messages

Every message is a JSON object with a type. Every message the server sends also carries seq, a number that increases by one on every message for this connection, and server_time, the server's own clock in epoch milliseconds.

TypeDirectionWhat it means
welcomeserver → clientThe first message on every accepted connection: how many connections you have used and how many remain on this plan.
subscribeclient → serverFollow a sport, optionally narrowed by league, tournament, fixtures, bookmakers or markets. Replaces any existing subscription outright and triggers a fresh picture.
pictureserver → clientEverything the current subscription covers, sent whole, in the same row shape GET /v1/sports/{sport}/odds returns. Large enough and it arrives as several consecutive picture frames instead of one — see part and parts.
changesserver → clientOnly what changed since the last message for this subscription, grouped to at most one message per second. Can split into several frames the same way a picture can.
heartbeatserver → clientSent every heartbeat_interval_sec (5 seconds by default) so a client can watch the connection's own clock.
unsubscribeclient → serverStop following anything. The connection stays open and idles on heartbeats until the next subscribe.
resumeclient → serverReconnect and ask for whatever changed while you were gone, by resume_token.
picture_requiredserver → clientA resume could not be honoured. Send a fresh subscribe.
errorserver → clientA problem with a message on an already open connection, such as an unknown sport in a subscribe. Same code vocabulary as the REST error.code field.

welcome

{
  "type": "welcome",
  "seq": 1,
  "server_time": 1782000000000,
  "connections_used": 1,
  "connections_available": 4,
  "heartbeat_interval_sec": 5,
  "resume_window_sec": 60
}

picture

Each row is the same shape GET /v1/sports/{sport}/odds returns, so you can reuse your REST parser.

{
  "type": "picture",
  "seq": 2,
  "server_time": 1782000000123,
  "cursor": 0,
  "resume_token": "3f1a9c2e1b7d4a6f.0",
  "part": 1,
  "parts": 1,
  "data": [
    {
      "id": "nfl:atlantafalcons-carolinapanthers:2026-09-27",
      "sport_key": "nfl",
      "sport_title": "NFL",
      "commence_time": "2026-09-27T17:00:00.000Z",
      "is_live": false,
      "home_team": "Carolina Panthers",
      "away_team": "Atlanta Falcons",
      "bookmakers": [
        {
          "key": "draftkings",
          "title": "DraftKings",
          "last_update": "2026-09-27T16:59:00.000Z",
          "markets": [
            {
              "key": "h2h",
              "last_update": "2026-09-27T16:59:00.000Z",
              "outcomes": [
                { "name": "Atlanta Falcons", "price": 120 },
                { "name": "Carolina Panthers", "price": -140 }
              ]
            }
          ]
        }
      ],
      "unmatched_bookmakers": []
    }
  ]
}

changes

upserts and removals are both keyed by scope (fixture, bookmaker, market, outcome, or for upserts only confirmed, unchanged and status) at the coarsest level that actually changed: a whole market pulled from a book is one market-scoped removal, not one removal per outcome.

Only what moved, nothing resent

A price is sent only when it actually moves. Books re-confirm their prices every few seconds, and almost every re-confirmation says the same thing again; measured on our own soccer feed, 99.5% of re-confirmed prices had not moved. Resending them would multiply your bandwidth for no new information, so the feed never does.

When a book confirms markets whose prices have not moved, the default feed batches targets sharing the exact bookmaker and confirmation clock into one confirmed entry. It carries bookmaker, last_update and up to 256 markets targets. Each target names event_id, market and optional period. Advance only those named markets to that exact clock and retain their prices. Never advance every market of a fixture or bookmaker.

A confirmation records when the book last confirmed the market. It does not establish when a price first moved. Different clocks remain separate groups; ISO clocks use exactly three fractional digits and a trailing Z; sub-millisecond digits are truncated. Price and size changes still arrive as outcome upserts, whose last_update advances their whole market. Removals keep their existing scopes.

Existing parsers that understand only unchanged must set "confirmations": "legacy" on every subscribe. That retains the individual event_id, bookmaker, market, optional period and last_update entries. New parsers should support confirmed, which is now the default. A resume token retains its format; changing formats requires a fresh subscribe and picture.

{
  "type": "changes",
  "seq": 3,
  "server_time": 1782000001456,
  "cursor": 1,
  "resume_token": "3f1a9c2e1b7d4a6f.1",
  "part": 1,
  "parts": 1,
  "upserts": [
    {
      "scope": "outcome",
      "event_id": "nfl:atlantafalcons-carolinapanthers:2026-09-27",
      "bookmaker": "draftkings",
      "market": "h2h",
      "last_update": "2026-09-27T17:00:00.000Z",
      "outcome": { "name": "Atlanta Falcons", "price": 125 }
    },
    {
      "scope": "outcome",
      "event_id": "nfl:atlantafalcons-carolinapanthers:2026-09-27",
      "bookmaker": "draftkings",
      "market": "spreads",
      "last_update": "2026-09-27T17:00:00.000Z",
      "outcome": { "name": "Atlanta Falcons", "price": -110, "point": -3.5, "main": true }
    },
    {
      "scope": "confirmed",
      "bookmaker": "fanduel",
      "last_update": "2026-09-27T17:00:01.000Z",
      "markets": [{ "event_id": "nfl:atlantafalcons-carolinapanthers:2026-09-27", "market": "totals" }]
    },
    {
      "scope": "status",
      "event_id": "nfl:atlantafalcons-carolinapanthers:2026-09-27",
      "is_live": true
    }
  ],
  "removals": [
    {
      "scope": "bookmaker",
      "event_id": "nfl:atlantafalcons-carolinapanthers:2026-09-27",
      "bookmaker": "betmgm"
    }
  ]
}

An outcome upsert carries the complete outcome. point appears on spreads, totals and team totals, including period markets. size appears when the book publishes a limit or depth for that exact line, as an object with kind and amount; the book's size_kind in GET /v1/bookmakers describes the kind. main appears on spread, total and team total ladders: true for the main line and false for alternates.

Kickoff: the is_live flag

Every fixture in a picture carries is_live, the same flag the REST routes return: true from scheduled kickoff until a sport-specific time limit after kickoff, false outside that window. It is time-derived, not a confirmed final status: delayed starts, early finishes and unusually long fixtures can disagree with it. See In-play odds for the same REST definition. When it flips, the feed sends one status upsert with the fixture's event_id and the new is_live, within the same one-second grouping as every other change. A flip moves no price, so no prices are restated with it: apply the supplied is_live, status and status_evidence fields to the fixture you hold and retain its prices.

A change to status or status_evidence also sends a status upsert, even when is_live stays the same. Current fixture statuses are scheduled, live, unknown and finished; clients must accept new values. live requires is_live: true and a linked book's explicit observation of play. status_evidence names that book, state and observation time. Apply the supplied status fields to the fixture; a null evidence value clears the previous observation.

Live scores

The same status upsert also carries live scores. It is sent when is_live flips, when status changes, or when the score changes: a goal, a new period, a set or game in tennis. It carries the fixture's current status, status_evidence and score, the same fields as the REST odds body, so replace the ones you hold. A running clock alone does not send one; the latest clock comes with the next score or status change.

part and parts

Every picture and changes message carries part and parts. Most of the time both are 1: the whole message fit in one frame. When it doesn't — a busy sport's picture can run several megabytes — the server sends it as several consecutive frames of the same type instead, part going 1, 2, ... up to parts, always in order and never interleaved with a frame of a different message. Every part shares the same cursor and resume_token: reassemble by concatenating data for a picture, or upserts and removals separately for a changes, in the order the parts arrived, and treat the message complete once part == parts. A split never happens mid-fixture or mid-entry: each part holds whole rows.

Keep a resume_token only once its message is complete, that is from the frame where part == parts. If a connection drops between parts, throw the partial message away and resume with the token of the last complete message you hold; the example above does this for you, since the assembler hands back only complete messages.

heartbeat

{ "type": "heartbeat", "seq": 4, "server_time": 1782000005000 }

picture_required

{ "type": "picture_required", "seq": 4, "server_time": 1782000005000 }

error

{
  "type": "error",
  "seq": 4,
  "server_time": 1782000005000,
  "code": "UNKNOWN_SPORT",
  "message": "Unknown sport key: curling"
}

seq, cursor and resume_token

Three different numbers ride these messages, each answering a different question:

  • seq is per connection. It counts every message this specific socket has sent you, starting from the welcome. It resets to zero on a new connection and is only useful for noticing a message was dropped on this connection.
  • cursor is per subscription. It counts changes for the filter shape you are following, and survives a reconnect: two different connections subscribed to the same sport and filters see the same cursor sequence.
  • resume_token is what you actually hold onto. It wraps the subscription's identity and its current cursor into one opaque string. Store the latest one you have seen; you never construct or parse it yourself.

Reconnecting and resuming

On a new connection, send a resume message carrying the latest resume_token you saw, instead of a fresh subscribe. If the gap was short enough, about a minute or less, the server replays every changes message you missed, each stamped with its own original cursor, and you are caught back up on the same subscription without a new picture. If the gap was too long, or the token is not recognised, the server answers picture_required instead of silently sending a snapshot: send a fresh subscribe and treat what comes back as a new starting point.

{ "type": "resume", "token": "3f1a9c2e1b7d4a6f.42" }

Close codes

CodeMeaning
1000Normal closure.
4001Reserved for a future explicit post-accept auth failure. Not in use today: every auth and plan check happens before the connection is accepted, so a rejected key or plan never reaches an open socket at all.
4002Slow consumer: your outgoing queue on the server could not be drained in time. Reconnect and consider a narrower subscription.
4003Connection limit reached for this API key's plan. The welcome message's connections_available field tells you how much headroom you have before opening another connection.
1013The feed is full right now, not your key specifically. Back off and retry; this is unrelated to your plan's own connection limit (4003).

Client examples

Both examples subscribe to NFL moneylines and spreads on DraftKings and FanDuel, then print each incoming message.

import asyncio
import json

import websockets

URL = "wss://oddslink.app/v1/feed"
API_KEY = "your-api-key"


class Assembler:
    """A picture or changes message bigger than one frame arrives as
    several consecutive frames of the same type, same cursor and same
    resume_token, part 1..parts in order (see "part and parts" in the
    docs). Buffer them until part == parts, then hand back one complete
    message shaped exactly like an unsplit one."""

    def __init__(self):
        self._pending = None

    def add(self, msg):
        if msg.get("parts", 1) == 1:
            return msg
        if msg["part"] == 1:
            self._pending = dict(msg)
        elif msg["type"] == "picture":
            self._pending["data"].extend(msg["data"])
        else:
            self._pending["upserts"].extend(msg["upserts"])
            self._pending["removals"].extend(msg["removals"])
        if msg["part"] < msg["parts"]:
            return None
        complete, self._pending = self._pending, None
        return complete


async def main():
    headers = {"X-API-Key": API_KEY}
    assembler = Assembler()
    # A picture or changes frame can be several MB for a busy sport (soccer's
    # default subscribe measured 6+ MB) -- well past the 1 MiB default frame
    # size most WebSocket clients, including this library, ship with. Raise
    # it; belt-and-braces alongside assembling part/parts above.
    async with websockets.connect(URL, additional_headers=headers, max_size=2**22) as ws:
        await ws.send(json.dumps({
            "type": "subscribe",
            "sport": "nfl",
            "bookmakers": ["draftkings", "fanduel"],
            "markets": ["h2h", "spreads"],
        }))
        async for raw in ws:
            msg = assembler.add(json.loads(raw))
            if msg is None:
                continue  # still waiting on more parts of this message
            if msg["type"] == "picture":
                print(f"picture: {len(msg['data'])} fixtures")
            elif msg["type"] == "changes":
                print(f"changes: {len(msg['upserts'])} upserts, "
                      f"{len(msg['removals'])} removals")
            elif msg["type"] == "picture_required":
                await ws.send(json.dumps({
                    "type": "subscribe", "sport": "nfl",
                    "bookmakers": ["draftkings", "fanduel"],
                    "markets": ["h2h", "spreads"],
                }))


asyncio.run(main())
// Node.js: npm install ws
import WebSocket from "ws";

// The ws package supports handshake headers. The browser's native
// WebSocket constructor cannot send X-API-Key; connect from your backend.
const ws = new WebSocket("wss://oddslink.app/v1/feed", {
  headers: { "X-API-Key": "your-api-key" },
  maxPayload: 2 ** 22,
});

let pending;
function assemble(msg) {
  if ((msg.parts ?? 1) === 1) return msg;
  if (msg.part === 1) pending = msg;
  else if (msg.type === "picture") pending.data.push(...msg.data);
  else {
    pending.upserts.push(...msg.upserts);
    pending.removals.push(...msg.removals);
  }
  if (msg.part < msg.parts) return null;
  const complete = pending;
  pending = undefined;
  return complete;
}

ws.addEventListener("open", () => {
  ws.send(JSON.stringify({
    type: "subscribe",
    sport: "nfl",
    bookmakers: ["draftkings", "fanduel"],
    markets: ["h2h", "spreads"],
  }));
});

ws.addEventListener("message", (event) => {
  const msg = assemble(JSON.parse(event.data));
  if (!msg) return;
  if (msg.type === "picture") {
    console.log(`picture: ${msg.data.length} fixtures`);
  } else if (msg.type === "changes") {
    console.log(`changes: ${msg.upserts.length} upserts, ${msg.removals.length} removals`);
  } else if (msg.type === "picture_required") {
    ws.send(JSON.stringify({
      type: "subscribe", sport: "nfl",
      bookmakers: ["draftkings", "fanduel"],
      markets: ["h2h", "spreads"],
    }));
  }
});

What the feed does not do

  • It does not meter individual messages against your monthly request allowance. Live-feed traffic is not counted as requests at all.
  • Silence never means a price was removed. A price only disappears from the board when you see it in a removals list, whether that is because the book stopped listing it or because it went stale under our normal freshness rule. If nothing changed, nothing is sent for it.
  • A test key still gets real-shaped answers, not the live board: it follows the same fixed sample data your polling requests get from a test key, and it never demands or counts as real board traffic.
  • It does not hold more than one subscription per connection. Following a second filter at the same time means opening a second connection, subject to your plan's connection limit.