Skip to content

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

bash
mkdir odds-tracker && cd odds-tracker
npm init -y
npm install node-fetch@2

Note: We use node-fetch@2 because v3+ is ESM-only. The @2 ensures CommonJS compatibility.

Step 2: Create tracker.js

javascript
// 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

bash
export SPORTSGAMEODDS_KEY=your_api_key_here
node tracker.js

Expected 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

javascript
const response = await fetch(`${API_BASE}/events?leagueID=NBA&finalized=false&oddsAvailable=true`);

Query parameters:

  • leagueID=NBA - Only NBA games
  • finalized=false - Games not yet completed
  • oddsAvailable=true - Must have odds data

Tip: Use live=true instead of finalized=false to only get games currently in progress.

2. Process Nested Bookmaker Structure

javascript
// 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:

json
{
  "byBookmaker": {
    "draftkings": {
      "odds": "-110",
      "spread": "-5.5",
      "available": true
    },
    "fanduel": {
      "odds": "-108",
      "spread": "-5.5",
      "available": true
    }
  }
}

3. Compare with Previous Odds

javascript
// 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

javascript
previousOdds[key] = {
  spread: currentBookmakerOdds.spread,
  overUnder: currentBookmakerOdds.overUnder,
  odds: currentBookmakerOdds.odds,
  lastUpdatedAt: currentBookmakerOdds.lastUpdatedAt,
};

Enhancements

Track Multiple Leagues

javascript
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

javascript
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

javascript
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

javascript
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:

javascript
// Requires AllStar plan
const stream = await client.stream.events({ feed: "events:live" });

channel.bind("update", (event) => {
  const movements = detectLineMovement([event]);
  displayMovements(movements);
});

See Real-time Streaming Guide

Troubleshooting

“No NBA games found”

Cause: No NBA games currently available (off-season or no games scheduled).

Solution: Try different leagues or check schedule:

javascript
// 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:

  1. Increase POLL_INTERVAL to 60 seconds (60000)
  2. Reduce number of leagues tracked
  3. Upgrade to higher tier

See Rate Limiting Guide

Missing Odds Data

Cause: Some games may not have odds available yet.

Solution: Add null check:

javascript
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

Learn More