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-feed

3. 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 test

6. 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}/events
  • GET /sports/{sport}/leagues/{league}/events/{eventId}/odds
  • GET /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.

More guides