This guide builds a small Node.js script that follows in-play odds for one football league over a WebSocket. It keeps the latest match-winner price from every bookmaker in memory, prints a line whenever the best price for home, draw or away changes, and after a disconnect it resumes from where it stopped, so the table stays current.
It is about 230 lines and uses the official Node.js SDK plus ws. If you are still deciding between polling and streaming, read Live Odds API: Polling vs WebSocket Streaming first. For the same ideas in Python, see the client in Odds Feed API: How It Works and How to Pick a Provider.
What the tracker does
- Lists the live matches in the league over REST.
- Takes a REST snapshot of their odds, together with the stream position that snapshot matches.
- Opens the WebSocket from that position, filtered to one league, live matches only and one market.
- Replaces the stored prices for a match and bookmaker on every update, and prints the best price per outcome when it moves.
- Reconnects from the last sequence number after a drop, and rebuilds from REST if the server says it cannot replay the gap.
Here is output from a test run on 12 November 2026, following the live club friendlies (node tracker.mjs club-friendlies) on a key with eight bookmakers selected, Polymarket among them. The disconnect at 10:31:54 was forced to test the resume:
10:30:44 FK Iskra v Mladost Donja Gorica home none -> 4.76 Polymarket draw none -> 5.56 Polymarket away none -> 1.32 Polymarket
10:30:44 RB Leipzig v Mlada Boleslav home none -> 1.53 Bet365 draw none -> 5.30 Unibet away none -> 8.00 Unibet
10:30:44 snapshot: 2 live events, 2 with ML prices, seq 2874836490
10:30:44 connected, 8 bookmakers on this key
10:30:44 caught up (replayed), seq 2874836509
10:31:15 RB Leipzig v Mlada Boleslav draw 5.30 Unibet -> 5.25 Unibet away 8.00 Unibet -> 7.50 Unibet
10:31:17 RB Leipzig v Mlada Boleslav home 1.53 Bet365 -> 1.55 DraftKings
10:31:35 FK Iskra v Mladost Donja Gorica no ML prices left
10:31:40 FK Iskra v Mladost Donja Gorica home none -> 5.00 Polymarket draw none -> 5.26 Polymarket away none -> 1.51 Polymarket
10:31:54 disconnected, reconnecting in 1124 ms from seq 2874865192
10:31:55 connected, 8 bookmakers on this key
10:31:55 caught up (replayed), seq 2874866858
10:32:11 RB Leipzig v Mlada Boleslav home 1.55 DraftKings -> 1.57 Bet365
10:32:13 RB Leipzig v Mlada Boleslav away 7.50 Unibet -> 8.00 Unibet
The "no ML prices left" line is a suspension: the only bookmaker pricing that match pulled its match-winner market for a few seconds, and the next update brought it back.
Prerequisites
- Node.js 20 or later.
- An Odds-API.io key on a paid plan with the WebSocket add-on. The add-on is listed on the pricing page.
- The bookmakers you want selected on the key. The stream has no bookmaker parameter. It sends updates for every bookmaker selected on your account, so the selection is your bookmaker filter. Change it in the dashboard or with
client.selectBookmakers(['Bet365', 'Unibet']). Write the names as the sportsbooks list does. The REST odds endpoints are case-sensitive and rejectbet365with a 400.
Install the SDK and ws:
npm install odds-api-io ws
The server only accepts connections that negotiate permessage-deflate compression, and refuses the rest with HTTP 426. ws negotiates it by default, and the SDK uses it when it is installed. Without ws the SDK falls back to the WebSocket built into Node, which also negotiated compression in our tests on Node 20, 22, 24 and 26, but ws is still worth installing: when the server refuses a connection, the SDK can only tell you why (bad key, bad filter, no WebSocket access) if ws is installed.
The code
Save this as tracker.mjs and run ODDS_API_KEY=your_key node tracker.mjs. Pass a league slug as the first argument to follow a different league, for example node tracker.mjs spain-laliga.
// Live odds tracker: best match-winner price per outcome for one league, over WebSocket.
// npm install odds-api-io ws
// ODDS_API_KEY=... node tracker.mjs [league-slug]
import { OddsAPIClient, OddsWebSocket } from 'odds-api-io';
const apiKey = process.env.ODDS_API_KEY;
if (!apiKey) {
console.error('Set ODDS_API_KEY first.');
process.exit(1);
}
const SPORT = 'football';
const LEAGUE = process.argv[2] ?? 'england-premier-league';
const MARKET = 'ML';
const OUTCOMES = ['home', 'draw', 'away'];
const client = new OddsAPIClient({ apiKey });
let prices = new Map(); // eventId -> Map(bookmaker -> { home, draw, away })
const best = new Map(); // eventId -> { home: { price, bookie }, ... }
const names = new Map(); // eventId -> "Home v Away"
let touched = new Set(); // "eventId:bookmaker" pairs seen since the last welcome
let feed;
let resyncing = false;
const log = (text) => console.log(`${new Date().toISOString().slice(11, 19)} ${text}`);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function chunk(list, size) {
const out = [];
for (let i = 0; i < list.length; i += size) out.push(list.slice(i, i + size));
return out;
}
function readPrices(markets) {
const line = markets.find((m) => m.name === MARKET)?.odds?.[0];
if (!line) return null;
const out = {};
for (const outcome of OUTCOMES) {
const price = Number.parseFloat(line[outcome]);
if (price > 1) out[outcome] = price;
}
return Object.keys(out).length ? out : null;
}
function eventName(id) {
if (!names.has(id)) {
names.set(id, `event ${id}`);
client
.getEventById(id)
.then((e) => names.set(id, `${e.home} v ${e.away}`))
.catch(() => {});
}
return names.get(id);
}
function fmt(entry) {
return entry ? `${entry.price.toFixed(2)} ${entry.bookie}` : 'none';
}
function refreshBest(id) {
const books = prices.get(id);
const prev = best.get(id) ?? {};
const next = {};
for (const outcome of OUTCOMES) {
for (const [bookie, p] of books ?? []) {
const price = p[outcome];
if (!price) continue;
const current = next[outcome];
if (!current || price > current.price || (price === current.price && bookie === prev[outcome]?.bookie)) {
next[outcome] = { price, bookie };
}
}
}
const changes = OUTCOMES.filter(
(o) => prev[o]?.price !== next[o]?.price || prev[o]?.bookie !== next[o]?.bookie
).map((o) => `${o} ${fmt(prev[o])} -> ${fmt(next[o])}`);
if (books?.size) {
if (changes.length) log(`${eventName(id)} ${changes.join(' ')}`);
best.set(id, next);
} else {
if (best.has(id)) log(`${eventName(id)} no ${MARKET} prices left`);
prices.delete(id);
best.delete(id);
}
}
// Every odds message carries the full market set for the pair: replace, never merge.
function apply(eventId, bookie, markets) {
const id = String(eventId);
touched.add(`${id}:${bookie}`);
const latest = readPrices(markets);
let books = prices.get(id);
if (latest) {
if (!books) prices.set(id, (books = new Map()));
books.set(bookie, latest);
} else {
books?.delete(bookie);
}
refreshBest(id);
}
// After a state snapshot, pairs the server did not resend are gone.
function dropUntouched() {
for (const [id, books] of prices) {
for (const bookie of books.keys()) {
if (!touched.has(`${id}:${bookie}`)) books.delete(bookie);
}
refreshBest(id);
}
}
// REST snapshot of every live event in the league, plus the stream position it matches.
async function loadSnapshot() {
const events = await client.getEvents({ sport: SPORT, league: LEAGUE, status: 'live' });
for (const e of events) names.set(String(e.id), `${e.home} v ${e.away}`);
const fresh = new Map();
let seq = null;
if (events.length) {
const { bookmakers } = await client.getSelectedBookmakers();
for (const eventIds of chunk(events.map((e) => e.id), 10)) {
for (const books of chunk(bookmakers, 30)) {
const page = await client.getOddsSnapshot({ eventIds, bookmakers: books, markets: [MARKET] });
if (page.seq === null) throw new Error('snapshot came back without a seq');
seq = seq === null ? page.seq : Math.min(seq, page.seq);
for (const event of page.data) {
for (const [bookie, markets] of Object.entries(event.bookmakers)) {
const latest = readPrices(markets);
if (!latest) continue;
const id = String(event.id);
if (!fresh.has(id)) fresh.set(id, new Map());
fresh.get(id).set(bookie, latest);
}
}
}
}
}
return { fresh, seq, count: events.length };
}
async function rebuild() {
for (let attempt = 1; ; attempt++) {
try {
const { fresh, seq, count } = await loadSnapshot();
const ids = new Set([...prices.keys(), ...fresh.keys()]);
prices = fresh;
for (const id of ids) refreshBest(id);
log(`snapshot: ${count} live events, ${fresh.size} with ${MARKET} prices, seq ${seq ?? 'none'}`);
return seq;
} catch (err) {
if ([400, 401, 403, 404].includes(err.status)) throw err;
const delay = Math.min(30_000, 1000 * 2 ** attempt);
log(`snapshot failed (${err.name}), retrying in ${delay} ms`);
await sleep(delay);
}
}
}
async function resync(reason) {
if (resyncing) return;
resyncing = true;
log(`resync required (${reason}), rebuilding from REST`);
const old = feed;
feed = null;
old.close();
try {
await connect(await rebuild());
} catch (err) {
log(`stopped: ${err.message}`);
process.exitCode = 1;
} finally {
resyncing = false;
}
}
function connect(lastSeq) {
const ws = new OddsWebSocket({
apiKey,
markets: [MARKET],
leagues: [LEAGUE],
status: 'live',
lastSeq: lastSeq ?? undefined,
});
feed = ws;
// A closed feed can still deliver queued frames; only the current one may touch state.
const on = (type, handler) => ws.on(type, (m) => ws === feed && handler(m));
on('welcome', (m) => {
touched = new Set();
log(`connected, ${m.bookmakers.length} bookmakers on this key`);
if (m.warning) log(`warning: ${m.warning}`);
});
on('created', (m) => apply(m.id, m.bookie, m.markets));
on('updated', (m) => apply(m.id, m.bookie, m.markets));
on('deleted', (m) => apply(m.id, m.bookie, []));
on('no_markets', (m) => apply(m.id, m.bookie, []));
on('replay_complete', (m) => {
if (m.outcome === 'live' && m.last_seq > 0) return resync('no catch-up sent');
if (m.outcome !== 'replayed' && m.outcome !== 'snapshot') return;
if (m.outcome === 'snapshot') dropUntouched();
// Snapshot messages carry no seq, so move the resume point to where the catch-up ended.
if (m.through_seq > (ws.lastSeq ?? 0)) ws.lastSeq = m.through_seq;
log(`caught up (${m.outcome}), seq ${ws.lastSeq}`);
});
on('resync_required', (m) => resync(m.reason));
on('reconnecting', ({ delay, lastSeq: seq }) => log(`disconnected, reconnecting in ${delay} ms from seq ${seq}`));
on('error', (err) => log(`error: ${err.message}`));
on('close', ({ willReconnect, code }) => {
if (willReconnect) return;
log(`stopped, connection closed (${code})`);
process.exitCode = 1;
});
return ws.connect();
}
process.on('SIGINT', () => {
log(`stopping at seq ${feed?.lastSeq}`);
feed?.close();
process.exit(0);
});
try {
await connect(await rebuild());
} catch (err) {
console.error(`Stopped: ${err.message}`);
process.exit(1);
}
How it works
State: one entry per match and bookmaker
prices maps an event id to a map of bookmaker to { home, draw, away }. The tracker reads the first line of the ML market (match winner) and parses the prices, which arrive as strings.
Every created and updated message carries the complete set of markets for that event and bookmaker, so apply() replaces the entry and never merges into it. A suspended market is not flagged in the feed. It is left out of the next message. If you merge, a price that is no longer offered stays in your data. Here, a message without an ML market removes that bookmaker from the match, and deleted and no_markets do the same, since they always arrive with an empty markets array.
Start from a REST snapshot
Without a snapshot the table is empty until each bookmaker sends its next update. loadSnapshot() fills it first. It lists live events with getEvents(), then calls getOddsSnapshot(), which is /odds/multi with includeSeq=true. That endpoint takes up to 10 events and 30 bookmakers per request, so the script loops over both. Each response carries the X-OddsAPI-Seq header, the position in the stream the snapshot matches. The script keeps the lowest one and connects with it as lastSeq. The server then replays anything that changed after the snapshot, so nothing falls between the REST call and the first live message.
The WebSocket messages carry an event id and a bookmaker, not team names. Names come from the getEvents() call, and for a match that goes live after startup the script fetches it once with getEventById().
Filters keep the payload small
The connection asks for leagues: [LEAGUE], status: 'live' and markets: ['ML']. markets is required for the odds channel. Each updated message holds every subscribed market for the pair, so subscribing to ten markets when you print one makes every message bigger. The limits per connection are 40 markets, 10 sports, 20 leagues or 50 event ids, and leagues and eventIds cannot be combined. A key can hold two connections at once. Opening a third closes the oldest one with close code 1008, and the SDK treats that as final instead of reconnecting, so two copies of the tracker plus a test script on one key will knock the first copy off.
To follow totals or handicaps as well, add Totals or Spread to the market list and extend readPrices(). Those markets carry several lines, each with its own hdp, so key the stored prices by line.
Reconnects and replay
Live odds updates carry a seq, a global number that never goes down on your connection. Values are not consecutive, and one update sent for several bookmakers shares a seq. The SDK tracks the highest one it has seen. Rarely, an update arrives without a seq. Apply it as normal; the SDK leaves its position unchanged. When the socket drops, it reconnects with exponential backoff and passes that value as lastSeq. The server replays what you missed, compacted to the latest state per event and bookmaker, and then continues live. Replay data is kept for up to 24 hours.
The SDK also asks for a replay_complete message, which tells you how the catch-up went:
-
replayed: you got the latest state of everything that changed while you were away. -
snapshot: the gap was too large to replay, so the server sent the current state of your subscription ascreatedmessages. Any pair it did not resend is gone. The script records every pair it sees after thewelcomemessage and drops the others indropUntouched(). -
resync_required: the server could not send a complete state. -
live: no catch-up was sent. That is normal on a first connection withoutlastSeq. On a reconnect it means the server pushed current state to this key a short while ago and skipped it this time, so your table may be missing updates. The script treats it likeresync_required.
The created messages in a snapshot catch-up carry no seq, and replay_complete arrives after the last of them. Its through_seq field is where the catch-up ended, so the script moves its resume point there.
A resync_required message comes with a reason, for example snapshot_truncated, replay_limit_exceeded or snapshot_cooldown. The script closes the connection, rebuilds the whole table from REST, and connects again from the new snapshot's sequence number. Do not take the current_seq from that message and resume from it. The updates between your old position and that number would be lost.
A rejected key, a key without WebSocket access, a bad filter, an unknown league or a connection replaced by a newer one stops the script with the reason and exit code 1. The SDK does not retry those.
One detail in connect(): after close(), frames that were already queued can still arrive on the old connection. The on() wrapper ignores events from any connection that is no longer the current one, so a resync cannot be overwritten by a late update.
What to build next
REST fallback. Keep the snapshot code. It is your recovery path, and it works as a fallback when the stream is down. /odds/multi serves the same market format inside an event response. The Fetching Odds guide covers the REST side.
Store the data. Write to Postgres or Redis wherever prices changes: updates and removals in apply(), removals in dropUntouched(), and the full table after rebuild(). Use one row per event, bookmaker and outcome. If the stored board and the seq are saved together, a restarted process can resume from that seq. Otherwise rebuild from REST on restart.
Scores and status. Add channels: ['odds', 'scores', 'status'] to the options to get score changes and match status on the same connection. Keep odds in the list, because channels is an allowlist. Score and status messages have no seq and are not replayed.
Ask questions in plain language. The Odds-API.io MCP server connects the same data to Claude, Cursor and other AI tools, which is useful for checking what the tracker prints against what the API holds.
The WebSocket guide and the WebSocket API reference list every message type, filter and resync reason.
I run Odds-API.io, so I'm biased toward the API used here. Originally published on the Odds-API.io blog.
Top comments (0)