Get game alerts with webhooks
A tiny Node server that receives webhook deliveries, verifies their signature against the raw body, prints alerts and can forward them to Slack or Discord, plus a script that registers, tests and deletes webhooks.
- Stack
- Node.js, Webhooks, HMAC-SHA256
- Plan
- Webhooks need a paid plan (Starter and up); local testing is free
- Code
- webhook-alerts/ (MIT)
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 webhook-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/webhook-alerts3. Verify the signature
Each delivery is a POST with a JSON body { event, timestamp, data } and an X-Webhook-Signature header: the lowercase hex HMAC-SHA256 of the raw body keyed with your webhook secret, with no sha256= prefix. Verify against the exact bytes received, before JSON.parse, with a timing-safe compare.
webhook-alerts/lib.js
// Signature verification and alert formatting for Realtime Sports API webhook deliveries.
// No network I/O here, so it is easy to unit test.
import { createHmac, timingSafeEqual } from 'node:crypto';
import { existsSync, readFileSync } from 'node:fs';
/** 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');
}
}
/** Lowercase hex HMAC-SHA256 of the raw body, exactly what the API sends in X-Webhook-Signature. */
export function sign(rawBody, secret) {
return createHmac('sha256', secret).update(rawBody).digest('hex');
}
/**
* True when `signatureHeader` is the HMAC of the raw body bytes. Compare in constant time, and
* always against the raw bytes you received (re-serialising parsed JSON changes the bytes).
*/
export function verifySignature(rawBody, signatureHeader, secret) {
if (!secret || typeof signatureHeader !== 'string') return false;
const received = signatureHeader.trim().toLowerCase();
if (!/^[0-9a-f]{64}$/.test(received)) return false;
const expected = Buffer.from(sign(rawBody, secret), 'hex');
return timingSafeEqual(expected, Buffer.from(received, 'hex'));
}
const label = (t) => t?.abbreviation || t?.name || '?';
const score = (d) => `${label(d.awayTeam)} ${d.awayTeam?.score ?? 0} - ${d.homeTeam?.score ?? 0} ${label(d.homeTeam)}`;
/**
* Turn a delivery `{ event, timestamp, data }` into a one-line alert, or null to ignore it.
* `only` optionally restricts which event types produce alerts.
*/
export function formatAlert(payload, only = null) {
const type = payload?.event;
const d = payload?.data ?? {};
if (!type || (only && !only.includes(type))) return null;
const tag = d.league ? `[${String(d.league).toUpperCase()}] ` : '';
switch (type) {
case 'event.final':
return `${tag}FINAL: ${score(d)}`;
case 'event.score_change': {
const prev = d.previousScore ? ` (was ${d.previousScore.away}-${d.previousScore.home})` : '';
return `${tag}SCORE: ${score(d)}${prev}`;
}
case 'event.live':
return `${tag}LIVE: ${d.awayTeam ? `${label(d.awayTeam)} @ ${label(d.homeTeam)}` : d.name ?? d.eventId}`;
case 'event.status_change':
return `${tag}${d.name ?? d.eventId}: ${d.status?.detail ?? d.status?.state ?? 'status changed'}`;
case 'event.play':
return `${tag}PLAY: ${d.play?.text ?? d.play?.shortText ?? '(no text)'}`;
default:
return `${tag}${type}: ${d.name ?? d.eventId ?? ''}`.trim();
}
}
4. The receiver
The server answers 200 before doing slow work so deliveries are not retried, rejects bad signatures with 401, and prints or forwards alerts for event.live, event.score_change, event.status_change, event.play and event.final.
webhook-alerts/server.js
// Receives Realtime Sports API webhook deliveries, verifies X-Webhook-Signature, and prints (and
// optionally forwards) an alert for each one. Node 18+, no dependencies.
//
// node server.js listens on PORT (default 3000) at POST /webhook
import http from 'node:http';
import { pathToFileURL } from 'node:url';
import { formatAlert, loadDotEnv, verifySignature } from './lib.js';
const MAX_BODY = 1024 * 1024;
/**
* Create the HTTP server. Options:
* secret the webhook signing secret (required)
* path URL path to accept deliveries on (default /webhook)
* only array of event types to alert on (default: all)
* onAlert called with (text, payload) for every verified delivery that produces an alert
*/
export function createAlertServer({ secret, path = '/webhook', only = null, onAlert = console.log }) {
if (!secret) throw new Error('WEBHOOK_SECRET is required');
return http.createServer((req, res) => {
if (req.method === 'GET' && req.url === '/health') return reply(res, 200, { ok: true });
if (req.method !== 'POST' || req.url.split('?')[0] !== path) return reply(res, 404, { error: 'not found' });
const chunks = [];
let size = 0;
req.on('data', (c) => {
size += c.length;
if (size > MAX_BODY) {
reply(res, 413, { error: 'payload too large' });
req.destroy();
} else chunks.push(c);
});
req.on('end', () => {
if (res.writableEnded) return;
const raw = Buffer.concat(chunks); // verify the exact bytes received, before JSON.parse
if (!verifySignature(raw, req.headers['x-webhook-signature'], secret)) {
return reply(res, 401, { error: 'invalid signature' });
}
let payload;
try {
payload = JSON.parse(raw.toString('utf8'));
} catch {
return reply(res, 400, { error: 'invalid JSON' });
}
// Answer fast; do slow work (forwarding) after responding so the delivery is not retried.
reply(res, 200, { received: true });
const text = formatAlert(payload, only);
if (text) Promise.resolve(onAlert(text, payload, req.headers)).catch((e) => console.error('alert handler failed:', e.message));
});
});
}
function reply(res, status, body) {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(body));
}
/** Post the alert to a Slack or Discord incoming webhook (both accept this body shape). */
async function forward(url, text) {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, content: text })
});
if (!res.ok) console.error(`Forward failed: HTTP ${res.status}`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
loadDotEnv(new URL('./.env', import.meta.url));
const env = process.env;
if (!env.WEBHOOK_SECRET) {
console.error('Set WEBHOOK_SECRET (the signing secret returned when you created the webhook). See .env.example.');
process.exit(1);
}
const only = env.ALERT_EVENTS ? env.ALERT_EVENTS.split(',').map((s) => s.trim()).filter(Boolean) : null;
const port = Number(env.PORT || 3000);
createAlertServer({
secret: env.WEBHOOK_SECRET,
path: env.WEBHOOK_PATH || '/webhook',
only,
onAlert: async (text, payload, headers) => {
const test = headers['x-webhook-test'] === 'true' ? ' (test delivery)' : '';
console.log(`${new Date().toISOString()} ${text}${test}`);
if (env.FORWARD_URL) await forward(env.FORWARD_URL, text);
}
}).listen(port, () => console.log(`Listening on http://localhost:${port}${env.WEBHOOK_PATH || '/webhook'}`));
}
5. Test locally without a paid plan
send-sample.js signs sample payloads with a local secret and posts them to your server; the tests do the same, including tampered and wrong-secret payloads.
Terminal
WEBHOOK_SECRET=local-dev-secret node server.js # terminal 1
WEBHOOK_SECRET=local-dev-secret node send-sample.js event.final # terminal 2
npm test6. Register the webhook
Deploy the server behind HTTPS, then register it with the events and leagues you want. The signing secret is shown once; put it in .env as WEBHOOK_SECRET. Webhooks filter by league; to follow one team, check data.homeTeam.id or data.awayTeam.id in your handler (team ids are on the team API pages).
Terminal
node register.js create https://your-host.example.com/webhook --leagues nfl,nba --events event.score_change,event.final
node register.js test <webhookId> event.finalwebhook-alerts/register.js
// Manage your Realtime Sports API webhooks (paid plans only). Node 18+, no dependencies.
//
// node register.js create https://your-host.example.com/webhook [--leagues nfl,nba] [--events event.final,event.score_change]
// node register.js list
// node register.js test <webhookId> [event.final] sends a signed sample delivery to your URL
// node register.js delete <webhookId>
import { loadDotEnv } from './lib.js';
loadDotEnv(new URL('./.env', import.meta.url));
loadDotEnv(new URL('../.env', import.meta.url));
const API = process.env.RSA_API_BASE || 'https://www.realtimesportsapi.com/api/v1';
const KEY = process.env.REALTIME_SPORTS_API_KEY;
const DEFAULT_EVENTS = ['event.live', 'event.score_change', 'event.final'];
function die(msg) {
console.error(msg);
process.exit(1);
}
function flag(args, name) {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
}
async function call(method, path, body) {
const res = await fetch(API + path, {
method,
headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined
});
const json = await res.json().catch(() => null);
if (!res.ok) {
const e = json?.error ?? {};
if (res.status === 403) die(`HTTP 403 ${e.code ?? ''}: ${e.message ?? ''}\nWebhooks need a paid plan: https://www.realtimesportsapi.com/pricing`);
die(`HTTP ${res.status} ${e.code ?? ''}: ${e.message ?? 'request failed'}`);
}
return json;
}
const [cmd, ...args] = process.argv.slice(2);
if (!KEY || KEY === 'your_api_key_here') die('Set REALTIME_SPORTS_API_KEY (see .env.example).');
switch (cmd) {
case 'create': {
const url = args[0];
if (!url || !/^https:\/\//.test(url)) die('Usage: node register.js create https://your-host/webhook [--leagues nfl,nba] [--events ...]');
const events = (flag(args, '--events') ?? DEFAULT_EVENTS.join(',')).split(',').map((s) => s.trim());
const leagues = flag(args, '--leagues')?.split(',').map((s) => s.trim());
const { data } = await call('POST', '/webhooks', { url, events, ...(leagues ? { leagues } : {}) });
console.log(`Created webhook ${data.id} -> ${data.url}`);
console.log(`Events: ${data.events.join(', ')} Leagues: ${data.leagues?.join(', ') ?? 'all'}`);
console.log(`\nSigning secret (shown only now; put it in .env as WEBHOOK_SECRET):\n${data.secret}`);
break;
}
case 'list': {
const { data } = await call('GET', '/webhooks');
if (!data.length) console.log('No webhooks.');
for (const w of data) {
console.log(`${w.id} ${w.active ? 'active ' : 'inactive'} ${w.url} [${w.events.join(', ')}] leagues: ${w.leagues?.join(', ') ?? 'all'}`);
}
break;
}
case 'test': {
if (!args[0]) die('Usage: node register.js test <webhookId> [eventType]');
const { data } = await call('POST', `/webhooks/${args[0]}/test`, args[1] ? { eventType: args[1] } : {});
if (data.delivered) console.log(`Delivered (HTTP ${data.statusCode}, ${data.durationMs} ms)`);
else console.log(`Not delivered: HTTP ${data.statusCode ?? '-'} ${String(data.error ?? '').replace(/\s+/g, ' ').slice(0, 160)}`);
break;
}
case 'delete': {
if (!args[0]) die('Usage: node register.js delete <webhookId>');
await call('DELETE', `/webhooks/${args[0]}`);
console.log(`Deleted ${args[0]}`);
break;
}
default:
die('Usage: node register.js create <https-url> [--leagues nfl] [--events event.final] | list | test <id> | delete <id>');
}
Endpoints used
POST /webhooksGET /webhooksPOST /webhooks/{webhookId}/testDELETE /webhooks/{webhookId}
Full reference: docs · OpenAPI · what each endpoint returns, by league
FAQ
- Do webhook deliveries count toward my quota?
- On Starter and Growth they share the monthly pool with REST calls; Pro and Scale have separate delivery allowances. Filter by league and event type to keep the count down.
- What happens if my endpoint is down?
- Failed deliveries are retried, and a webhook is deactivated after 5 consecutive failures until you re-enable it. Make your handler safe to run twice for the same delivery.