API reference
WebSocket stream
Subscribe to events and receive every price change as it happens. Resume after a drop without missing a move.
/odds/v1/wsConnect
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
snapshot: one per subscribed event, the full/oddsbody for your filters (incl.open_odds/close_odds,urlandbooks[].stale), plus"type": "snapshot"and itsseq.subscribed:{"type": "subscribed", "event_ids": [...], "tier": "main", "rejected": [...]}, where each rejection carries anevent_idand areason. After a resume,resumedlists the events that were replayed.changes: only what moved, as soon as we see it (we check every second). Each carries aseq.snapshot_required: your resume could not be honoured; a freshsnapshotfollows straight away. See Resume after a disconnect.event_removed: the event is no longer listed (finished or withdrawn); it is unsubscribed for you.heartbeatevery 15s,pongfor yourping,unsubscribed, anderror({"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
selectionobject with itsname,label,player,line,direction,periodandmarket_name. - A frame with an empty
changeslist and a newstatusmeans the event went in-play. changesframes do not repeatopen_odds/close_odds; those come in snapshots.- A
booksobject appears only when a book'sstaleflag 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}| reason | Meaning |
|---|---|
epoch_mismatch | The server restarted: server_epoch changed. |
out_of_buffer | You were away longer than we keep history. |
cursor_ahead | A seq we never sent. |
cursor_invalid | A seq that is not an integer. |
What to know
- We keep about 5 minutes of history (
limits.replay_max_age_s) and at mostlimits.replay_max_changeschange rows per event. Under heavy load the oldest history across all events goes first. - A
seqcan skip numbers when your filters drop a change. A gap is not loss: the server tells you withsnapshot_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
| Code | Meaning | Do |
|---|---|---|
| 1008 | Invalid key, the demo key, or too many messages | Do not retry with the same key |
| 1013 | Connection limit, or you were not reading fast enough | Retry with backoff |
| 1012 | Server restart | Reconnect and re-subscribe |