Build NHL goal alerts with a goal horn
A Node script that watches live NHL games (or one team), fires an alert the moment the score changes, adds the scorer from the latest scoring play, sends a phone push and runs your goal-horn command for your team's goals.
- Stack
- Node.js, NHL, ntfy
- Plan
- Free for a few test games; Starter for one team all season
- Code
- nhl-goal-alerts/ (MIT)
Updated 2026-10-18. Unit tested against a real NHL scoreboard captured on that date; --once run against live NHL games. 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 nhl-goal-alerts/ 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/nhl-goal-alerts3. Detect goals from the live scores
detectGoals compares each live game's score with the last poll. A score that goes up is a goal for that side; a score that goes down is a goal taken off the board after a review. Games seen for the first time only set a baseline, so starting the script mid-game does not fire old goals.
NHL box scores are not available from the API, so the scorer comes from play-by-play instead: latestScoringPlay picks the most recent play with scoringPlay: true, preferring the scoring team's.
nhl-goal-alerts/lib.js
// Pure helpers for NHL goal alerts (plus apiGet/notify). 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 label = (t) => t?.abbreviation || t?.name || '?';
/** True when `team` (abbreviation or id, case-insensitive) plays 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 last scores (eventId -> { away, home }) with the live games now and return
* goals: [{ game, side: 'home'|'away', team, count, kind: 'goal'|'overturned' }]
* next: the new score map
* A score that goes down is a goal taken off the board after review ("overturned").
* Games seen for the first time only set a baseline, so a restart never fires old goals.
*/
export function detectGoals(prev, games) {
const next = new Map();
const goals = [];
for (const g of games) {
const now = { away: g.awayTeam?.score ?? 0, home: g.homeTeam?.score ?? 0 };
next.set(g.id, now);
const old = prev.get(g.id);
if (!old) continue;
for (const side of ['away', 'home']) {
const diff = now[side] - old[side];
if (diff === 0) continue;
goals.push({ game: g, side, team: g[`${side}Team`], count: Math.abs(diff), kind: diff > 0 ? 'goal' : 'overturned' });
}
}
return { goals, next };
}
/** The most recent scoring play, preferring one credited to `teamId`. Plays arrive in chronological order. */
export function latestScoringPlay(plays, teamId) {
const scoring = (plays ?? []).filter((p) => p?.scoringPlay);
if (!scoring.length) return null;
const forTeam = teamId != null ? scoring.filter((p) => String(p.teamId ?? p.team?.id ?? '') === String(teamId)) : [];
return (forTeam.length ? forTeam : scoring).at(-1);
}
/** "🚨 CAR GOAL! CAR 2 - 1 CGY (13:12 - 2nd Period) · S. Aho (5) Wrist Shot" */
export function goalMessage({ game, team, kind }, play = null) {
const score = `${label(game.awayTeam)} ${game.awayTeam?.score ?? 0} - ${game.homeTeam?.score ?? 0} ${label(game.homeTeam)}`;
const when = game.status?.detail ? ` (${game.status.detail})` : '';
if (kind === 'overturned') return `❌ ${label(team)} goal taken off the board. ${score}${when}`;
const who = play?.text ? ` · ${play.text}` : '';
return `🚨 ${label(team)} GOAL! ${score}${when}${who}`;
}
/** Should this goal trigger the horn? Only the followed team's goals when TEAM is set. */
export function hornFor(goal, team) {
if (goal.kind !== 'goal') return false;
if (!team) return true;
const t = String(team).toLowerCase();
return String(goal.team?.id) === t || String(goal.team?.abbreviation ?? '').toLowerCase() === t;
}
/** 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;
}
/** Post a plain-text push to an ntfy topic URL (https://ntfy.sh/<topic> or your own server). */
export async function notifyNtfy(url, title, message) {
const res = await fetch(url, { method: 'POST', headers: { Title: title, Tags: 'ice_hockey,rotating_light' }, body: message });
if (!res.ok) console.error(`ntfy failed: HTTP ${res.status}`);
}
4. Alert, push and horn
For each goal the script makes one /plays?limit=1000 call (a whole NHL game fits in one page) and prints the alert. If NTFY_URL is set it posts the same text to an ntfy topic for a phone push, and GOAL_COMMAND runs for your team's goals: afplay ~/horn.mp3 on macOS, aplay on Linux, or a curl to a smart light.
nhl-goal-alerts/alerts.js
// NHL goal alerts: a terminal line, an optional phone push (ntfy) and an optional goal horn
// command for every goal, powered by the Realtime Sports API. Node 18+, no dependencies.
//
// node alerts.js watch live NHL games (or TEAM) and alert on every goal
// node alerts.js --once print the live games and their latest scoring play, then exit
// node alerts.js --test-horn run GOAL_COMMAND once, to check your horn works
import { spawn } from 'node:child_process';
import { apiGet, detectGoals, goalMessage, hornFor, involvesTeam, label, latestScoringPlay, loadDotEnv, nextStartMs, notifyNtfy } from './lib.js';
loadDotEnv(new URL('./.env', import.meta.url));
loadDotEnv(new URL('../.env', import.meta.url));
const args = new Set(process.argv.slice(2));
const env = process.env;
const LEAGUE = env.LEAGUE || 'nhl';
const TEAM = env.TEAM || '';
const LIVE_MS = Math.max(30, Number(env.POLL_SECONDS || 30)) * 1000;
const MAX_IDLE_MS = 6 * 3_600_000;
const KEY = env.REALTIME_SPORTS_API_KEY;
const base = `/sports/hockey/leagues/${LEAGUE}`;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const log = (...a) => console.error(new Date().toISOString(), ...a);
function die(msg) {
console.error(msg);
process.exit(1);
}
function horn() {
if (!env.GOAL_COMMAND) return;
// Your own command, e.g. "afplay horn.mp3" (macOS), "aplay horn.wav" (Linux) or a curl to a smart plug.
spawn(env.GOAL_COMMAND, { shell: true, stdio: 'ignore', detached: true }).unref();
}
if (args.has('--test-horn')) {
if (!env.GOAL_COMMAND) die('Set GOAL_COMMAND first (see .env.example).');
horn();
process.exit(0);
}
if (!KEY || KEY === 'your_api_key_here') die('Set REALTIME_SPORTS_API_KEY (see .env.example). Free key: https://www.realtimesportsapi.com/signup');
/** One plays call (a full NHL game fits in one page of 1,000) to name the scorer. */
async function scoringPlay(game, teamId) {
try {
const { data } = await apiGet(`${base}/events/${game.id}/plays`, KEY, { limit: 1000 });
return latestScoringPlay(data, teamId);
} catch {
return null; // the score alert still goes out without the scorer
}
}
async function alert(goal) {
const play = goal.kind === 'goal' ? await scoringPlay(goal.game, goal.team?.id) : null;
const msg = goalMessage(goal, play);
console.log(msg);
if (hornFor(goal, TEAM)) horn();
if (env.NTFY_URL) await notifyNtfy(env.NTFY_URL, `${label(goal.team)} ${goal.kind === 'goal' ? 'goal' : 'goal overturned'}`, msg);
}
async function once() {
const live = (await apiGet(`${base}/events/live`, KEY)).data.filter((g) => involvesTeam(g, TEAM));
if (!live.length) return log(`No ${LEAGUE.toUpperCase()}${TEAM ? ` ${TEAM}` : ''} games live right now.`);
for (const g of live) {
const play = await scoringPlay(g, null);
console.log(`${label(g.awayTeam)} ${g.awayTeam.score} - ${g.homeTeam.score} ${label(g.homeTeam)} (${g.status?.detail}) · last goal: ${play?.text ?? 'none yet'}`);
}
}
async function loop() {
log(`Watching ${LEAGUE.toUpperCase()}${TEAM ? ` (${TEAM})` : ''} for goals every ${LIVE_MS / 1000}s while live`);
let prev = new Map();
for (;;) {
try {
const live = (await apiGet(`${base}/events/live`, KEY)).data.filter((g) => involvesTeam(g, TEAM));
const { goals, next } = detectGoals(prev, live);
for (const goal of goals) await alert(goal);
prev = next;
if (live.length) {
await sleep(LIVE_MS);
continue;
}
const events = (await apiGet(`${base}/events`, KEY)).data.filter((g) => involvesTeam(g, TEAM));
const start = nextStartMs(events);
const wait = start ? Math.min(Math.max(start - Date.now() - 120_000, 5 * 60_000), MAX_IDLE_MS) : TEAM && !events.length ? MAX_IDLE_MS : 15 * 60_000;
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 (args.has('--once')) once().catch((e) => die(e.message));
else {
process.once('SIGINT', () => process.exit(0));
await loop();
}
5. Run it
Alerts look like 🚨 CAR GOAL! CAR 2 - 1 CGY (13:12 - 2nd Period) · Sebastian Aho (5) Wrist Shot.
Terminal
node alerts.js --once # live games and their latest goal
TEAM=CAR GOAL_COMMAND="afplay ~/horn.mp3" node alerts.js --test-horn
TEAM=CAR NTFY_URL=https://ntfy.sh/my-team-goals-x7f3 node alerts.js
npm test6. Quota and timing
Polling every 30 seconds costs about 300 calls per game plus about six scorer lookups, roughly 4,500 a month for one team (Starter). At 60 seconds it is about half that. Data is typically 20-30 seconds behind live play, so the horn sounds after the broadcast, not before it. To follow every game in the league, webhooks with event.score_change are cheaper than polling.
Endpoints used
GET /sports/hockey/leagues/nhl/events/liveGET /sports/hockey/leagues/nhl/eventsGET /sports/hockey/leagues/nhl/events/{eventId}/plays
Full reference: docs · OpenAPI · what each endpoint returns, by league
FAQ
- Does it ship a horn sound?
- No. Point GOAL_COMMAND at any audio file you have the rights to, or at a smart-light or speaker API.
- What happens when a goal is overturned?
- The score goes down, and the script sends a "goal taken off the board" alert without sounding the horn.
- Can I get the assists too?
- The scoring play text usually names the scorer and assists; the play's athletes array has the participants with their roles.