Odds337Docs
    Odds337BackGet API key
    Getting started
    • Introduction
    • Authentication
    • Quickstart
    • Tokens & pricing
    • Limits
    • Errors
    • Odds formats
    Reference
    • GET/sports
    • GET/books
    • GET/teams
    • GET/players
    • Logos & headshots
    • GET/events
    • GET/odds
    • GET/scores
    • GET/history
    • GET/clv
    • POST /clv
    • GET/usage
    • GET/_health
    • WebSocket
    • Specs & SDKs
    Racing
    • GET/racing/meetings
    • GET/racing/race
    • GET/racing/history
    • race_id and status
    Concepts
    • event_id
    • selection_key
    • team_id & player_id
    • Open & close prices
    • 337 model price
    • Event links
    • Statuses
    Getting started
    • Introduction
    • Authentication
    • Quickstart
    • Tokens & pricing
    • Limits
    • Errors
    • Odds formats
    Reference
    • GET/sports
    • GET/books
    • GET/teams
    • GET/players
    • Logos & headshots
    • GET/events
    • GET/odds
    • GET/scores
    • GET/history
    • GET/clv
    • POST /clv
    • GET/usage
    • GET/_health
    • WebSocket
    • Specs & SDKs
    Racing
    • GET/racing/meetings
    • GET/racing/race
    • GET/racing/history
    • race_id and status
    Concepts
    • event_id
    • selection_key
    • team_id & player_id
    • Open & close prices
    • 337 model price
    • Event links
    • Statuses

    API reference

    WebSocket stream

    Subscribe to events and receive every price change as it happens. Resume after a drop without missing a move.

    WSS/odds/v1/ws

    Connect

    Connect to wss://api.b337.ai/odds/v1/ws with X-API-Key (or ?api_key=). Your own key only: the demo key is refused. Every frame is one JSON object. On connect the server sends hello:

    {"type": "hello", "schema_version": 1, "server_time": "2026-10-03T02:30:26Z",
     "server_epoch": "9c1f0e6a3b2d4c5e",
     "limits": {"max_subs": 50, "max_connections": 3, "queue_max": 2000,
                "heartbeat_s": 15.0, "replay_max_age_s": 300.0, "replay_max_changes": 15000},
     "tokens": {"included": true, "cost_per_minute": 0}}

    server_epoch identifies this server run; keep it for resuming.

    # pip install websockets
    import asyncio, json, os
    import websockets
    
    URL = "wss://api.b337.ai/odds/v1/ws?api_key=" + os.environ["ODDS_API_KEY"]
    EVENT_ID = "soccer/gb/epl/arsenal_fc_v_leeds_united_fc/2026-10-10"
    
    async def main():
        async with websockets.connect(URL) as ws:
            print(json.loads(await ws.recv()))  # hello
            await ws.send(json.dumps({
                "op": "subscribe",
                "event_ids": [EVENT_ID],
                "markets": ["head_to_head"],
            }))
            async for frame in ws:
                msg = json.loads(frame)
                if msg["type"] == "changes":
                    for c in msg["changes"]:
                        print(c["book"], c["selection_key"], c["prev_odds"], "->", c["odds"])
    
    asyncio.run(main())
    // npm install ws, then run as an .mjs file
    import WebSocket from "ws";
    
    const ws = new WebSocket("wss://api.b337.ai/odds/v1/ws", {
      headers: { "X-API-Key": process.env.ODDS_API_KEY },
    });
    
    ws.on("open", () => {
      ws.send(JSON.stringify({
        op: "subscribe",
        event_ids: ["soccer/gb/epl/arsenal_fc_v_leeds_united_fc/2026-10-10"],
        markets: ["head_to_head"],
      }));
    });
    
    ws.on("message", (raw) => {
      const msg = JSON.parse(raw);
      if (msg.type === "changes") {
        for (const c of msg.changes) console.log(c.book, c.selection_key, c.prev_odds, "->", c.odds);
      }
    });
    # websocat: https://github.com/vi/websocat
    websocat "wss://api.b337.ai/odds/v1/ws?api_key=$ODDS_API_KEY"
    # then type:
    {"op": "subscribe", "event_ids": ["soccer/gb/epl/arsenal_fc_v_leeds_united_fc/2026-10-10"], "markets": ["head_to_head"]}

    Subscribe

    You send:

    {"op": "subscribe", "event_ids": ["soccer/gb/epl/arsenal_fc_v_leeds_united_fc/2026-10-10"],
     "markets": ["head_to_head"], "books": ["sportsbet", "tab"], "tier": "main",
     "format": "decimal"}
    {"op": "unsubscribe", "event_ids": ["..."]}
    {"op": "ping"}

    markets, books, tier and format are optional (defaults: all, all, main, decimal). Re-subscribing an event replaces its filters. Streaming is included on Business and Enterprise and spends no tokens; the event and connection caps are on Limits.

    Server frames

    1. snapshot: one per subscribed event, the full /odds body for your filters (incl. open_odds / close_odds, url and books[].stale), plus "type": "snapshot" and its seq.
    2. subscribed: {"type": "subscribed", "event_ids": [...], "tier": "main", "rejected": [...]}, where each rejection carries an event_id and a reason. After a resume, resumed lists the events that were replayed.
    3. changes: only what moved, as soon as we see it (we check every second). Each carries a seq.
    4. snapshot_required: your resume could not be honoured; a fresh snapshot follows straight away. See Resume after a disconnect.
    5. event_removed: the event is no longer listed (finished or withdrawn); it is unsubscribed for you.
    6. heartbeat every 15s, pong for your ping, unsubscribed, and error ({"type": "error", "code": "...", "message": "..."}).

    A changes frame

    {"type": "changes", "event_id": "soccer/gb/epl/arsenal_fc_v_leeds_united_fc/2026-10-10",
     "seq": 42, "tier": "main", "status": "prematch", "odds_format": "decimal",
     "generated_at": "2026-10-03T02:30:27Z",
     "changes": [
       {"market": "head_to_head", "selection_key": "774c72a86710", "book": "sportsbet",
        "odds": 1.4, "prev_odds": 1.37, "updated_at": "2026-10-03T02:30:25Z"},
       {"market": "head_to_head", "selection_key": "8fd2377f4f58", "book": "sportsbet",
        "odds": 7.5, "prev_odds": 8.0, "updated_at": "2026-10-03T02:30:25Z"}]}
    • odds: null: that book no longer prices the selection.
    • A selection you have not seen before carries a selection object with its name, label, player, line, direction, period and market_name.
    • A frame with an empty changes list and a new status means the event went in-play.
    • changes frames do not repeat open_odds / close_odds; those come in snapshots.
    • A books object appears only when a book's stale flag flipped. See Stale books.

    Applying changes

    Apply changes to the last snapshot by (market, selection_key, book). If you reconnect, subscribe again: with resume you get only what you missed, without it you get fresh snapshots to replace your state with.

    Resume after a disconnect

    Every snapshot and changes frame carries seq: a number per event (and tier) that goes up by one each time we see that event move. Keep, per event, the seq of the last frame you applied, and the server_epoch from hello. When you reconnect, send the same subscribe (same tier, markets, books and format) with resume:

    {"op": "subscribe", "event_ids": ["soccer/gb/epl/arsenal_fc_v_leeds_united_fc/2026-10-10"],
     "markets": ["head_to_head"],
     "resume": {"server_epoch": "9c1f0e6a3b2d4c5e",
                "from_seq": {"soccer/gb/epl/arsenal_fc_v_leeds_united_fc/2026-10-10": 42}}}

    For each event in from_seq the server does one of two things.

    Replay

    It sends the changes frames you missed, in order, each with "replayed": true, and no snapshot. The event is listed in subscribed.resumed. If you missed nothing, no frames at all. Live frames follow.

    snapshot_required

    It cannot replay, so it sends a snapshot_required frame immediately followed by a fresh snapshot. Replace your state for that event with it.

    {"type": "snapshot_required", "event_id": "soccer/gb/epl/arsenal_fc_v_leeds_united_fc/2026-10-10",
     "reason": "out_of_buffer", "seq": 57}
    reasonMeaning
    epoch_mismatchThe server restarted: server_epoch changed.
    out_of_bufferYou were away longer than we keep history.
    cursor_aheadA seq we never sent.
    cursor_invalidA seq that is not an integer.

    What to know

    • We keep about 5 minutes of history (limits.replay_max_age_s) and at most limits.replay_max_changes change rows per event. Under heavy load the oldest history across all events goes first.
    • A seq can skip numbers when your filters drop a change. A gap is not loss: the server tells you with snapshot_required.
    • Replay assumes the same filters as before. If you change them, subscribe without resume.
    • An event you subscribe without a cursor gets a normal snapshot. A resumed event counts toward your event cap like any new subscription.

    A reconnecting client

    # pip install websockets
    import asyncio, json, os
    import websockets
    
    URL = "wss://api.b337.ai/odds/v1/ws?api_key=" + os.environ["ODDS_API_KEY"]
    EVENT_ID = "soccer/gb/epl/arsenal_fc_v_leeds_united_fc/2026-10-10"
    SUB = {"op": "subscribe", "event_ids": [EVENT_ID], "markets": ["head_to_head"]}
    
    epoch = None          # server_epoch from the last hello
    seq = {}              # event_id -> seq of the last frame we applied
    books = {}            # event_id -> your own copy of the card
    
    async def run_once():
        global epoch
        async with websockets.connect(URL) as ws:
            hello = json.loads(await ws.recv())
            sub = dict(SUB)
            if epoch and seq:
                sub["resume"] = {"server_epoch": epoch, "from_seq": dict(seq)}
            epoch = hello["server_epoch"]
            await ws.send(json.dumps(sub))
            async for frame in ws:
                msg = json.loads(frame)
                kind = msg["type"]
                if kind == "snapshot":
                    books[msg["event_id"]] = msg      # replace state
                    seq[msg["event_id"]] = msg["seq"]
                elif kind == "changes":
                    apply_changes(books[msg["event_id"]], msg)   # your code
                    seq[msg["event_id"]] = msg["seq"]
                elif kind == "snapshot_required":
                    print("full snapshot follows:", msg["reason"])
                elif kind == "event_removed":
                    seq.pop(msg["event_id"], None)
    
    async def main():
        delay = 1
        while True:
            try:
                await run_once()
            except (websockets.ConnectionClosed, OSError):
                pass
            await asyncio.sleep(delay)
            delay = min(delay * 2, 30)
    
    asyncio.run(main())
    // npm install ws, then run as an .mjs file
    import WebSocket from "ws";
    
    const EVENT_ID = "soccer/gb/epl/arsenal_fc_v_leeds_united_fc/2026-10-10";
    let epoch = null;            // server_epoch from the last hello
    const seq = new Map();       // event_id -> seq of the last frame we applied
    const cards = new Map();     // event_id -> your own copy of the card
    let delay = 1000;
    
    function connect() {
      const ws = new WebSocket("wss://api.b337.ai/odds/v1/ws", {
        headers: { "X-API-Key": process.env.ODDS_API_KEY },
        perMessageDeflate: true,
      });
    
      ws.on("message", (raw) => {
        const msg = JSON.parse(raw);
        switch (msg.type) {
          case "hello": {
            delay = 1000;
            const sub = { op: "subscribe", event_ids: [EVENT_ID], markets: ["head_to_head"] };
            if (epoch && seq.size) {
              sub.resume = { server_epoch: epoch, from_seq: Object.fromEntries(seq) };
            }
            epoch = msg.server_epoch;
            ws.send(JSON.stringify(sub));
            break;
          }
          case "snapshot":
            cards.set(msg.event_id, msg);          // replace state
            seq.set(msg.event_id, msg.seq);
            break;
          case "changes":
            applyChanges(cards.get(msg.event_id), msg);   // your code
            seq.set(msg.event_id, msg.seq);
            break;
          case "snapshot_required":
            console.log("full snapshot follows:", msg.reason);
            break;
          case "event_removed":
            seq.delete(msg.event_id);
            break;
        }
      });
    
      ws.on("close", () => {
        setTimeout(connect, delay);
        delay = Math.min(delay * 2, 30000);
      });
      ws.on("error", () => {});
    }
    
    connect();

    Stale books

    Each book in a snapshot carries stale, the same flag as on /odds. When it flips, the next changes frame for the event carries a books object with the books whose flag changed. A frame can carry only that (changes empty).

    {"type": "changes", "event_id": "soccer/gb/epl/arsenal_fc_v_leeds_united_fc/2026-10-10", "seq": 43, "changes": [],
     "books": {"tab": {"stale": true, "updated_at": "2026-10-03T02:22:10Z", "age_seconds": 412.0}}}

    A stale book's prices are still served: the flag says it may have stopped updating this event, not that a price is wrong. The threshold is per book; see GET /odds for the rule.

    Compression

    The socket supports permessage-deflate (RFC 7692), on by default on our side. Most clients only use it when asked. A 1.35 MB snapshot (NFL, 27 books) goes over the wire as 178 KB (13%); change frames shrink to about 17%.

    • Python websockets: on by default (compression="deflate").
    • Node ws: new WebSocket(url, {perMessageDeflate: true}).
    • Browsers always offer it.
    # websockets negotiates permessage-deflate by default.
    # Pass compression="deflate" to be explicit, or None to turn it off.
    async with websockets.connect(URL, compression="deflate") as ws:
        ...
    // ws: ask for it (it is off by default in Node).
    const ws = new WebSocket("wss://api.b337.ai/odds/v1/ws", {
      headers: { "X-API-Key": process.env.ODDS_API_KEY },
      perMessageDeflate: true,
    });

    Close codes

    CodeMeaningDo
    1008Invalid key, the demo key, or too many messagesDo not retry with the same key
    1013Connection limit, or you were not reading fast enoughRetry with backoff
    1012Server restartReconnect and re-subscribe
    PreviousGET /_healthNextSpecs & SDKs

    On this page

    • Connect
    • Subscribe
    • Server frames
    • Applying changes
    • Resume after a disconnect
    • Stale books
    • Compression
    • Close codes