Build a Slack live scores bot

A zero-dependency Node script that polls live games every 60 seconds and posts a Slack message when a game starts, when the score changes and when it ends, then sleeps until the next game.

Stack
Node.js, Slack Incoming Webhook
Plan
Free to try; Starter for live polling every game day

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 slack-scoreboard/ 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/slack-scoreboard

3. Create a Slack Incoming Webhook

Create an app at api.slack.com/apps, turn on Incoming Webhooks, add one for your channel and copy its URL. Put it and your key in .env (SLACK_WEBHOOK_URL, REALTIME_SPORTS_API_KEY), and set SPORT/LEAGUE (default football/nfl).

4. Diff the live games and build Slack blocks

diffGames compares two polls of /events/live: new ids are games that started, changed scores are score updates, and ids that disappeared have finished (the bot then fetches /events/{id} once for the final score). slackPayload turns a game into a text fallback plus Block Kit blocks.

slack-scoreboard/lib.js

// Helpers for the Slack scoreboard. Network-free except apiGet/postToSlack, 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 function teamLabel(t) {
  return t?.abbreviation || t?.name || '?';
}

/** Snapshot of what we compare between polls. */
export function snapshot(game) {
  return {
    away: game.awayTeam?.score ?? 0,
    home: game.homeTeam?.score ?? 0,
    state: game.status?.state ?? 'pre'
  };
}

/**
 * Compare the previous snapshot map (eventId -> snapshot) with the games currently live.
 * Returns:
 *   started:  games live now that were not live last time (only when `prev` was initialised)
 *   scored:   games whose score changed
 *   finished: eventIds that were live last time and are gone now (fetch them for the final)
 *   next:     the new snapshot map
 */
export function diffGames(prev, games, { initialised = true } = {}) {
  const next = new Map();
  const started = [];
  const scored = [];
  for (const g of games) {
    const snap = snapshot(g);
    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 };
}

/** "TB 14 - 10 DAL" (away first, like a scoreboard). Scores are omitted before kickoff. */
export function scoreLine(game) {
  const a = game.awayTeam ?? {};
  const h = game.homeTeam ?? {};
  if (game.status?.state === 'pre') return `${teamLabel(a)} @ ${teamLabel(h)}`;
  return `${teamLabel(a)} ${a.score ?? 0} - ${h.score ?? 0} ${teamLabel(h)}`;
}

const KIND_PREFIX = { start: ':large_green_circle: Started', score: ':rotating_light: Score', final: ':checkered_flag: Final', info: '' };

/**
 * Build a Slack Incoming Webhook payload for one game.
 * `text` is the notification/fallback text; `blocks` is what Slack renders.
 */
export function slackPayload(game, kind = 'score', league = '') {
  const prefix = KIND_PREFIX[kind] ?? '';
  const detail = game.status?.detail || (kind === 'final' ? 'Final' : '');
  const line = scoreLine(game);
  const text = `${prefix ? prefix + ': ' : ''}${line}${detail ? ` (${detail})` : ''}`;
  const tag = league ? `${league.toUpperCase()} · ` : '';
  return {
    text,
    blocks: [
      { type: 'section', text: { type: 'mrkdwn', text: `${prefix ? prefix + '  ' : ''}*${line}*` } },
      { type: 'context', elements: [{ type: 'mrkdwn', text: `${tag}${detail || ' '}` }] }
    ]
  };
}

/** 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);
}

/** POST a payload to a Slack Incoming Webhook URL, retrying once on 429. */
export async function postToSlack(webhookUrl, payload) {
  for (let attempt = 0; attempt < 2; attempt++) {
    const res = await fetch(webhookUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload)
    });
    if (res.ok) return true;
    if (res.status === 429) {
      const wait = Number(res.headers.get('retry-after')) || 1;
      await new Promise((r) => setTimeout(r, wait * 1000));
      continue;
    }
    console.error(`Slack webhook failed: HTTP ${res.status} ${await res.text().catch(() => '')}`);
    return false;
  }
  return false;
}

