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/n8n3. 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.json6. 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}/eventsPOST /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.