Build a sports stats newsletter

A Node script that writes a recap newsletter (final scores, passing/rushing/receiving leaders and next week's games) as Markdown and inline-styled HTML for any email tool. It never sends email itself.

Stack
Node.js, Markdown, Email HTML
Plan
Free for a weekly NFL issue (about 18 calls each)
Code
newsletter/ (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 newsletter/ 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/newsletter

3. Leaders and rendering

statLeaders in lib.js reads player lines from each box score and picks the top three per category: passing, rushing and receiving yards for football; points, rebounds and assists for basketball; RBIs and hits for baseball. Soccer box scores have no player rows and NHL box scores are not available, so those recaps list scores only. The HTML uses inline styles and a single table so it survives email clients.

newsletter/lib.js

// Pure helpers for the recap newsletter: stat leaders and Markdown/HTML rendering.
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. Returns the envelope, or throws an Error with .status and .code. */
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.code = body?.error?.code;
    throw err;
  }
  return body;
}

const n = (v) => {
  const x = parseFloat(v);
  return Number.isFinite(x) ? x : 0;
};
const label = (t) => t?.abbreviation || t?.name || '?';

/**
 * Stat categories per sport. Box score player stats are strings; football nests them under
 * `categories`, other sports use flat keys.
 */
const CATEGORIES = {
  football: [
    { title: 'Passing', get: (p) => p.categories?.passing?.passingYards, fmt: (p) => `${p.categories.passing.passingYards} yds, ${p.categories.passing.passingTouchdowns ?? 0} TD` },
    { title: 'Rushing', get: (p) => p.categories?.rushing?.rushingYards, fmt: (p) => `${p.categories.rushing.rushingYards} yds, ${p.categories.rushing.rushingTouchdowns ?? 0} TD` },
    { title: 'Receiving', get: (p) => p.categories?.receiving?.receivingYards, fmt: (p) => `${p.categories.receiving.receptions ?? '?'} rec, ${p.categories.receiving.receivingYards} yds` }
  ],
  basketball: [
    { title: 'Points', get: (p) => p.points, fmt: (p) => `${p.points} pts, ${p.rebounds ?? 0} reb, ${p.assists ?? 0} ast` },
    { title: 'Rebounds', get: (p) => p.rebounds, fmt: (p) => `${p.rebounds} reb` },
    { title: 'Assists', get: (p) => p.assists, fmt: (p) => `${p.assists} ast` }
  ],
  baseball: [
    { title: 'RBIs', get: (p) => p.RBIs, fmt: (p) => `${p['hits-atBats'] ?? ''}, ${p.homeRuns ?? 0} HR, ${p.RBIs} RBI` },
    { title: 'Hits', get: (p) => p.hits, fmt: (p) => `${p['hits-atBats'] ?? p.hits}` }
  ],
  hockey: [
    { title: 'Goals', get: (p) => p.goals, fmt: (p) => `${p.goals} G, ${p.assists ?? 0} A` },
    { title: 'Assists', get: (p) => p.assists, fmt: (p) => `${p.assists} A` }
  ],
  soccer: [{ title: 'Goals', get: (p) => p.goals ?? p.totalGoals, fmt: (p) => `${p.goals ?? p.totalGoals} G` }]
};

/**
 * Leaders across a set of box scores: { [category]: [{ name, team, line, value }] } (top `top`).
 * `boxes` is [{ game, box }] where box is the /boxscore `data`.
 */
export function statLeaders(sport, boxes, top = 3) {
  const cats = CATEGORIES[sport] ?? [];
  const out = {};
  for (const cat of cats) {
    const rows = [];
    for (const { box } of boxes) {
      for (const [side, players] of [['homeTeam', box?.homePlayers], ['awayTeam', box?.awayPlayers]]) {
        for (const p of players ?? []) {
          const raw = cat.get(p);
          if (raw == null || raw === '') continue;
          rows.push({ name: p.name, team: label(box[side]), value: n(raw), line: cat.fmt(p) });
        }
      }
    }
    rows.sort((a, b) => b.value - a.value);
    const best = rows.filter((r) => r.value > 0).slice(0, top);
    if (best.length) out[cat.title] = best;
  }
  return out;
}

/** "ARI 24 @ **NYG 36**" with the winner in bold. `escape` lets the HTML renderer escape team names. */
export function finalLine(g, { bold = (s) => `**${s}**`, escape = (s) => s } = {}) {
  const a = g.awayTeam ?? {};
  const h = g.homeTeam ?? {};
  const as = escape(`${label(a)} ${a.score ?? 0}`);
  const hs = escape(`${label(h)} ${h.score ?? 0}`);
  const aw = (a.score ?? 0) > (h.score ?? 0);
  const hw = (h.score ?? 0) > (a.score ?? 0);
  return `${aw ? bold(as) : as} @ ${hw ? bold(hs) : hs}`;
}

