Track NBA box scores live, with player milestone alerts
A zero-dependency Node script that finds live NBA games (or one team's game), redraws each box score every minute and alerts once per player when they reach 20/30/40/50 points, a double-double or a triple-double.
- Stack
- Node.js, NBA, Discord/Slack webhook
- Plan
- Free for a few test games; Starter for one team all season
- Code
- nba-box-score/ (MIT)
Updated 2026-10-18. Unit tested on that date; the NBA regular season starts October 20, so live box scores were checked against the endpoint's documented shape. 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 nba-box-score/ 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/nba-box-score3. What the box score returns
GET /events/{id}/boxscore returns homeTeam/awayTeam with scores, periodScores (one number per quarter) and homePlayers/awayPlayers. For basketball, points, rebounds and assists are numbers and minutes a string; the other stats keep their own keys as strings, such as fieldGoalsMade-fieldGoalsAttempted: "11-22" and steals: "2". Before tip-off the endpoint answers 404 with BOXSCORE_NOT_AVAILABLE. See the box score endpoint page for a live sample.
4. Normalize players and detect milestones
playerStats reads those fields defensively, renderBox prints the score, the quarter line and each team's top five scorers, and newMilestones returns only milestones a player has not reached before, keyed by game, player and milestone, so each alert fires once. WATCH limits alerts to the players you name.
nba-box-score/lib.js
// Pure helpers for the NBA live box score tracker (plus apiGet/postWebhook). Unit tested with node --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 }. Throws on non-2xx. */
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'}`);
err.status = res.status;
err.code = body?.error?.code;
err.retryAfter = Number(res.headers.get('retry-after')) || undefined;
throw err;
}
return body;
}
export const label = (t) => t?.abbreviation || t?.name || '?';
/** True when `team` (abbreviation or id, case-insensitive) plays in `game`. Empty team = every game. */
export function involvesTeam(game, team) {
if (!team) return true;
const t = String(team).toLowerCase();
return [game.homeTeam, game.awayTeam].some((x) => x && (String(x.id) === t || String(x.abbreviation ?? '').toLowerCase() === t));
}
const num = (v) => {
const n = parseInt(String(v ?? ''), 10);
return Number.isFinite(n) ? n : 0;
};
/** Find a "made-attempted" stat by key pattern, e.g. threePointFieldGoalsMade-threePointFieldGoalsAttempted -> "3-7". */
function madeAttempted(p, re) {
const k = Object.keys(p).find((key) => re.test(key));
return k ? String(p[k]) : null;
}
/**
* Normalise one basketball box score player. points/rebounds/assists are numbers and minutes a
* string; the other stats keep the API's own keys (strings), so we read them defensively.
*/
export function playerStats(p) {
return {
name: p.name,
pts: num(p.points),
reb: num(p.rebounds),
ast: num(p.assists),
stl: num(p.steals),
blk: num(p.blocks),
min: p.minutes ?? '',
fg: madeAttempted(p, /^fieldGoalsMade-fieldGoalsAttempted$/i),
three: madeAttempted(p, /^threePoint.*Made.*Attempted$/i),
dnp: Boolean(p.didNotPlay)
};
}
/** "LeBron James 28 PTS 7 REB 9 AST (10-18 FG, 3-7 3PT, 34 MIN)" */
export function playerLine(p) {
const s = playerStats(p);
const extra = [s.fg && `${s.fg} FG`, s.three && `${s.three} 3PT`, s.min && `${s.min} MIN`].filter(Boolean).join(', ');
return `${s.name} ${s.pts} PTS ${s.reb} REB ${s.ast} AST${extra ? ` (${extra})` : ''}`;
}
/** Players who played, best scorers first. */
export function topPlayers(players, n = 5) {
return (players ?? [])
.filter((p) => !p.didNotPlay)
.sort((a, b) => num(b.points) - num(a.points) || num(b.rebounds) + num(b.assists) - num(a.rebounds) - num(a.assists))
.slice(0, n);
}
/** Plain-text box score: score line, period scores, top players per team. */
export function renderBox(box, game, top = 5) {
const a = box.awayTeam ?? game?.awayTeam ?? {};
const h = box.homeTeam ?? game?.homeTeam ?? {};
const lines = [`${label(a)} ${a.score ?? 0} @ ${label(h)} ${h.score ?? 0}${game?.status?.detail ? ` 路 ${game.status.detail}` : ''}`];
const ps = box.periodScores;
if (ps?.home?.length) {
lines.push(` ${label(a).padEnd(4)} ${ps.away.map((x) => String(x).padStart(3)).join(' ')}`);
lines.push(` ${label(h).padEnd(4)} ${ps.home.map((x) => String(x).padStart(3)).join(' ')}`);
}
for (const [team, players] of [[a, box.awayPlayers], [h, box.homePlayers]]) {
lines.push(` ${label(team)}`);
for (const p of topPlayers(players, top)) lines.push(` ${playerLine(p)}`);
}
return lines.join('\n');
}
const DOUBLE_CATS = ['pts', 'reb', 'ast', 'stl', 'blk'];
/**
* Milestones a player has reached: points thresholds, double-double, triple-double. Returns
* stable ids like "30pts" / "triple-double", so callers can alert once per player per game.
*/
export function milestonesFor(p, thresholds = [20, 30, 40, 50]) {
const s = playerStats(p);
const out = [];
for (const t of thresholds) if (s.pts >= t) out.push({ id: `${t}pts`, text: `${t}+ points` });
const tens = DOUBLE_CATS.filter((c) => s[c] >= 10).length;
if (tens >= 3) out.push({ id: 'triple-double', text: 'a triple-double' });
else if (tens >= 2) out.push({ id: 'double-double', text: 'a double-double' });
return out;
}
/**
* New milestones since last time. `seen` is a Set of "eventId:playerId:milestoneId" and is
* updated in place. Players in `watch` (lowercase names; empty = everyone) are the only ones checked.
*/
export function newMilestones(eventId, box, seen, { watch = [], thresholds } = {}) {
const out = [];
for (const [side, players] of [['awayTeam', box.awayPlayers], ['homeTeam', box.homePlayers]]) {
for (const p of players ?? []) {
if (watch.length && !watch.includes(String(p.name).toLowerCase())) continue;
for (const m of milestonesFor(p, thresholds)) {
const key = `${eventId}:${p.id ?? p.name}:${m.id}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({ player: p, team: box[side], milestone: m, text: `馃弨 ${p.name} (${label(box[side])}) has ${m.text}: ${playerLine(p)}` });
}
}
}
return out;
}
/** POST a message to a Discord or Slack webhook (Discord reads `content`, Slack reads `text`). */
export async function postWebhook(url, message) {
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: message, text: message }) });
if (!res.ok) console.error(`webhook failed: HTTP ${res.status}`);
}
5. The tracker loop
Each minute it reads the live games, fetches one box score per live game and redraws the screen. Milestones already reached when the tracker starts are recorded silently, so a restart in the fourth quarter does not replay them. With no live games it reads the scoreboard once and sleeps until about two minutes before the next tip-off.
nba-box-score/tracker.js
// NBA live box score and player stat tracker for the Realtime Sports API. Node 18+, no dependencies.
//
// node tracker.js live box scores (refreshing) + milestone alerts while games are on
// node tracker.js --once print the box score of every live game once
// node tracker.js --event <id> print one game's box score (live or finished) and exit
import { apiGet, involvesTeam, loadDotEnv, newMilestones, postWebhook, renderBox } from './lib.js';
loadDotEnv(new URL('./.env', import.meta.url));
loadDotEnv(new URL('../.env', import.meta.url));
const argv = process.argv.slice(2);
const env = process.env;
const LEAGUE = env.LEAGUE || 'nba'; // mens-college-basketball works too (box scores from its season start)
const TEAM = env.TEAM || '';
const WATCH = (env.WATCH || '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
const THRESHOLDS = (env.POINT_THRESHOLDS || '20,30,40,50').split(',').map(Number).filter(Boolean);
const LIVE_MS = Math.max(30, Number(env.POLL_SECONDS || 60)) * 1000;
const MAX_IDLE_MS = 6 * 3_600_000;
const KEY = env.REALTIME_SPORTS_API_KEY;
const base = `/sports/basketball/leagues/${LEAGUE}`;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const log = (...a) => console.error(new Date().toISOString(), ...a);
function die(msg) {
console.error(msg);
process.exit(1);
}
if (!KEY || KEY === 'your_api_key_here') die('Set REALTIME_SPORTS_API_KEY (see .env.example). Free key: https://www.realtimesportsapi.com/signup');
async function boxFor(id) {
try {
return (await apiGet(`${base}/events/${id}/boxscore`, KEY)).data;
} catch (err) {
if (err.code === 'BOXSCORE_NOT_AVAILABLE' || err.status === 404) return null; // tip-off not reached yet
throw err;
}
}
const liveGames = async () => (await apiGet(`${base}/events/live`, KEY)).data.filter((g) => involvesTeam(g, TEAM));
async function printEvent(id) {
const [{ data: game }, box] = await Promise.all([apiGet(`${base}/events/${id}`, KEY), boxFor(id)]);
console.log(box ? renderBox(box, game, 8) : `No box score yet for ${game.shortName} (${game.status?.detail}).`);
}
async function loop() {
log(`Tracking ${LEAGUE.toUpperCase()}${TEAM ? ` (${TEAM})` : ''} box scores every ${LIVE_MS / 1000}s while live${WATCH.length ? `; watching ${WATCH.join(', ')}` : ''}`);
const seen = new Set();
let first = true;
for (;;) {
try {
const games = await liveGames();
if (!games.length) {
const events = (await apiGet(`${base}/events`, KEY)).data.filter((g) => involvesTeam(g, TEAM));
const starts = events.filter((e) => e.status?.state === 'pre').map((e) => Date.parse(e.date)).filter((t) => t > Date.now());
const wait = starts.length ? Math.min(Math.max(Math.min(...starts) - Date.now() - 120_000, 5 * 60_000), MAX_IDLE_MS) : TEAM && !events.length ? MAX_IDLE_MS : 15 * 60_000;
log(`Nothing live; next check in ${Math.round(wait / 60000)} min`);
await sleep(wait);
continue;
}
const screens = [];
for (const g of games) {
const box = await boxFor(g.id);
if (!box) continue;
screens.push(renderBox(box, g));
// On the first pass, record milestones already reached so a restart doesn't re-alert them.
const alerts = newMilestones(g.id, box, seen, { watch: WATCH, thresholds: THRESHOLDS });
if (first) continue;
for (const a of alerts) {
console.log(a.text);
if (env.WEBHOOK_URL) await postWebhook(env.WEBHOOK_URL, a.text);
}
}
first = false;
if (process.stderr.isTTY) process.stderr.write('\x1b[2J\x1b[H'); // refresh the screen in a terminal
process.stderr.write(screens.join('\n\n') + '\n');
await sleep(LIVE_MS);
} catch (err) {
if (err.status === 401) die(err.message);
const wait = err.status === 429 ? Math.min((err.retryAfter ?? 3600) * 1000, MAX_IDLE_MS) : LIVE_MS;
log(`${err.message}; retrying in ${Math.round(wait / 1000)} s`);
await sleep(wait);
}
}
}
const ev = argv.indexOf('--event');
if (ev >= 0) {
if (!argv[ev + 1]) die('usage: node tracker.js --event <eventId>');
printEvent(argv[ev + 1]).catch((e) => die(e.message));
} else if (argv.includes('--once')) {
liveGames()
.then(async (games) => {
if (!games.length) return log(`No ${LEAGUE.toUpperCase()}${TEAM ? ` ${TEAM}` : ''} games live right now. Try --event <id> with a finished game.`);
for (const g of games) {
const box = await boxFor(g.id);
console.log(box ? renderBox(box, g) : `${g.shortName}: no box score yet`);
}
})
.catch((e) => die(e.message));
} else {
process.once('SIGINT', () => process.exit(0));
await loop();
}
6. Run it
The NBA regular season tips off on October 20, 2026. Until then, --event prints the box score of any finished game.
Terminal
node tracker.js --event <eventId> # one game, live or finished
TEAM=BOS node tracker.js --once # the Celtics game, if live
WATCH="Player One,Player Two" WEBHOOK_URL=https://discord.com/api/webhooks/... node tracker.js
npm test7. Quota
Each refresh is one live-games call plus one box score call per live game: about 300 calls for one game at 60-second refreshes, roughly 4,200 a month for one team, which fits Starter (10,000 calls). Every game on a full 10-game night is about 2,000 calls, so a whole-league tracker needs Pro or Scale. Data is typically 20-30 seconds behind live play, so refreshing faster than every 30 seconds buys nothing.
Endpoints used
GET /sports/basketball/leagues/nba/events/liveGET /sports/basketball/leagues/nba/eventsGET /sports/basketball/leagues/nba/events/{eventId}/boxscoreGET /sports/basketball/leagues/nba/events/{eventId}
Full reference: docs 路 OpenAPI 路 what each endpoint returns, by league
FAQ
- Does it work for college basketball?
- Yes, set LEAGUE=mens-college-basketball. Box scores for the 2026-27 college season are verified once games are played (the season starts November 3).
- Can I alert on one player only?
- Yes, set WATCH to a comma-separated list of names as they appear in the box score.
- Which stats are included?
- Points, rebounds, assists, minutes, field goals, three-pointers, steals and blocks, plus every other stat key the box score carries for basketball.