Track men's college basketball and the March Madness bracket

One Python script with three commands: today's scoreboard for the teams you follow, a live watcher that flags close finishes and finals, and the NCAA tournament grouped First Four to Championship with winners and a CSV export.

Stack
Python, College basketball, CSV
Plan
Free for the scoreboard and bracket; Starter to watch games live

Updated 2026-10-18. Unit tested on that date; the 2026-27 season starts November 3, so live games were not yet available. 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 college-basketball/ 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/college-basketball

3. The tournament endpoint

GET /seasons/{season}/tournament returns the NCAA tournament games for a season (NIT, CBI and CIT are excluded), each annotated with tournament.region, tournament.round and tournament.firstFour. The season is the year the tournament is played: 2026 returned 67 games. Filters: round, region and firstFour. See the tournament endpoint page.

4. Group the bracket, spot close games

group_bracket orders rounds First Four, 1st Round, 2nd Round, Sweet 16, Elite 8, Final Four, Championship and puts the regionless Final Four and title game under "National". is_close flags a live game in the second half or overtime within a point margin, and idle_seconds sleeps until just before the next tip-off when nothing is live.

college-basketball/cbb_lib.py

"""Pure helpers for the men's college basketball scoreboard and bracket tracker.

No network I/O except `api_get`, so everything else is unit tested (python -m unittest).
"""
from __future__ import annotations

import json
import os
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime
from pathlib import Path

API_BASE = os.environ.get("RSA_API_BASE", "https://www.realtimesportsapi.com/api/v1")
LEAGUE_PATH = "/sports/basketball/leagues/mens-college-basketball"

# Round labels as the tournament endpoint returns them, in bracket order. Matching is by substring,
# case-insensitive; anything unknown sorts last.
ROUND_ORDER = ["first four", "1st round", "2nd round", "sweet 16", "elite 8", "final four", "championship"]


class ApiError(Exception):
    def __init__(self, status: int, code: str, message: str, retry_after: int | None = None):
        super().__init__(f"HTTP {status} {code}: {message}")
        self.status, self.code, self.retry_after = status, code, retry_after


def load_dotenv(path: Path) -> None:
    """Minimal .env loader (KEY=value lines). Variables already in the environment win."""
    if not path.exists():
        return
    for line in path.read_text().splitlines():
        line = line.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        k, v = line.split("=", 1)
        os.environ.setdefault(k.strip(), v.strip().strip("'\""))


def api_get(path: str, key: str, **query) -> dict:
    """GET a path under /api/v1 and return the envelope {success, data, meta}."""
    qs = urllib.parse.urlencode({k: v for k, v in query.items() if v is not None})
    req = urllib.request.Request(f"{API_BASE}{path}{'?' + qs if qs else ''}",
                                 headers={"Authorization": f"Bearer {key}", "Accept": "application/json"})
    try:
        with urllib.request.urlopen(req, timeout=30) as res:
            return json.load(res)
    except urllib.error.HTTPError as e:
        try:
            err = json.load(e).get("error", {})
        except Exception:  # noqa: BLE001 - non-JSON error body
            err = {}
        ra = e.headers.get("Retry-After")
        raise ApiError(e.code, err.get("code", ""), err.get("message", "request failed"), int(ra) if ra and ra.isdigit() else None) from None


def label(team: dict | None) -> str:
    team = team or {}
    return team.get("abbreviation") or team.get("name") or "?"


def involves(game: dict, teams: list[str]) -> bool:
    """True when any of `teams` (abbreviations or ids, case-insensitive) plays. Empty list = every game."""
    if not teams:
        return True
    wanted = {t.lower() for t in teams}
    for side in ("homeTeam", "awayTeam"):
        t = game.get(side) or {}
        if str(t.get("id", "")).lower() in wanted or str(t.get("abbreviation", "")).lower() in wanted:
            return True
    return False


def game_line(game: dict) -> str:
    """'DUKE 71 - 68 UNC  (2:14 - 2nd Half)'; upcoming games show the start time instead of 0-0."""
    a, h = game.get("awayTeam") or {}, game.get("homeTeam") or {}
    status = game.get("status") or {}
    detail = status.get("detail") or ""
    if status.get("state") == "pre":
        return f"{label(a)} @ {label(h)}  ({detail})"
    return f"{label(a)} {a.get('score', 0)} - {h.get('score', 0)} {label(h)}  ({detail})"


def is_close(game: dict, margin: int = 5) -> bool:
    """A live game in the second half (or overtime) decided by `margin` points or fewer."""
    status = game.get("status") or {}
    if status.get("state") != "in" or (status.get("period") or 0) < 2:
        return False
    a = (game.get("awayTeam") or {}).get("score", 0) or 0
    h = (game.get("homeTeam") or {}).get("score", 0) or 0
    return abs(a - h) <= margin


