Build an odds feed for a fantasy or pick'em app
A Node script that builds a JSON feed for a league's upcoming games: current spread, moneyline and total, the opening line, margin-free implied win probability and every recorded line move. Games without a line stay in the feed with odds: null.
- Stack
- Node.js, JSON
- Plan
- Free for a few runs a month; Starter for hourly refreshes
- Code
- odds-feed/ (MIT)
Updated 2026-10-07. Tested against the live API on that date. Data is aggregated from public sources and is typically 20-30 seconds behind live play (about 1 second after our source). There is no SLA.
1. Get a free API key
Sign up (no card) and copy the key from the dashboard. The Free plan gives you 1,000 calls in the first 30 days, then 125 a month, at 1 request per second. Every successful REST call counts as one call.
Check the key works with one call to the live-scores endpoint:
Terminal
export REALTIME_SPORTS_API_KEY=your_key
curl -H "Authorization: Bearer $REALTIME_SPORTS_API_KEY" \
"https://www.realtimesportsapi.com/api/v1/sports/football/leagues/nfl/events/live"2. Get the code
Everything below is in the odds-feed/ folder of the examples repo (MIT). Clone it and work from that folder.
Terminal
git clone https://github.com/ElcoDevRepos/realtime-sports-api-examples
cd realtime-sports-api-examples/odds-feed3. Normalize odds and detect line moves
normalizeOdds maps the API's odds object to a stable shape and computes implied win probability with the bookmaker margin removed. The history endpoint can contain snapshots in any order and near-duplicates, so the feed sorts them by time, compares consecutive snapshots and lists real changes as moves. Soccer moneylines have no draw price, so implied probability is left null for soccer.
odds-feed/lib.js
// Odds normalisation and line-move detection. Pure functions, unit tested in test/.
import { existsSync, readFileSync } from 'node:fs';
export const API_BASE = process.env.RSA_API_BASE || 'https://www.realtimesportsapi.com/api/v1';
/** Minimal .env loader (KEY=value lines). Variables already in the environment win. */
export function loadDotEnv(path) {
if (!existsSync(path)) return;
for (const line of readFileSync(path, 'utf8').split(/\r?\n/)) {
if (line.trimStart().startsWith('#')) continue;
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2].replace(/^(['"])(.*)\1$/, '$2');
}
}
/** GET a path under /api/v1 and return the envelope { success, data, meta, message? }. */
export async function apiGet(path, key, query = {}) {
const url = new URL(API_BASE + path);
for (const [k, v] of Object.entries(query)) if (v != null) url.searchParams.set(k, String(v));
const res = await fetch(url, { headers: { Authorization: `Bearer ${key}`, Accept: 'application/json' } });
const body = await res.json().catch(() => null);
if (!res.ok) {
const err = new Error(`HTTP ${res.status} ${body?.error?.code ?? ''}: ${body?.error?.message ?? 'request failed'} (${path})`);
err.status = res.status;
throw err;
}
return body;
}
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
/** American odds -> implied probability (0..1), including the book's margin. */
export function impliedProbability(american) {
const a = num(american);
if (a === null || a === 0) return null;
return a < 0 ? -a / (-a + 100) : 100 / (a + 100);
}
/**
* Normalise one odds object from /odds (or an /odds/history snapshot) into a flat, stable shape.
* Returns null when there are no odds (the API returns `data: null` in that case).
* `spread.line` is from the home team's perspective (-8.5 = home favoured by 8.5).
* Pass `twoWay: false` for soccer: the moneyline has no draw price, so a two-way implied win
* probability would be misleading and is left null.
*/
export function normalizeOdds(o, { twoWay = true } = {}) {
if (!o || typeof o !== 'object') return null;
const spread = o.spread ? { line: num(o.spread.line), home: num(o.spread.home), away: num(o.spread.away) } : null;
const moneyline = o.moneyline ? { home: num(o.moneyline.home), away: num(o.moneyline.away) } : null;
const total = o.overUnder ? { line: num(o.overUnder.total), over: num(o.overUnder.over), under: num(o.overUnder.under) } : null;
if (!spread && !moneyline && !total) return null;
let impliedWin = null;
const ph = impliedProbability(moneyline?.home);
const pa = impliedProbability(moneyline?.away);
if (twoWay && ph !== null && pa !== null) {
// Remove the margin so the two sides sum to 1.
impliedWin = { home: round(ph / (ph + pa), 3), away: round(pa / (ph + pa), 3) };
}
return { provider: o.provider ?? null, spread, moneyline, total, impliedWin, updatedAt: o.updatedAt ?? null };
}
function round(x, places) {
const f = 10 ** places;
return Math.round(x * f) / f;
}
/** The values whose change we call a "line move". */
const TRACKED = [
['spread', (o) => o?.spread?.line],
['total', (o) => o?.total?.line],
['moneyline.home', (o) => o?.moneyline?.home],
['moneyline.away', (o) => o?.moneyline?.away]
];
/**
* Sort history snapshots oldest -> newest, drop consecutive duplicates, and list every change in
* the tracked values: [{ market, from, to, at }]. Also returns opening and latest snapshots.
*/
export function detectMoves(history) {
const snaps = (history ?? [])
.map((h) => ({ at: h.timestamp ?? h.odds?.updatedAt, odds: normalizeOdds(h.odds) }))
.filter((s) => s.at && s.odds)
.sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
const moves = [];
let prev = null;
for (const s of snaps) {
if (prev) {
for (const [market, get] of TRACKED) {
const from = get(prev.odds);
const to = get(s.odds);
if (from != null && to != null && from !== to) moves.push({ market, from, to, at: s.at });
}
}
prev = s;
}
return { opening: snaps[0] ?? null, latest: snaps.at(-1) ?? null, snapshots: snaps.length, moves };
}
/** Net change in spread and total from opening to now (null when unknown). */
export function netMovement(opening, current) {
const diff = (a, b) => (a != null && b != null ? round(b - a, 2) : null);
return {
spread: diff(opening?.spread?.line, current?.spread?.line),
total: diff(opening?.total?.line, current?.total?.line)
};
}
/** Build one feed entry for a game. */
export function feedEntry(event, oddsData, historyData, { twoWay = true } = {}) {
const current = normalizeOdds(oddsData, { twoWay });
const { opening, latest, snapshots, moves } = detectMoves(historyData);
// The current line can be newer than the last history snapshot; count that change too.
if (current && latest) {
for (const [market, get] of TRACKED) {
const from = get(latest.odds);
const to = get(current);
if (from != null && to != null && from !== to) moves.push({ market, from, to, at: current.updatedAt });
}
}
const net = netMovement(opening?.odds, current);
return {
eventId: event.id,
name: event.name,
shortName: event.shortName,
start: event.date,
home: { id: event.homeTeam?.id, abbreviation: event.homeTeam?.abbreviation, name: event.homeTeam?.name },
away: { id: event.awayTeam?.id, abbreviation: event.awayTeam?.abbreviation, name: event.awayTeam?.name },
odds: current,
opening: opening ? { ...opening.odds, recordedAt: opening.at } : null,
movement: { ...net, snapshots, moves, lineMoved: moves.some((m) => m.market === 'spread' || m.market === 'total') }
};
}
/** "TB @ DAL DAL -8.5 O/U 47.5 ML DAL -455 / TB +350 (spread moved -5 since open)" */
export function describe(entry) {
const o = entry.odds;
if (!o) return `${entry.shortName} no odds available`;
const sign = (n) => (n > 0 ? `+${n}` : `${n}`);
const parts = [entry.shortName];
if (o.spread?.line != null) parts.push(`${entry.home.abbreviation} ${sign(o.spread.line)}`);
if (o.total?.line != null) parts.push(`O/U ${o.total.line}`);
if (o.moneyline?.home != null) parts.push(`ML ${entry.home.abbreviation} ${sign(o.moneyline.home)} / ${entry.away.abbreviation} ${sign(o.moneyline.away)}`);
const m = entry.movement;
const notes = [];
if (m.spread) notes.push(`spread ${sign(m.spread)} since open`);
if (m.total) notes.push(`total ${sign(m.total)} since open`);
return parts.join(' ') + (notes.length ? ` (${notes.join(', ')})` : '');
}
4. Build the feed
For each upcoming game on the scoreboard it calls /odds and, when there is a line, /odds/history. When the book has no line yet, data is null and the game is kept with "odds": null.
odds-feed/feed.js
// Normalised odds feed with line-move detection for a league's upcoming games.
// Node 18+, no dependencies.
//
// node feed.js [sport] [league] [--limit 8] [--out feed.json]
// node feed.js football nfl
// node feed.js football college-football --limit 5 --out cfb.json
//
// Costs 1 scoreboard call + 2 calls per game (odds + odds history).
import { writeFileSync } from 'node:fs';
import { apiGet, describe, feedEntry, loadDotEnv } from './lib.js';
loadDotEnv(new URL('./.env', import.meta.url));
loadDotEnv(new URL('../.env', import.meta.url));
const argv = process.argv.slice(2);
const opt = (name, fallback) => {
const i = argv.indexOf(name);
return i >= 0 ? argv[i + 1] : fallback;
};
const positional = argv.filter((a, i) => !a.startsWith('--') && !argv[i - 1]?.startsWith('--'));
const [sport = 'football', league = 'nfl'] = positional;
const limit = Math.max(1, Number(opt('--limit', 8)));
const out = opt('--out', null);
const KEY = process.env.REALTIME_SPORTS_API_KEY;
if (!KEY || KEY === 'your_api_key_here') {
console.error('Set REALTIME_SPORTS_API_KEY (see .env.example). Get a free key at https://www.realtimesportsapi.com/signup');
process.exit(1);
}
const base = `/sports/${sport}/leagues/${league}`;
try {
const { data: events } = await apiGet(`${base}/events`, KEY);
const upcoming = events.filter((e) => e.status?.state === 'pre').sort((a, b) => Date.parse(a.date) - Date.parse(b.date)).slice(0, limit);
if (!upcoming.length) console.error(`No upcoming ${league} games on the current scoreboard.`);
const games = [];
for (const e of upcoming) {
// `data` is null (with a `message`) when the book has no line for this game yet.
const odds = await apiGet(`${base}/events/${e.id}/odds`, KEY);
const history = odds.data ? await apiGet(`${base}/events/${e.id}/odds/history`, KEY, { limit: 100 }) : { data: [] };
const entry = feedEntry(e, odds.data, history.data, { twoWay: sport !== 'soccer' });
games.push(entry);
console.error(describe(entry));
}
const feed = {
generatedAt: new Date().toISOString(),
sport,
league,
note: 'Odds come from a single source book (see odds.provider). Data is typically 20-30 seconds behind live.',
games
};
const json = JSON.stringify(feed, null, 2);
if (out) {
writeFileSync(out, json + '\n');
console.error(`\nWrote ${games.length} games to ${out}`);
} else {
console.log(json);
}
} catch (err) {
console.error(err.message);
process.exit(1);
}
5. Run it
On 7 October 2026 the NFL run showed TB @ DAL at DAL -8.5, total 47.5, moneyline DAL -455 / TB +350, with the spread and total each 5 points lower than at open.
Terminal
node feed.js football nfl # JSON to stdout, summary to stderr
node feed.js football college-football --limit 5
node feed.js hockey nhl --out nhl-feed.json
npm test6. What the odds are, and are not
Odds come from a single source book named in odds.provider (sometimes "Unknown"); this is not a multi-book comparison feed. Data is typically 20-30 seconds behind live, so it is fine for pre-game lines in fantasy and pick'em apps and not suited to in-play betting. Odds were returned for NFL, college football, MLB, NHL and 11 soccer competitions when we checked; see the odds endpoint page.
Endpoints used
GET /sports/{sport}/leagues/{league}/eventsGET /sports/{sport}/leagues/{league}/events/{eventId}/oddsGET /sports/{sport}/leagues/{league}/events/{eventId}/odds/history
Full reference: docs · OpenAPI · what each endpoint returns, by league
FAQ
- How many calls does a run cost?
- One scoreboard call plus up to two per game (odds and history). The default limit of 8 games is at most 17 calls.
- Can I get notified when a line moves?
- Yes on paid plans: the WebSocket stream sends event_odds_change messages for games that have odds.