Build a Discord live scores bot
A bot that posts every score change and final score for a league, or just your team, to a Discord channel. It streams over WebSocket when your plan has it and falls back to quota-friendly REST polling.
- Stack
- Node.js, Discord webhook, WebSocket
- Plan
- Free for a dry run; Starter for live polling all season
- Code
- discord-score-bot/ (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 discord-score-bot/ 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/discord-score-bot3. Create a Discord channel webhook
In Discord open Channel settings > Integrations > Webhooks > New Webhook and copy the URL. No bot account or token is needed. Treat the URL like a password: anyone with it can post to the channel.
Put both values in .env (copy .env.example). SPORT/LEAGUE default to football/nfl; set TEAM=KC (abbreviation or team id) to follow one team. Team ids are on each team API page.
4. How the bot decides what to post
lib.js holds the network-free helpers: diffScores compares the last poll with the current live games and returns the games whose score changed plus the ones that dropped off the live list (finished). idleSleepMs sleeps until about two minutes before the next game when nothing is live, so idle days cost a handful of calls.
discord-score-bot/lib.js
// Helpers for the bot, kept free of network I/O so they can be unit tested with `node --test`.
import { existsSync, readFileSync } from 'node:fs';
/** Minimal .env loader (KEY=value lines; existing environment variables win). Works on Node 18+. */
export function loadDotEnv(path) {
if (!existsSync(path)) return;
for (const line of readFileSync(path, 'utf8').split(/\r?\n/)) {
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
if (!m || line.trimStart().startsWith('#')) continue;
const value = m[2].replace(/^(['"])(.*)\1$/, '$2');
if (process.env[m[1]] === undefined) process.env[m[1]] = value;
}
}
/** Short label for a team object from an event or WebSocket payload. */
export function teamLabel(team) {
if (!team) return '?';
return team.abbreviation || team.shortDisplayName || team.name || team.displayName || team.id || '?';
}
/** True when `teamFilter` (abbreviation or id, case-insensitive) matches either side. Empty filter matches all. */
export function involvesTeam(game, teamFilter) {
if (!teamFilter) return true;
const want = String(teamFilter).trim().toLowerCase();
return [game?.homeTeam, game?.awayTeam].some(
(t) => t && [t.abbreviation, t.id, t.name, t.displayName].some((v) => v != null && String(v).toLowerCase() === want)
);
}
export function scoreKey(game) {
return `${game?.awayTeam?.score ?? '-'}-${game?.homeTeam?.score ?? '-'}`;
}
/** "**KC 14 - 10 BAL** (5:23 - 2nd Quarter)" (away first, like a scoreboard). */
export function formatScore(game, prefix = '') {
const a = game.awayTeam ?? {};
const h = game.homeTeam ?? {};
const detail = game.status?.detail ? ` (${game.status.detail})` : '';
return `${prefix}**${teamLabel(a)} ${a.score ?? 0} - ${h.score ?? 0} ${teamLabel(h)}**${detail}`;
}
/**
* Compare the previous scores with the current live games.
* Returns { changes: games whose score changed, finished: eventIds no longer live, next: new score map }.
*/
export function diffScores(prev, games) {
const next = new Map();
const changes = [];
for (const g of games) {
const key = scoreKey(g);
next.set(g.id, key);
if (prev.has(g.id) && prev.get(g.id) !== key) changes.push(g);
}
const finished = [...prev.keys()].filter((id) => !next.has(id));
return { changes, finished, next };
}
/** Earliest upcoming start time (ms) among events in state "pre", or null. */
export function nextStartMs(events, now = Date.now()) {
let best = null;
for (const e of events ?? []) {
if (e?.status?.state !== 'pre' || !e.date) continue;
const t = Date.parse(e.date);
if (Number.isFinite(t) && t > now && (best === null || t < best)) best = t;
}
return best;
}
/**
* How long to sleep when nothing is live: the idle interval, or (when the next game is further away)
* until ~2 minutes before it, capped at maxIdleMs.
*/
export function idleSleepMs({ nextStart, now = Date.now(), idleMs, maxIdleMs }) {
if (nextStart == null) return idleMs;
const untilStart = nextStart - now - 2 * 60_000;
if (untilStart <= idleMs) return idleMs;
return Math.min(untilStart, maxIdleMs);
}
5. The bot loop
bot.js subscribes to event_score_change and event_final over the WebSocket (paid plans; the Free plan has a 500-message monthly preview). Without WebSocket access, or with MODE=poll, it polls /events/live every 60 seconds while games are live. Duplicate messages after reconnects are dropped.
discord-score-bot/bot.js
// Discord score bot for the Realtime Sports API.
// Streams score changes over the WebSocket (paid plans; Free has a 500-message monthly preview) or falls back to sane REST polling.
// Usage: npm start (posts to DISCORD_WEBHOOK_URL)
// npm run dry-run (prints messages instead of posting)
import WebSocket from 'ws';
import { RealtimeSportsClient, RealtimeSportsError } from 'realtime-sports-api';
import { diffScores, formatScore, idleSleepMs, involvesTeam, loadDotEnv, nextStartMs, scoreKey } from './lib.js';
loadDotEnv(new URL('./.env', import.meta.url));
const env = process.env;
const DRY_RUN = process.argv.includes('--dry-run') || env.DRY_RUN === '1';
const SPORT = env.SPORT || 'football';
const LEAGUE = env.LEAGUE || 'nfl';
const TEAM = (env.TEAM || '').trim();
const MODE = (env.MODE || 'auto').toLowerCase();
const LIVE_MS = Math.max(30, Number(env.POLL_LIVE_SECONDS || 60)) * 1000;
const IDLE_MS = Math.max(5, Number(env.POLL_IDLE_MINUTES || 15)) * 60_000;
const MAX_IDLE_MS = Math.max(1, Number(env.POLL_MAX_IDLE_HOURS || 6)) * 3_600_000;
if (!env.REALTIME_SPORTS_API_KEY || env.REALTIME_SPORTS_API_KEY === 'your_api_key_here') {
die('Set REALTIME_SPORTS_API_KEY (see .env.example). Get a free key at https://www.realtimesportsapi.com/signup');
}
if (!env.DISCORD_WEBHOOK_URL && !DRY_RUN) die('Set DISCORD_WEBHOOK_URL, or run with --dry-run.');
const client = new RealtimeSportsClient({
apiKey: env.REALTIME_SPORTS_API_KEY,
...(env.RSA_BASE_URL ? { baseUrl: env.RSA_BASE_URL } : {}) // only for local testing
});
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const log = (...a) => console.log(new Date().toISOString(), ...a);
function die(msg) {
console.error(msg);
process.exit(1);
}
// ---------------------------------------------------------------- Discord
async function post(content) {
if (DRY_RUN) {
log('[dry-run]', content);
return;
}
for (let attempt = 0; attempt < 3; attempt++) {
const res = await fetch(env.DISCORD_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, allowed_mentions: { parse: [] } })
});
if (res.ok) return;
if (res.status === 429) {
const body = await res.json().catch(() => ({}));
await sleep(Math.ceil((body.retry_after ?? 1) * 1000));
continue;
}
log('Discord webhook failed:', res.status, await res.text().catch(() => ''));
return;
}
}
// ---------------------------------------------------------------- WebSocket mode
function runStream() {
return new Promise((resolve) => {
const lastScore = new Map(); // eventId -> "away-home", dedupes repeats after reconnects
const filters = { sport: SPORT, league: LEAGUE };
let settled = false;
const stream = client.stream({
WebSocket: globalThis.WebSocket ?? WebSocket,
subscriptions: [
{ event: 'event_score_change', filters },
{ event: 'event_final', filters }
],
onOpen: () => log(`Streaming ${SPORT}/${LEAGUE}${TEAM ? ` (team ${TEAM})` : ''}`),
onSubscribed: (ack) => ack.warnings?.forEach((w) => log('Subscription warning:', w)),
onMessage: async (msg) => {
const g = { ...msg.data, id: msg.data.eventId };
if (!involvesTeam(g, TEAM)) return;
if (msg.type === 'event_final') {
lastScore.delete(g.id);
await post(formatScore({ ...g, status: { detail: 'Final' } }, 'FINAL: '));
return;
}
const key = scoreKey(g);
if (lastScore.get(g.id) === key) return;
lastScore.set(g.id, key);
await post(formatScore(g));
},
onError: (err) => {
log('Stream error:', err.code, err.message);
// No WebSocket on this plan (or bad key / quota): hand over to polling in auto mode.
const fatal = err.code === 'WEBSOCKET_FORBIDDEN' || err.status === 401 || err.status === 403 || err.status === 429;
if (fatal && !settled) {
settled = true;
stream.close();
resolve(err);
}
}
});
const stop = () => {
stream.close();
process.exit(0);
};
process.once('SIGINT', stop);
process.once('SIGTERM', stop);
});
}
// ---------------------------------------------------------------- REST polling mode
async function runPolling() {
log(`Polling ${SPORT}/${LEAGUE}${TEAM ? ` (team ${TEAM})` : ''}: every ${LIVE_MS / 1000}s while live, idle ${IDLE_MS / 60000} min`);
let prev = new Map();
let live = false;
for (;;) {
try {
if (live || prev.size > 0) {
// 1 call per cycle while games are live.
const games = (await client.listLiveEvents(SPORT, LEAGUE)).filter((g) => involvesTeam(g, TEAM));
const { changes, finished, next } = diffScores(prev, games);
for (const g of changes) await post(formatScore(g));
for (const id of finished) {
// 1 extra call per finished game for the final score.
const g = await client.getEvent(SPORT, LEAGUE, id).catch(() => null);
if (g) await post(formatScore({ ...g, status: { ...g.status, detail: g.status?.detail || 'Final' } }, 'FINAL: '));
}
if (prev.size === 0 && games.length) log(`Tracking ${games.length} live game(s)`);
prev = next;
live = games.length > 0;
if (live) {
await sleep(LIVE_MS);
continue;
}
}
// Idle: 1 call to the scoreboard window, which includes live and upcoming games.
const events = (await client.listEvents(SPORT, LEAGUE)).filter((g) => involvesTeam(g, TEAM));
const liveNow = events.filter((e) => e.status?.state === 'in');
if (liveNow.length) {
prev = new Map(liveNow.map((g) => [g.id, scoreKey(g)]));
live = true;
for (const g of liveNow) await post(formatScore(g, 'LIVE: '));
await sleep(LIVE_MS);
continue;
}
const wait = idleSleepMs({ nextStart: nextStartMs(events), idleMs: IDLE_MS, maxIdleMs: MAX_IDLE_MS });
log(`Nothing live; next check in ${Math.round(wait / 60000)} min`);
await sleep(wait);
} catch (err) {
if (err instanceof RealtimeSportsError && err.isRateLimited) {
const wait = Math.min((err.retryAfter ?? 3600) * 1000, 6 * 3_600_000);
log(`Monthly quota exhausted; sleeping ${Math.round(wait / 60000)} min. Upgrade or wait for the reset.`);
await sleep(wait);
} else if (err instanceof RealtimeSportsError && err.isAuthError) {
die(`Auth failed (${err.code}): ${err.message}`);
} else {
log('Polling error:', err?.message ?? err);
await sleep(LIVE_MS);
}
}
}
}
// ---------------------------------------------------------------- main
if (MODE === 'poll') {
await runPolling();
} else {
const err = await runStream();
if (MODE === 'stream') die(`Stream stopped: ${err?.message}`);
log('Falling back to REST polling.');
await runPolling();
}
6. Run it
Start with a dry run, which prints the messages instead of posting them. Then run it for real, on any always-on machine or a small VM.
Terminal
npm install
npm run dry-run # prints messages
npm test # unit tests for the helpers
npm start # posts to Discord7. Keep it inside your quota
Polling one NFL team at 60 seconds is about 210 calls per game, roughly 1,000-1,100 calls a month, which fits Starter. The regular Free quota (125 a month) is not enough for live polling. Over WebSocket you pay per delivered message instead; subscriptions are league-wide, so filter to one game with an eventId filter or add a frequency throttle. Polling faster than 30 seconds buys nothing: data is typically 20-30 seconds behind live.
Endpoints used
POST /api/websocket/auth (WebSocket token)GET /sports/{sport}/leagues/{league}/events/liveGET /sports/{sport}/leagues/{league}/eventsGET /sports/{sport}/leagues/{league}/events/{eventId}
Full reference: docs · OpenAPI · what each endpoint returns, by league
FAQ
- Do I need a Discord bot token?
- No. The bot posts through a channel webhook URL, which you create in the channel settings.
- Which leagues does it support?
- Any of the 29 monitored leagues: set SPORT and LEAGUE, e.g. basketball/nba, hockey/nhl, baseball/mlb, soccer/eng.1. "football" means American football; soccer is "soccer".
- Can it follow just one team?
- Yes, set TEAM to the abbreviation (e.g. KC) or the team id. In WebSocket mode the league is still streamed and the bot filters, so the messages still count toward your quota.