def idle_seconds(events: list[dict], now: float, default: int, cap: int = 6 * 3600) -> int:
    """Seconds to sleep when nothing is live: until ~2 min before the next start, at least 5 min,
    at most `cap`. Falls back to `default` when no upcoming start is known."""
    starts = []
    for e in events:
        if (e.get("status") or {}).get("state") != "pre" or not e.get("date"):
            continue
        try:
            t = datetime.fromisoformat(e["date"].replace("Z", "+00:00")).timestamp()
        except ValueError:
            continue
        if t > now:
            starts.append(t)
    if not starts:
        return default
    return int(min(max(min(starts) - now - 120, 300), cap))


def round_rank(round_label: str | None) -> int:
    r = (round_label or "").lower()
    for i, name in enumerate(ROUND_ORDER):
        if name in r:
            return i
    return len(ROUND_ORDER)


def winner(game: dict) -> str | None:
    """Winning team's label for a completed game, else None."""
    if not (game.get("status") or {}).get("completed"):
        return None
    a, h = game.get("awayTeam") or {}, game.get("homeTeam") or {}
    if a.get("winner") or (a.get("score", 0) > h.get("score", 0)):
        return label(a)
    if h.get("winner") or (h.get("score", 0) > a.get("score", 0)):
        return label(h)
    return None


def group_bracket(games: list[dict]) -> list[tuple[str, list[tuple[str, list[dict]]]]]:
    """Tournament games grouped as [(round, [(region, [games by date])])], rounds in bracket order.

    Final Four and championship games have no region; they are grouped under "National".
    """
    rounds: dict[str, dict[str, list[dict]]] = {}
    for g in games:
        t = g.get("tournament") or {}
        rnd = t.get("round") or "Unknown round"
        region = t.get("region") or "National"
        rounds.setdefault(rnd, {}).setdefault(region, []).append(g)
    out = []
    for rnd in sorted(rounds, key=round_rank):
        regions = [(reg, sorted(gs, key=lambda x: x.get("date") or "")) for reg, gs in sorted(rounds[rnd].items())]
        out.append((rnd, regions))
    return out


def render_bracket(games: list[dict]) -> str:
    lines = []
    for rnd, regions in group_bracket(games):
        lines.append(f"== {rnd} ==")
        for region, gs in regions:
            lines.append(f"  {region}")
            for g in gs:
                w = winner(g)
                lines.append(f"    {game_line(g)}{f'  -> {w}' if w else ''}")
    return "\n".join(lines)


def bracket_rows(games: list[dict]) -> list[dict]:
    """Flat rows for CSV export."""
    rows = []
    for rnd, regions in group_bracket(games):
        for region, gs in regions:
            for g in gs:
                a, h = g.get("awayTeam") or {}, g.get("homeTeam") or {}
                rows.append({
                    "round": rnd, "region": region, "date": g.get("date", ""), "event_id": g.get("id", ""),
                    "away": label(a), "away_score": a.get("score", 0), "home": label(h), "home_score": h.get("score", 0),
                    "status": (g.get("status") or {}).get("detail", ""), "winner": winner(g) or "",
                })
    return rows

5. The commands

scoreboard is one call; watch polls live games every 60 seconds and makes one extra call per finished game for the final; bracket is one call and can write a CSV.

college-basketball/cbb.py

#!/usr/bin/env python3
"""Men's college basketball scoreboard, close-game watcher and NCAA tournament bracket.

Python 3.9+, standard library only.

  python cbb.py scoreboard [--teams DUKE,UNC]      today's scoreboard (one call)
  python cbb.py watch [--teams ...] [--margin 5]   poll live games; flag close finishes and finals
  python cbb.py bracket [--season 2026] [--csv out.csv]   NCAA tournament games by round and region
"""
from __future__ import annotations

import argparse
import csv
import os
import sys
import time
from datetime import datetime, timezone
from pathlib import Path

from cbb_lib import LEAGUE_PATH, ApiError, api_get, bracket_rows, game_line, idle_seconds, involves, is_close, load_dotenv, render_bracket

HERE = Path(__file__).resolve().parent
load_dotenv(HERE / ".env")
load_dotenv(HERE.parent / ".env")


def key() -> str:
    k = os.environ.get("REALTIME_SPORTS_API_KEY", "")
    if not k or k == "your_api_key_here":
        sys.exit("Set REALTIME_SPORTS_API_KEY (see .env.example). Free key: https://www.realtimesportsapi.com/signup")
    return k


def teams_arg(s: str | None) -> list[str]:
    return [t.strip() for t in (s or os.environ.get("TEAMS", "")).split(",") if t.strip()]


def scoreboard(args) -> None:
    games = [g for g in api_get(f"{LEAGUE_PATH}/events", key(), limit=args.limit)["data"] if involves(g, teams_arg(args.teams))]
    if not games:
        print("No games on the current scoreboard" + (" for those teams." if args.teams else "."))
    order = {"in": 0, "pre": 1, "post": 2}
    for g in sorted(games, key=lambda g: (order.get((g.get("status") or {}).get("state"), 3), g.get("date") or "")):
        print(game_line(g))