5. The polling loop

While games are live it polls every 60 seconds (minimum 30). When nothing is live it reads the scoreboard once and sleeps until about two minutes before the next start, at most 6 hours.

slack-scoreboard/bot.js

// Slack live-score bot for the Realtime Sports API. Node 18+, no dependencies.
//
//   node bot.js                    poll and post score changes to SLACK_WEBHOOK_URL
//   node bot.js --dry-run          same loop, but print the Slack payloads instead of posting
//   node bot.js --once             one pass: print a payload 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, actually posting to Slack
import { apiGet, diffGames, idleSleepMs, loadDotEnv, nextStartMs, postToSlack, slackPayload } 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 || 'football';
const LEAGUE = env.LEAGUE || 'nfl';
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.SLACK_WEBHOOK_URL) die('Set SLACK_WEBHOOK_URL, 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, payloads on stdout

function die(msg) {
  console.error(msg);
  process.exit(1);
}

async function send(payload) {
  if (DRY_RUN) {
    console.log(JSON.stringify(payload));
    return;
  }
  await postToSlack(env.SLACK_WEBHOOK_URL, payload);
}

async function once() {
  const path = args.has('--scoreboard') ? `${base}/events` : `${base}/events/live`;
  const { data: games } = await apiGet(path, KEY);
  if (!games.length) {
    log(`No ${LEAGUE} games ${args.has('--scoreboard') ? 'on the scoreboard' : 'live right now'}.`);
    return;
  }
  for (const g of games) {
    const kind = g.status?.state === 'post' ? 'final' : g.status?.state === 'in' ? 'score' : 'info';
    await send(slackPayload(g, kind, LEAGUE));
  }
}

async function loop() {
  log(`Watching ${SPORT}/${LEAGUE}: every ${LIVE_MS / 1000}s while live${DRY_RUN ? ' (dry run)' : ''}`);
  let prev = new Map();
  let initialised = false;
  for (;;) {
    try {
      const { data: live } = await apiGet(`${base}/events/live`, KEY);
      const { started, scored, finished, next } = diffGames(prev, live, { initialised });
      for (const g of started) await send(slackPayload(g, 'start', LEAGUE));
      for (const g of scored) await send(slackPayload(g, 'score', LEAGUE));
      for (const id of finished) {
        // One extra call per finished game, to post the final score.
        const { data: g } = await apiGet(`${base}/events/${id}`, KEY).catch(() => ({ data: null }));
        if (g) await send(slackPayload(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 time, then sleep.
      const { data: events } = await apiGet(`${base}/events`, KEY);
      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.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

--once prints the payloads for the games live right now without posting, and --once --scoreboard does the same for the whole scoreboard, handy on a day with no live games.

Terminal

node bot.js --once --scoreboard   # print payloads, no posting
npm test                          # diff + formatting tests
node bot.js --dry-run             # full loop, printing
npm start                         # full loop, posting to Slack

7. Quota

A live NFL game window at 60-second polling is about 210 calls; idle days cost a few calls. One team or one league for a season fits Starter (10,000 calls a month). For instant pushes without polling, use webhooks instead.

Endpoints used

  • GET /sports/{sport}/leagues/{league}/events/live
  • GET /sports/{sport}/leagues/{league}/events
  • GET /sports/{sport}/leagues/{league}/events/{eventId}

Full reference: docs · OpenAPI · what each endpoint returns, by league

FAQ

Does it need a Slack bot token?
No, only an Incoming Webhook URL for the channel.
How quickly do scores appear?
Within one poll (60 seconds by default) of the API seeing the change; the data itself is typically 20-30 seconds behind live play.
Can I run it on a free host?
Any always-on Node 18 process works. Serverless cron also works if you run node bot.js --once --post every minute during game windows, at the same call cost.

More guides