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})`Source:https://dev.to/bjermeland/live-odds-api-in-nodejs-build-a-websocket-odds-tracker-2fi5