Build a Telegram live scores bot
A zero-dependency Node bot that polls live games every 60 seconds and messages a Telegram chat when a game starts, the score changes and it ends, then sleeps until the next game.
- Stack
- Node.js, Telegram Bot API
- Plan
- Free to try; Starter for one team all season
- Code
- telegram-bot/ (MIT)
Updated 2026-10-18. Tested against live NHL and NFL scoreboards 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 telegram-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/telegram-bot3. Create the bot and find the chat id
Message @BotFather, send /newbot and copy the token. Add the bot to your group (or message it directly), open https://api.telegram.org/bot<TOKEN>/getUpdates and read message.chat.id; group ids are negative. For a channel, make the bot an admin and use @yourchannel. Put the token, chat id and your API key in .env and set SPORT/LEAGUE (default hockey/nhl) and optionally TEAM.
4. Diff the live games
diffGames compares two polls of /events/live: new ids are games that started, changed scores are score updates, and ids that disappear have finished (the bot then fetches /events/{id} once for the final). The first poll only records a baseline. telegramText builds an HTML-mode message and escapes team names.
telegram-bot/lib.js
// Helpers for the Telegram live-score bot. Network-free except apiGet/sendTelegram, so the diff
// and formatting logic can be 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.retryAfter = Number(res.headers.get('retry-after')) || undefined;
throw err;
}
return body;
}
export const teamLabel = (t) => t?.abbreviation || t?.name || '?';
/** True when `team` (abbreviation or id, case-insensitive) is playing 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));
}
/**
* Compare the previous snapshot map (eventId -> { away, home }) with the games live now.
* Returns started / scored games, finished event ids, and the next snapshot map. On the first poll
* (`initialised: false`) nothing is reported, so a restart mid-game does not re-announce everything.
*/
export function diffGames(prev, games, { initialised = true } = {}) {
const next = new Map();
const started = [];
const scored = [];
for (const g of games) {
const snap = { away: g.awayTeam?.score ?? 0, home: g.homeTeam?.score ?? 0 };
next.set(g.id, snap);
const old = prev.get(g.id);
if (!old) {
if (initialised) started.push(g);
} else if (old.away !== snap.away || old.home !== snap.home) {
scored.push(g);
}
}
const finished = [...prev.keys()].filter((id) => !next.has(id));
return { started, scored, finished, next };
}
/** Escape text for Telegram's HTML parse mode. */
export const escapeHtml = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
const PREFIX = { start: '馃煝 Started', score: '馃毃 Score', final: '馃弫 Final', info: '馃搮' };
/** Telegram message (HTML parse mode) for one game: "馃毃 Score: <b>CAR 2 - 1 CGY</b> (13:12 - 2nd Period)". */
export function telegramText(game, kind = 'score', league = '') {
const a = game.awayTeam ?? {};
const h = game.homeTeam ?? {};
const line = game.status?.state === 'pre'
? `${teamLabel(a)} @ ${teamLabel(h)}`
: `${teamLabel(a)} ${a.score ?? 0} - ${h.score ?? 0} ${teamLabel(h)}`;
const detail = game.status?.detail || (kind === 'final' ? 'Final' : '');
const tag = league ? ` 路 ${league.toUpperCase()}` : '';
return `${PREFIX[kind] ?? ''}: <b>${escapeHtml(line)}</b>${detail ? ` (${escapeHtml(detail)})` : ''}${tag}`.replace(/^: /, '');
}
/** Earliest future start time (ms) among pre-game events, 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;
}
/** Idle sleep: the idle interval, or until ~2 min before a distant next game, capped at maxIdleMs. */
export function idleSleepMs({ nextStart, now = Date.now(), idleMs, maxIdleMs }) {
if (nextStart == null) return idleMs;
const until = nextStart - now - 2 * 60_000;
if (until <= idleMs) return idleMs;
return Math.min(until, maxIdleMs);
}
/** Send one message through the Telegram Bot API. Retries once when Telegram rate-limits (429). */
export async function sendTelegram(token, chatId, text) {
for (let attempt = 0; attempt < 2; attempt++) {
const res = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chat_id: chatId, text, parse_mode: 'HTML', disable_web_page_preview: true })
});
const body = await res.json().catch(() => ({}));
if (res.ok && body.ok) return true;
if (res.status === 429) {
await new Promise((r) => setTimeout(r, (body.parameters?.retry_after ?? 1) * 1000));
continue;
}
console.error(`Telegram sendMessage failed: HTTP ${res.status} ${body.description ?? ''}`);
return false;
}
return false;
}
5. The bot loop
While games are live it polls every 60 seconds. When nothing is live it reads the scoreboard once and sleeps until about two minutes before the next game; with TEAM set and no game of theirs on the scoreboard it sleeps 6 hours.
telegram-bot/bot.js
// Telegram live-score bot for the Realtime Sports API. Node 18+, no dependencies.
//
// node bot.js poll and send score changes to TELEGRAM_CHAT_ID
// node bot.js --dry-run same loop, printing messages instead of sending
// node bot.js --once one pass: print a message for every game live right now
// node bot.js --once --scoreboard one pass over the league scoreboard (upcoming, live, final)
// node bot.js --once --post one pass, really sending to Telegram
import { apiGet, diffGames, idleSleepMs, involvesTeam, loadDotEnv, nextStartMs, sendTelegram, telegramText } from './lib.js';
loadDotEnv(new URL('./.env', import.meta.url));
loadDotEnv(new URL('../.env', import.meta.url)); // repo-root .env as a fallback
const args = new Set(process.argv.slice(2));
const env = process.env;
const ONCE = args.has('--once');
const DRY_RUN = args.has('--dry-run') || (ONCE && !args.has('--post'));
const SPORT = env.SPORT || 'hockey';
const LEAGUE = env.LEAGUE || 'nhl';
const TEAM = env.TEAM || '';
const LIVE_MS = Math.max(30, Number(env.POLL_SECONDS || 60)) * 1000;
const IDLE_MS = Math.max(5, Number(env.IDLE_MINUTES || 15)) * 60_000;
const MAX_IDLE_MS = 6 * 3_600_000;
const KEY = env.REALTIME_SPORTS_API_KEY;
if (!KEY || 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 (!DRY_RUN && (!env.TELEGRAM_BOT_TOKEN || !env.TELEGRAM_CHAT_ID)) {
die('Set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID, or run with --dry-run / --once.');
}
const base = `/sports/${SPORT}/leagues/${LEAGUE}`;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const log = (...a) => console.error(new Date().toISOString(), ...a); // logs on stderr, messages on stdout
const mine = (games) => games.filter((g) => involvesTeam(g, TEAM));
function die(msg) {
console.error(msg);
process.exit(1);
}
async function send(text) {
if (DRY_RUN) return console.log(text);
await sendTelegram(env.TELEGRAM_BOT_TOKEN, env.TELEGRAM_CHAT_ID, text);
}
async function once() {
const path = args.has('--scoreboard') ? `${base}/events` : `${base}/events/live`;
const games = mine((await apiGet(path, KEY)).data);
if (!games.length) return log(`No ${LEAGUE}${TEAM ? ` ${TEAM}` : ''} games ${args.has('--scoreboard') ? 'on the scoreboard' : 'live right now'}.`);
for (const g of games) {
const kind = g.status?.state === 'post' ? 'final' : g.status?.state === 'in' ? 'score' : 'info';
await send(telegramText(g, kind, LEAGUE));
}
}
async function loop() {
log(`Watching ${SPORT}/${LEAGUE}${TEAM ? ` (${TEAM})` : ''}: every ${LIVE_MS / 1000}s while live${DRY_RUN ? ' (dry run)' : ''}`);
let prev = new Map();
let initialised = false;
for (;;) {
try {
const live = mine((await apiGet(`${base}/events/live`, KEY)).data);
const { started, scored, finished, next } = diffGames(prev, live, { initialised });
for (const g of started) await send(telegramText(g, 'start', LEAGUE));
for (const g of scored) await send(telegramText(g, 'score', LEAGUE));
for (const id of finished) {
// One extra call per finished game, for the final score.
const { data: g } = await apiGet(`${base}/events/${id}`, KEY).catch(() => ({ data: null }));
if (g) await send(telegramText(g, 'final', LEAGUE));
}
prev = next;
initialised = true;
if (live.length) {
await sleep(LIVE_MS);
continue;
}
// Nothing live: one scoreboard call to find the next start, then sleep until just before it.
const events = mine((await apiGet(`${base}/events`, KEY)).data);
// With TEAM set and no game of theirs on the current scoreboard, they don't play soon: sleep long.
const idleMs = TEAM && !events.length ? MAX_IDLE_MS : IDLE_MS;
const wait = idleSleepMs({ nextStart: nextStartMs(events), idleMs, maxIdleMs: MAX_IDLE_MS });
log(`Nothing live; next check in ${Math.round(wait / 60000)} min`);
await sleep(wait);
} 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);
}
}
}
if (ONCE) {
once().catch((err) => die(err.message));
} else {
process.once('SIGINT', () => process.exit(0));
await loop();
}
6. Run it
On 18 October 2026, node bot.js --once --scoreboard printed 馃弫 Final: <b>CBJ 1 - 2 MIN</b> (Final) 路 NHL and 馃毃 Score: <b>CAR 1 - 0 CGY</b> (12:44 - 2nd Period) 路 NHL.
Terminal
node bot.js --once --scoreboard # print messages, no sending
npm test # diff + formatting tests
node bot.js --dry-run # full loop, printing
npm start # full loop, sending to Telegram7. Quota
One NBA or NHL game window is about 150-180 calls at 60-second polling, so one team for a season is about 2,000-2,500 calls a month (Starter). A whole league every night is about 11,000 (Growth). For instant pushes without polling, use webhooks.
Endpoints used
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
- Does the bot need a server?
- Any always-on machine running Node 18 works: a Raspberry Pi, a small VM or a free-tier container.
- Can it post to a channel?
- Yes. Make the bot an admin of the channel and set TELEGRAM_CHAT_ID to @yourchannel.
- Which leagues does it support?
- Any of the 29 monitored leagues: set SPORT and LEAGUE, e.g. basketball/nba, football/nfl, basketball/mens-college-basketball, soccer/eng.1.