/** Pre-game line. status.detail is already a readable local start time, e.g. "Thu, October 8th at 8:15 PM EDT". */
export function upcomingLine(g) {
  const venue = g.venue?.name ? ` · ${g.venue.name}${g.venue.city ? `, ${g.venue.city}` : ''}` : '';
  return `${label(g.awayTeam)} @ ${label(g.homeTeam)} · ${g.status?.detail ?? g.date}${venue}`;
}

export function renderMarkdown({ title, subtitle, finals, leaders, leadersNote, upcoming, footer }) {
  const lines = [`# ${title}`, '', `_${subtitle}_`, '', '## Final scores', ''];
  if (finals.length) for (const g of finals) lines.push(`- ${finalLine(g)}`);
  else lines.push('_No completed games in this window._');
  if (Object.keys(leaders).length) {
    lines.push('', '## Top performers', '');
    if (leadersNote) lines.push(`_${leadersNote}_`, '');
    for (const [cat, rows] of Object.entries(leaders)) {
      lines.push(`**${cat}**`, '');
      for (const r of rows) lines.push(`- ${r.name} (${r.team}): ${r.line}`);
      lines.push('');
    }
  } else lines.push('');
  lines.push('## Coming up', '');
  if (upcoming.length) for (const g of upcoming) lines.push(`- ${upcomingLine(g)}`);
  else lines.push('_Nothing scheduled in the current window._');
  lines.push('', '---', '', footer, '');
  return lines.join('\n');
}

const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');

/** Email-friendly HTML: a single table layout with inline styles, no external CSS or images. */
export function renderHtml({ title, subtitle, finals, leaders, leadersNote, upcoming, footer }) {
  const h2 = (t) => `<h2 style="font-size:18px;margin:24px 0 8px;border-bottom:1px solid #ddd;padding-bottom:4px">${esc(t)}</h2>`;
  const li = (html) => `<li style="margin:4px 0">${html}</li>`;
  const list = (items) => `<ul style="padding-left:20px;margin:0">${items.join('')}</ul>`;
  const em = (t) => `<p style="color:#666;margin:4px 0"><em>${esc(t)}</em></p>`;
  const parts = [
    `<h1 style="font-size:24px;margin:0 0 4px">${esc(title)}</h1>`,
    em(subtitle),
    h2('Final scores'),
    finals.length ? list(finals.map((g) => li(finalLine(g, { bold: (s) => `<strong>${s}</strong>`, escape: esc })))) : em('No completed games in this window.')
  ];
  if (Object.keys(leaders).length) {
    parts.push(h2('Top performers'));
    if (leadersNote) parts.push(em(leadersNote));
    for (const [cat, rows] of Object.entries(leaders)) {
      parts.push(`<p style="margin:12px 0 4px"><strong>${esc(cat)}</strong></p>`);
      parts.push(list(rows.map((r) => li(`${esc(r.name)} (${esc(r.team)}): ${esc(r.line)}`))));
    }
  }
  parts.push(h2('Coming up'));
  parts.push(upcoming.length ? list(upcoming.map((g) => li(esc(upcomingLine(g))))) : em('Nothing scheduled in the current window.'));
  parts.push(`<hr style="border:0;border-top:1px solid #ddd;margin:24px 0 8px"><p style="color:#888;font-size:12px">${footer}</p>`);
  return `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)}</title></head>
<body style="margin:0;background:#f6f6f6">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td align="center" style="padding:16px">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:640px;background:#fff;border-radius:6px">
<tr><td style="padding:24px;font-family:-apple-system,Segoe UI,Helvetica,Arial,sans-serif;font-size:15px;line-height:1.5;color:#222">
${parts.join('\n')}
</td></tr></table></td></tr></table>
</body></html>
`;
}

4. Build the issue

Weekly mode (NFL, college football) reads the current week from the scoreboard, recaps the previous week with the week schedule and lists this week's games. Daily mode uses the finals and upcoming games on the current scoreboard, which only covers the current day, so run it late in the evening.

newsletter/recap.js

// Build a sports recap newsletter (final scores, top performers, upcoming games) as Markdown and
// HTML, ready to paste into any email tool. Node 18+, no dependencies.
//
//   node recap.js                                   NFL: last completed week + this week's games
//   node recap.js --league college-football
//   node recap.js --sport basketball --league nba   daily: finals and upcoming on the current scoreboard
//   node recap.js --boxscores 4 --out-dir out
//
// Weekly mode (nfl, college-football): 1 scoreboard call + 1 schedule call + 1 call per box score.
// Daily mode (other leagues):          1 scoreboard call + 1 call per box score.
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { apiGet, loadDotEnv, renderHtml, renderMarkdown, statLeaders } from './lib.js';