def watch(args) -> None:
    k, teams = key(), teams_arg(args.teams)
    seen_close: set[str] = set()
    last: dict[str, dict] = {}
    poll = max(30, args.poll)
    print(f"Watching men's college basketball{' (' + ', '.join(teams) + ')' if teams else ''} every {poll}s", file=sys.stderr)
    while True:
        try:
            live = [g for g in api_get(f"{LEAGUE_PATH}/events/live", k)["data"] if involves(g, teams)]
            now_ids = {g["id"] for g in live}
            for g in live:
                if is_close(g, args.margin) and g["id"] not in seen_close:
                    seen_close.add(g["id"])
                    print(f"🔥 Close game: {game_line(g)}", flush=True)
                last[g["id"]] = g
            for gone in [i for i in last if i not in now_ids]:
                final = api_get(f"{LEAGUE_PATH}/events/{gone}", k)["data"]  # one call per finished game
                print(f"🏁 Final: {game_line(final)}", flush=True)
                del last[gone]
            if live:
                time.sleep(poll)
                continue
            # Nothing live: one scoreboard call, then sleep until shortly before the next tip-off.
            events = [g for g in api_get(f"{LEAGUE_PATH}/events", k)["data"] if involves(g, teams)]
            wait = idle_seconds(events, time.time(), default=args.idle * 60)
            print(f"Nothing live; next check in {wait // 60} min", file=sys.stderr)
            time.sleep(wait)
        except ApiError as e:
            if e.status == 401:
                sys.exit(str(e))
            wait = min(e.retry_after or 3600, 6 * 3600) if e.status == 429 else poll
            print(f"{e}; retrying in {wait}s", file=sys.stderr)
            time.sleep(wait)
        except KeyboardInterrupt:
            return


def bracket(args) -> None:
    games = api_get(f"{LEAGUE_PATH}/seasons/{args.season}/tournament", key())["data"]
    if not games:
        print(f"No NCAA tournament games for {args.season} yet (Selection Sunday is in mid-March).")
        return
    print(render_bracket(games))
    if args.csv:
        rows = bracket_rows(games)
        with open(args.csv, "w", newline="") as f:
            w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
            w.writeheader()
            w.writerows(rows)
        print(f"\nWrote {len(rows)} games to {args.csv}", file=sys.stderr)


def main() -> None:
    p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    sub = p.add_subparsers(dest="cmd", required=True)
    s = sub.add_parser("scoreboard")
    s.add_argument("--teams", help="comma-separated abbreviations or team ids (default: TEAMS env)")
    s.add_argument("--limit", type=int, default=50, help="max games from the scoreboard window (default 50)")
    s.set_defaults(fn=scoreboard)
    w = sub.add_parser("watch")
    w.add_argument("--teams")
    w.add_argument("--margin", type=int, default=5, help="points that count as close (default 5)")
    w.add_argument("--poll", type=int, default=60, help="seconds between polls while live (min 30)")
    w.add_argument("--idle", type=int, default=30, help="minutes between checks when nothing is live and no start time is known")
    w.set_defaults(fn=watch)
    b = sub.add_parser("bracket")
    b.add_argument("--season", type=int, default=datetime.now(timezone.utc).year if datetime.now(timezone.utc).month <= 6 else datetime.now(timezone.utc).year + 1,
                   help="season year = the year the tournament is played (default: the coming one)")
    b.add_argument("--csv", help="also write the games to this CSV file")
    b.set_defaults(fn=bracket)
    args = p.parse_args()
    try:
        args.fn(args)
    except ApiError as e:
        sys.exit(str(e))


if __name__ == "__main__":
    main()

6. Run it

The 2026-27 season starts November 3, 2026; bracket --season 2026 works today with last season's tournament.

Terminal

python3 cbb.py bracket --season 2026 --csv bracket-2026.csv
python3 cbb.py scoreboard --teams DUKE,UNC,KU
python3 cbb.py watch --margin 4
python3 -m unittest -v

7. Quota

Checking the scoreboard or bracket a few times a day fits the Free plan (125 calls a month after the first month's 1,000). Watching one team live is about 1,100 calls a month (Starter). Watching every game on a tournament day with 12 hours of games is about 750 calls, because one live-games call covers every game. For box scores of college games, the NBA box score tracker runs with LEAGUE=mens-college-basketball.

Endpoints used

  • GET /sports/basketball/leagues/mens-college-basketball/events
  • GET /sports/basketball/leagues/mens-college-basketball/events/live
  • GET /sports/basketball/leagues/mens-college-basketball/seasons/{season}/tournament

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

FAQ

Does it include the women's tournament?
No. The API monitors men's college basketball; the tournament endpoint returns the men's NCAA tournament.
Are team rankings included?
No. Events carry teams, scores, status and records; filter by the team abbreviations you care about.
Why are box scores marked seasonal for college basketball?
They could not be checked before the season started. The endpoint is the same one the NBA uses and is verified once games are played.

More guides