Send sports score alerts with n8n

Import a workflow, add two credentials and activate it: game starts, score changes and finals for your team arrive in Telegram. The polling version calls the API only when a game is live or about to start; the webhook version is pushed by the API.

Stack
n8n, Telegram, Webhooks
Plan
Polling: Starter for live alerts all season. Webhooks: paid plans
Code
n8n/ (MIT)

Updated 2026-10-18. Code-node logic unit tested against a real NHL scoreboard captured 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 n8n/ 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/n8n

3. The polling workflow

Schedule (every minute) โ†’ **Worth calling?** โ†’ **Get scoreboard** โ†’ **Score changes** โ†’ **Send to Telegram**. The gate Code node reads nextCheck from the workflow's static data and stops the run unless it is time to call the API: every 2 minutes while your team's game is live, about 5 minutes before the next start, otherwise every 3 hours. One scoreboard call returns live scores and start times, and scoreStep turns two consecutive scoreboards into ๐ŸŸข Started, ๐Ÿšจ Score and ๐Ÿ Final messages. The first run only records a baseline.

n8n/code/score-step.js

// Score-change logic for the n8n polling workflow. Plain JavaScript with no imports: build.mjs
// pastes this file into the workflow's Code nodes, and test/ runs it with node --test.

/** True when `team` (abbreviation or id, case-insensitive) plays in `game`. Empty team = every game. */
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));
}

function line(g) {
  const a = g.awayTeam || {};
  const h = g.homeTeam || {};
  return `${a.abbreviation || a.name} ${a.score ?? 0} - ${h.score ?? 0} ${h.abbreviation || h.name}`;
}

/**
 * One polling step. `state` is { scores: {eventId: 'away-home|state'}, initialised, nextCheck }.
 * `games` is the `data` array of GET /events (the scoreboard, which carries live scores too).
 * Returns { messages, state } where state.nextCheck (ms) says when the next API call is worth making:
 * 2 minutes while a game is live, ~5 minutes before the next start, else in 3 hours (max 6 hours).
 */
function scoreStep(state, games, team, now, { liveMs = 120000 } = {}) {
  const prev = (state && state.scores) || {};
  const initialised = Boolean(state && state.initialised);
  const scores = {};
  const messages = [];
  let live = false;
  let nextStart = null;
  for (const g of games || []) {
    if (!involvesTeam(g, team)) continue;
    const st = (g.status && g.status.state) || 'pre';
    const key = `${g.awayTeam?.score ?? 0}-${g.homeTeam?.score ?? 0}|${st}`;
    scores[g.id] = key;
    if (st === 'in') live = true;
    if (st === 'pre' && g.date) {
      const t = Date.parse(g.date);
      if (t > now && (nextStart === null || t < nextStart)) nextStart = t;
    }
    const old = prev[g.id];
    if (!initialised || old === key) continue;
    const detail = g.status?.detail ? ` (${g.status.detail})` : '';
    const oldState = old ? old.split('|')[1] : 'pre';
    if (st === 'in' && oldState === 'pre') messages.push(`๐ŸŸข Started: ${line(g)}${detail}`);
    else if (st === 'post' && oldState !== 'post') messages.push(`๐Ÿ Final: ${line(g)}`);
    else if (st === 'in') messages.push(`๐Ÿšจ Score: ${line(g)}${detail}`);
  }
  let wait;
  if (live) wait = liveMs;
  else if (nextStart !== null) wait = Math.max(nextStart - now - 300000, liveMs);
  else wait = 3 * 3600000;
  return { messages, state: { scores, initialised: true, nextCheck: now + Math.min(wait, 6 * 3600000) } };
}

4. The webhook workflow

On paid plans the API pushes event.live, event.score_change and event.final to an n8n Webhook node, and formatAlert turns each delivery into one line. Register the n8n production URL with POST /api/v1/webhooks (see webhook game alerts) and use a random webhook path. The repo's webhook-alerts example shows how to verify X-Webhook-Signature.

n8n/code/format-alert.js

// Turns a Realtime Sports API webhook delivery { event, timestamp, data } into one line of text,
// or null to skip it. Plain JavaScript; build.mjs pastes it into the webhook workflow's Code node.
function formatAlert(payload) {
  const type = payload && payload.event;
  const d = (payload && payload.data) || {};
  if (!type) return null;
  const label = (t) => (t && (t.abbreviation || t.name)) || '?';
  const score = `${label(d.awayTeam)} ${d.awayTeam?.score ?? 0} - ${d.homeTeam?.score ?? 0} ${label(d.homeTeam)}`;
  const tag = d.league ? `[${String(d.league).toUpperCase()}] ` : '';
  switch (type) {
    case 'event.final':
      return `${tag}๐Ÿ FINAL: ${score}`;
    case 'event.score_change':
      return `${tag}๐Ÿšจ ${score}${d.status?.detail ? ` (${d.status.detail})` : ''}`;
    case 'event.live':
      return `${tag}๐ŸŸข LIVE: ${label(d.awayTeam)} @ ${label(d.homeTeam)}`;
    default:
      return null; // status changes and plays are frequent; add cases here if you want them
  }
}

5. Import and activate

Import a file from workflows/, create a Header Auth credential (name Authorization, value Bearer YOUR_API_KEY) on **Get scoreboard**, set TEAM in **Score changes**, add your Telegram credential and chat id, then activate. Static data is only saved for active workflows, not manual test runs. build.mjs generates the workflow JSON from the tested files in code/.

Terminal

npm test        # rebuild workflows/ and run the Code-node tests
# then in n8n: Workflows > Import from File > workflows/poll-score-alerts.json

6. Quota

Gate runs that stop early make no API call. Idle checks are about 240 calls a month and one NBA or NHL game about 75, so one team is roughly 1,300 calls a month (Starter). Webhook deliveries count against the same monthly pool; filter by league when you register. On n8n Cloud, each gate run counts as an execution, so you may prefer a cron trigger limited to your game hours.

Endpoints used

  • GET /sports/{sport}/leagues/{league}/events
  • POST /webhooks

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

FAQ

Can I send to Slack or Discord instead of Telegram?
Yes. Replace the last node with the Slack, Discord, email or SMS node and map {{ $json.text }} into the message.
Does it work on n8n Cloud?
Yes, but every gate run is an execution. A cron schedule limited to game hours keeps the execution count down.
Why does nothing happen in a manual test run?
The first run records a baseline, and static data is only saved for active workflows. Activate it and wait for the next score change.

More guides