loadDotEnv(new URL('./.env', import.meta.url));
loadDotEnv(new URL('../.env', import.meta.url));

const argv = process.argv.slice(2);
const opt = (name, fallback) => {
  const i = argv.indexOf(name);
  return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback;
};
const sport = opt('--sport', 'football');
const league = opt('--league', 'nfl');
const maxBoxscores = Math.max(0, Number(opt('--boxscores', 16)));
const outDir = opt('--out-dir', 'out');
const KEY = process.env.REALTIME_SPORTS_API_KEY;
const WEEKLY = ['nfl', 'college-football'].includes(league);

if (!KEY || KEY === 'your_api_key_here') {
  console.error('Set REALTIME_SPORTS_API_KEY (see .env.example). Get a free key at https://www.realtimesportsapi.com/signup');
  process.exit(1);
}

const base = `/sports/${sport}/leagues/${league}`;
let calls = 0;
const get = (path, query) => {
  calls++;
  return apiGet(path, KEY, query);
};
const byDate = (a, b) => Date.parse(a.date) - Date.parse(b.date);

try {
  const { data: board } = await get(`${base}/events`);
  const upcoming = board.filter((e) => e.status?.state === 'pre').sort(byDate);
  let finals;
  let heading;

  if (WEEKLY) {
    // The scoreboard is on the current week; recap the one before it.
    const ref = board[0]?.season;
    const week = ref?.week?.number;
    if (!week) throw new Error('Could not tell the current week from the scoreboard.');
    const lastWeek = week - 1;
    if (lastWeek < 1) throw new Error('No completed regular-season week yet.');
    const { data: games } = await get(`${base}/seasons/${ref.year}/schedule`, { week: lastWeek, seasonType: ref.type?.id });
    finals = games.filter((e) => e.status?.state === 'post').sort(byDate);
    heading = `${league.toUpperCase()} Week ${lastWeek} recap`;
  } else {
    finals = board.filter((e) => e.status?.state === 'post').sort(byDate);
    heading = `${league.toUpperCase()} daily recap`;
  }

  // Box scores for the top performers. Some leagues/events have none (BOXSCORE_NOT_AVAILABLE).
  const boxes = [];
  for (const game of finals.slice(0, maxBoxscores)) {
    try {
      const { data: box } = await get(`${base}/events/${game.id}/boxscore`);
      if (box) boxes.push({ game, box });
    } catch (err) {
      console.error(`  no box score for ${game.shortName}: ${err.code ?? err.message}`);
    }
  }
  const leaders = statLeaders(sport, boxes);
  const leadersNote = boxes.length && boxes.length < finals.length ? `From box scores of ${boxes.length} of ${finals.length} games.` : '';

  const today = new Date().toISOString().slice(0, 10);
  const doc = {
    title: heading,
    subtitle: `${finals.length} final${finals.length === 1 ? '' : 's'}, ${upcoming.length} upcoming · generated ${today}`,
    finals,
    leaders,
    leadersNote,
    upcoming: upcoming.slice(0, 20),
    footer:
      'Scores and stats from the <a href="https://www.realtimesportsapi.com/?utm_source=newsletter">Realtime Sports API</a>.'
  };
  const md = renderMarkdown({ ...doc, footer: 'Scores and stats from the [Realtime Sports API](https://www.realtimesportsapi.com/?utm_source=newsletter).' });
  const html = renderHtml(doc);

  mkdirSync(outDir, { recursive: true });
  const stem = join(outDir, `recap-${league}-${today}`);
  writeFileSync(`${stem}.md`, md);
  writeFileSync(`${stem}.html`, html);
  console.log(md);
  console.error(`Wrote ${stem}.md and ${stem}.html (${calls} API calls)`);
} catch (err) {
  console.error(err.message);
  process.exit(1);
}

5. Run and schedule it

The 7 October 2026 run recapped NFL week 4 (16 finals, Joe Burrow's 428 passing yards on top) in 18 calls. Paste the HTML into Buttondown, Mailchimp, Beehiiv or your own SMTP script.

Terminal

node recap.js                                   # NFL weekly -> out/recap-nfl-<date>.md + .html
node recap.js --league college-football --boxscores 10
node recap.js --sport basketball --league nba   # daily
# cron: every Tuesday 9:00
0 9 * * 2 cd /path/to/newsletter && node recap.js

Endpoints used

  • GET /sports/{sport}/leagues/{league}/events
  • GET /sports/{sport}/leagues/{league}/seasons/{season}/schedule?week=N
  • GET /sports/{sport}/leagues/{league}/events/{eventId}/boxscore

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

FAQ

Does it send the email?
No. Every run writes Markdown and HTML files; you send them with your email tool.
How many calls does an issue cost?
Weekly mode: 2 calls plus one per box score (a full NFL week is 18). Daily mode: 1 plus one per box score.

More guides