Build a Live Odds Tracker
Complete working example that tracks NBA odds every 30 seconds and detects line movement.
What You’ll Build
A Node.js script that:
- Fetches live NBA odds every 30 seconds
- Tracks line movement across all bookmakers
- Alerts when odds change
- Shows before/after comparisons
Perfect for: Detecting sharp money, identifying steam moves, monitoring arbitrage opportunities
Prerequisites
- Node.js 18+
- SportsGameOdds API key (Get one free)
- Basic JavaScript knowledge
Complete Code
Step 1: Setup Project
mkdir odds-tracker && cd odds-tracker
npm init -y
npm install node-fetch@2Note: We use
node-fetch@2because v3+ is ESM-only. The@2ensures CommonJS compatibility.
Step 2: Create tracker.js
// tracker.js
const fetch = require("node-fetch");
const API_KEY = process.env.SPORTSGAMEODDS_KEY;
const API_BASE = "https://api.sportsgameodds.com/v2";
const POLL_INTERVAL = 30000; // 30 seconds
// Store previous odds for comparison
let previousOdds = {};
// Fetch current NBA odds
async function fetchNBAOdds() {
try {
// Use finalized=false to get upcoming games (live=true only works during live games)
const response = await fetch(`${API_BASE}/events?leagueID=NBA&finalized=false&oddsAvailable=true`, {
headers: { "x-api-key": API_KEY },
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
return data.data;
} catch (error) {
console.error("Error fetching odds:", error.message);
return [];
}
}
// Compare current odds with previous and detect changes
function detectLineMovement(events) {
const movements = [];
events.forEach((event) => {
// Get team names from the nested names structure
const awayName = event.teams.away.names.long;
const homeName = event.teams.home.names.long;
const matchup = `${awayName} @ ${homeName}`;
// Check each odd for movement
Object.entries(event.odds || {}).forEach(([oddID, currentOdd]) => {
// Check each bookmaker's odds for this market
Object.entries(currentOdd.byBookmaker || {}).forEach(([bookmakerID, currentBookmakerOdds]) => {
const key = `${event.eventID}-${oddID}-${bookmakerID}`;
const previousBookmakerOdds = previousOdds[key];
if (previousBookmakerOdds) {
const currentLine = currentBookmakerOdds.spread || currentBookmakerOdds.overUnder;
const previousLine = previousBookmakerOdds.spread || previousBookmakerOdds.overUnder;
const lineChanged = previousLine !== currentLine;
const priceChanged = previousBookmakerOdds.odds !== currentBookmakerOdds.odds;
if (lineChanged || priceChanged) {
movements.push({
matchup,
eventID: event.eventID,
betType: currentOdd.betTypeID,
bookmaker: bookmakerID,
side: currentOdd.sideID,
previous: {
line: previousLine,
price: previousBookmakerOdds.odds,
},
current: {
line: currentLine,
price: currentBookmakerOdds.odds,
},
lineChanged,
priceChanged,
});
}
}
// Store current odds for next comparison
previousOdds[key] = {
spread: currentBookmakerOdds.spread,
overUnder: currentBookmakerOdds.overUnder,
odds: currentBookmakerOdds.odds,
lastUpdatedAt: currentBookmakerOdds.lastUpdatedAt,
};
});
});
});
return movements;
}
// Display line movements in console
function displayMovements(movements) {
if (movements.length === 0) {
console.log("No line movement detected");
return;
}
console.log(`\n${movements.length} LINE MOVEMENT(S) DETECTED\n`);
movements.forEach((movement) => {
console.log("----------------------------------------");
console.log(`${movement.matchup}`);
console.log(`${movement.betType.toUpperCase()} (${movement.side}) - ${movement.bookmaker}`);
if (movement.lineChanged) {
console.log(`Line: ${movement.previous.line} -> ${movement.current.line}`);
}
if (movement.priceChanged) {
console.log(`Price: ${movement.previous.price} -> ${movement.current.price}`);
}
console.log("----------------------------------------\n");
});
}
// Main tracking loop
async function trackOdds() {
console.log(new Date().toLocaleTimeString(), "- Checking for line movement...");
const events = await fetchNBAOdds();
if (events.length === 0) {
console.log("No NBA games found\n");
return;
}
console.log(`Tracking ${events.length} NBA game(s)`);
const movements = detectLineMovement(events);
displayMovements(movements);
}
// Start tracking
console.log("Starting NBA Live Odds Tracker...");
console.log(`Polling every ${POLL_INTERVAL / 1000} seconds\n`);
// Run immediately, then every 30 seconds
trackOdds();
setInterval(trackOdds, POLL_INTERVAL);Step 3: Run It
export SPORTSGAMEODDS_KEY=your_api_key_here
node tracker.jsExpected Output
Starting NBA Live Odds Tracker...
Polling every 30 seconds
7:45:32 PM - Checking for line movement...
Tracking 3 NBA game(s)
No line movement detected
7:46:02 PM - Checking for line movement...
Tracking 3 NBA game(s)
2 LINE MOVEMENT(S) DETECTED
----------------------------------------
Boston Celtics @ Los Angeles Lakers
SP (home) - draftkings
Line: -5.5 -> -6.0
----------------------------------------
----------------------------------------
Golden State Warriors @ Phoenix Suns
ML (away) - fanduel
Price: +145 -> +150
----------------------------------------How It Works
1. Fetch Odds
const response = await fetch(`${API_BASE}/events?leagueID=NBA&finalized=false&oddsAvailable=true`);Query parameters:
leagueID=NBA- Only NBA gamesfinalized=false- Games not yet completedoddsAvailable=true- Must have odds data
Tip: Use
live=trueinstead offinalized=falseto only get games currently in progress.
2. Process Nested Bookmaker Structure
// Each odd has a byBookmaker object with odds from each bookmaker
Object.entries(currentOdd.byBookmaker || {}).forEach(([bookmakerID, bookmakerOdds]) => {
const currentLine = bookmakerOdds.spread || bookmakerOdds.overUnder;
const currentPrice = bookmakerOdds.odds;
});The API returns odds nested by bookmaker:
{
"byBookmaker": {
"draftkings": {
"odds": "-110",
"spread": "-5.5",
"available": true
},
"fanduel": {
"odds": "-108",
"spread": "-5.5",
"available": true
}
}
}3. Compare with Previous Odds
// Key includes bookmakerID since odds are per-bookmaker
const key = `${eventID}-${oddID}-${bookmakerID}`;
const previousBookmakerOdds = previousOdds[key];
if (previousBookmakerOdds) {
const lineChanged = previousLine !== currentLine;
const priceChanged = previousBookmakerOdds.odds !== currentBookmakerOdds.odds;
}We store every odd by a unique key (eventID-oddID-bookmakerID) and compare on each poll.
4. Detect Significant Movement
The script detects:
- Line changes - Spread/total moves (e.g., -5.5 -> -6.0)
- Price changes - Juice moves (e.g., -110 -> -115)
5. Store for Next Comparison
previousOdds[key] = {
spread: currentBookmakerOdds.spread,
overUnder: currentBookmakerOdds.overUnder,
odds: currentBookmakerOdds.odds,
lastUpdatedAt: currentBookmakerOdds.lastUpdatedAt,
};Enhancements
Track Multiple Leagues
const LEAGUES = ["NBA", "NFL", "NHL"];
async function trackAllLeagues() {
for (const league of LEAGUES) {
console.log(`\n${league} Updates:`);
const events = await fetchOdds(league);
const movements = detectLineMovement(events);
displayMovements(movements);
}
}Add Discord/Slack Notifications
async function sendDiscordAlert(movement) {
await fetch(process.env.DISCORD_WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
content: `Line moved: ${movement.matchup}\n${movement.betType} ${movement.previous.line || "N/A"} -> ${movement.current.line || "N/A"}`,
}),
});
}
// Call after detecting movement
movements.forEach(sendDiscordAlert);Filter by Significant Moves Only
function isSignificantMove(movement) {
// Only alert on moves of 0.5 points or more
if (movement.lineChanged && movement.current.line && movement.previous.line) {
const currentLine = parseFloat(movement.current.line);
const previousLine = parseFloat(movement.previous.line);
const lineDiff = Math.abs(currentLine - previousLine);
return lineDiff >= 0.5;
}
// Or price moves of 5 points or more
if (movement.priceChanged) {
const currentPrice = parseInt(movement.current.price);
const previousPrice = parseInt(movement.previous.price);
const priceDiff = Math.abs(currentPrice - previousPrice);
return priceDiff >= 5;
}
return false;
}
const significantMoves = movements.filter(isSignificantMove);Store Historical Movement Data
const fs = require("fs");
function logMovement(movement) {
const logEntry = {
timestamp: new Date().toISOString(),
...movement,
};
fs.appendFileSync("movement-history.jsonl", JSON.stringify(logEntry) + "\n");
}
movements.forEach(logMovement);Add Real-Time Streaming (AllStar Plan)
Instead of polling, use WebSocket streaming for instant updates:
// Requires AllStar plan
const stream = await client.stream.events({ feed: "events:live" });
channel.bind("update", (event) => {
const movements = detectLineMovement([event]);
displayMovements(movements);
});Troubleshooting
“No NBA games found”
Cause: No NBA games currently available (off-season or no games scheduled).
Solution: Try different leagues or check schedule:
// Try multiple leagues
const LEAGUES = ["NBA", "NFL", "NHL", "MLB"];
for (const league of LEAGUES) {
const events = await fetchOdds(league);
if (events.length > 0) {
console.log(`Found ${events.length} ${league} events`);
}
}Rate Limit Errors (429)
Cause: Polling too frequently for your plan tier.
Solutions:
- Increase
POLL_INTERVALto 60 seconds (60000) - Reduce number of leagues tracked
- Upgrade to higher tier
Missing Odds Data
Cause: Some games may not have odds available yet.
Solution: Add null check:
Object.entries(event.odds || {}).forEach(([oddID, odd]) => {
// Check if any bookmaker has odds before processing
if (!odd.byBookmaker || Object.keys(odd.byBookmaker).length === 0) return;
// ... rest of code
});Next Steps
Combine with Other Examples
- Arbitrage Calculator - Detect arb opportunities from line movements
- Odds Comparison Dashboard - Visualize movements in real-time
- Player Props Analyzer - Track prop line movements
Learn More
- Understanding oddID - Deep dive into odds structure
- Best Practices - Optimize your polling strategy
- SDK Guide - Use our TypeScript/Python SDK for easier development
