Tutorial · 12 min · endpoints verified 2026-08-15

How to build a fantasy hockey app

A draft board that ranks every NHL skater on 5v5 production, expected goals and a percentile tier — built on live endpoints, in about fifteen minutes. Two paths: hand it to your AI agent, or write it yourself.

PoolDraft PoolDraft is a live fantasy hockey pool app running on these exact endpoints. It is the reason this page exists: everything below is the path that app actually took, not a hypothetical one.

Why this is hard

The problem with fantasy hockey data

Fantasy hockey rewards one thing above all: identifying which skaters generate offence at a rate their box score has not caught up to yet. That is a data problem, and it is the part every hobby project gets stuck on.

  • Raw goal totals are power-play inflated. A winger who feasts on the man advantage looks elite until your league scores 5v5-weighted.
  • Scraping NHL.com gives you counting stats and no shot-quality context, so you cannot separate a hot streak from a repeatable one.
  • Player identity is the hidden tax — reconciling names across a stats source, an injury feed and a schedule feed is most of the work in a scraped build.
  • Nobody wants to maintain a scraper through a season. The break always lands the night before your draft.

Path 1 · recommended

Have your AI agent build it

Our MCP server is live. Point any MCP client — Claude Code, Claude Desktop, Cursor, or your own agent — at it, and the model can read our data directly while it writes your app. You describe the draft board; it queries, shapes and renders it.

1. Connect the MCP server

Add the server in your MCP client and sign in with your Big Balls account. Your client registers itself and handles the token exchange — there is no key to copy.

MCP client configjson
{
  "mcpServers": {
    "bigballs-sports-data": {
      "url": "https://mcp.bigballsdata.com/mcp"
    }
  }
}

2. Tools your agent gets

  • get_matches

    Live, upcoming or historical matches for a sport or league.

  • get_standings

    League table with wins, losses, win_pct, games_played.

  • get_player_stats

    Per-player career or per-season totals and per-game rates.

  • get_team_elo

    Team Elo rating, rank and optional rating history.

  • get_coverage

    Machine-readable map of what we hold and what we do not.

3. Ask for what you want

Paste this at your agent. It is written to make the model check coverage before it designs anything, which is what stops it inventing a field we do not serve.

Prompttext
Build me a fantasy hockey draft board.

Use the Big Balls Sports Data MCP server. Start by calling get_coverage for
ice_hockey so you know exactly what is available before you design anything.

Then:
1. Pull the current NHL standings with get_standings (sport: ice_hockey).
2. For a list of player ids I give you, call get_player_stats
   (sport: ice_hockey) and build a table of points per game, shots per game
   and time on ice.
3. Call get_team_elo for each team so a player's schedule strength is visible
   next to their production.
4. Render it as a sortable table, best available at the top.

Tell me which numbers came from which tool. If something is not in the
coverage map, say so rather than estimating it.

What the agent cannot reach

The MCP server serves five read tools, which is less than the REST API. Two things this tutorial uses — the 5v5 expected-goals leaderboard and the player tier score — have no MCP tool yet, so an agent-built board will need the REST calls in the manual path for those. Ask your agent to fetch them over plain HTTP with the same key; it is the same gateway.

Path 2 · hand-coded

Build it yourself

Every call below was run against production on the date in the footer, and the responses are trimmed copies of what came back. You need a free API key from the dashboard and nothing else — no SDK, no build step.

  1. 01

    Get a key and confirm it works

    Create a free key in the dashboard, then confirm the gateway sees it. A 200 here means every later step is an authentication-free problem.

    Verify your keybash
    curl -s "https://api.bigballsdata.com/v1/coverage?sport=ice_hockey" \
      -H "x-api-key: $BBS_API_KEY"

    Real response, trimmed

    jsonjson
    { "data": { "sport": "ice_hockey", "coverage": "live", ... } }
  2. 02

    Pull the 5v5 scoring leaderboard

    This is the spine of the draft board. GET /v1/nhl/leaders ranks skaters on 5-on-5 production only, so power-play specialists do not distort the board. Pass stat=points, or goals, assists, shots or xg.

    GET /v1/nhl/leadersbash
    curl -s "https://api.bigballsdata.com/v1/nhl/leaders?stat=points&min_games=20&limit=50" \
      -H "x-api-key: $BBS_API_KEY"

    Real response, trimmed

    jsonjson
    {
      "data": {
        "stat": "points",
        "season": 2025,
        "situation": "5on5",
        "min_games": 20,
        "leaders": [
          {
            "rank": 1,
            "player": { "id": "d074fa18-…", "name": "Nathan MacKinnon", "position": "C" },
            "team": { "id": "4c1f4bb8-…", "abbr": "COL" },
            "games_played": 79,
            "icetime_minutes": 1298.4,
            "goals": 22,
            "assists": 47,
            "points": 69,
            "shots": 201,
            "xg": 19.8,
            "goals_above_xg": 2.2
          }
        ]
      }
    }
  3. 03

    Read goals_above_xg — this is the actual edge

    Every row carries xg (expected goals from shot quality) and goals_above_xg. A skater far above their expected goals is finishing hot; a skater far below is generating chances the results have not rewarded yet. That second group is where late-round value lives, and it is the number a scraped build does not have.

    Rank by underlying generation, not resultstypescript
    type Leader = {
      player: { id: string; name: string; position: string };
      team: { abbr: string };
      games_played: number;
      points: number;
      shots: number;
      xg: number | null;
      goals_above_xg: number | null;
    };
    
    const res = await fetch(
      'https://api.bigballsdata.com/v1/nhl/leaders?stat=points&min_games=20&limit=100',
      { headers: { 'x-api-key': process.env.BBS_API_KEY! } },
    );
    const { data } = (await res.json()) as { data: { leaders: Leader[] } };
    
    // Skaters generating more than their finishing shows — the buy-low board.
    const buyLow = data.leaders
      .filter((l) => l.xg !== null && (l.goals_above_xg ?? 0) < -2)
      .sort((a, b) => (b.xg ?? 0) - (a.xg ?? 0));
    
    console.table(
      buyLow.map((l) => ({
        player: l.player.name,
        team: l.team.abbr,
        pts: l.points,
        xg: l.xg,
        over_under: l.goals_above_xg,
      })),
    );
  4. 04

    Add per-game rates for each player

    The leaderboard is season totals. For a draft board you want rates, so a player who missed twenty games is not punished. GET /v1/players/:id/stats returns them. The ?sport= parameter is REQUIRED — omit it and you get a 400 telling you so.

    GET /v1/players/:id/statsbash
    curl -s "https://api.bigballsdata.com/v1/players/d074fa18-08f3-40c4-9c1e-7f85715f66c6/stats?sport=ice_hockey" \
      -H "x-api-key: $BBS_API_KEY"

    Real response, trimmed

    jsonjson
    {
      "data": {
        "sport": "ice_hockey",
        "position": "C",
        "gp": 268,
        "goals": 152,
        "assists": 267,
        "points": 419,
        "plus_minus": 125,
        "shots_on_goal": 1196,
        "goals_pg": 0.6,
        "assists_pg": 1,
        "points_pg": 1.6,
        "shots_pg": 4.5,
        "toi_per_game_seconds": 1359,
        "save_pct": null,
        "gaa": null
      },
      "meta": { "matches_counted": 268, "seasons_counted": [2025, 2024, 2023] }
    }
  5. 05

    Flag injured players before they reach your board

    Nothing wrecks a draft tool faster than ranking a player who is out. GET /v1/injuries?sport=ice_hockey returns the current list with a status and an expected return date; filter your board against it.

    GET /v1/injuriesbash
    curl -s "https://api.bigballsdata.com/v1/injuries?sport=ice_hockey" \
      -H "x-api-key: $BBS_API_KEY"

    Real response, trimmed

    jsonjson
    {
      "data": {
        "sport": "ice_hockey",
        "count": 110,
        "injuries": [
          {
            "player": { "id": "06ecb18a-…", "name": "Aaron Ekblad",
                        "team": { "name": "Florida Panthers", "abbreviation": "FLA" } },
            "status": "Out",
            "injury_type": "Finger",
            "return_date": "2026-09-15"
          }
        ]
      }
    }
  6. 06

    Weight by schedule strength with team Elo

    A player on a team that plays the league's best defences all March is worth less than their rate suggests. GET /v1/teams/:id/elo gives every team a rating; join it to your board through the team id already on each leaderboard row.

    GET /v1/teams/:id/elobash
    curl -s "https://api.bigballsdata.com/v1/teams/bc4ad6fe-a20a-4537-8fcc-839351ac4f52/elo" \
      -H "x-api-key: $BBS_API_KEY"

    Real response, trimmed

    jsonjson
    {
      "data": {
        "name": "Carolina Hurricanes",
        "abbreviation": "CAR",
        "sport": "ice_hockey",
        "league": "NHL",
        "elo_rating": 1685.9,
        "elo_rank": 1,
        "games_counted": 94
      }
    }

Reference

Every endpoint this tutorial uses

All on the gateway at api.bigballsdata.com. Verified against production on 2026-08-15.

MethodPathReturnsWhy you need itPlan
GET/v1/nhl/leadersRanked skaters — goals, assists, points, shots, xg, goals_above_xg, icetimeThe draft board itself. 5v5 only, so power-play volume does not distort the ranking.Free
GET/v1/players/:id/stats?sport=ice_hockeyCareer and per-game rates — points_pg, shots_pg, toi_per_game_secondsTurns season totals into rates so injured-but-elite players rank correctly.Free
GET/v1/injuries?sport=ice_hockeyCurrent injuries with status, type and expected return dateFilters unavailable players off the board before your draft, not during it.Free
GET/v1/standings?league=NHLLeague table by season with wins, losses, win_pctTeam context next to each player, and the source of team ids for the Elo join.Free
GET/v1/teams/:id/eloElo rating, rank, games countedSchedule-strength weighting. The rating is free; see "where the free tier stops" below.Free
GET/v1/players/:id/tierTier 1-5, tier label, tier score, position percentileA single sortable number per player, position-adjusted — the draft-board column you would otherwise have to model yourself.Solo
GET/v1/matches?sport=ice_hockeyMatches with kickoff, status, score, linescoreSchedule and results. See the roadmap note on upcoming NHL fixtures.The NHL forward schedule is thin until the 2026-27 season loads; opening night is 29 September 2026.Free

Pricing, honestly

Where the free tier stops

Everything above runs on a free key. The board works, ranks and drafts. What the free tier does not give you is the modelled layer — the numbers we compute rather than collect — and for a draft tool that layer is the product.

Free key

jsonjson
// Free key — /v1/teams/:id/elo
{
  "elo_rating": 1685.9,
  "elo_rank": null,
  "upgrade": { "locked": ["elo_rank", "elo/history", "win_probability"] }
}

Solo

jsonjson
// Solo — /v1/players/:id/tier
{
  "tier": 2,
  "tier_label": "World Class",
  "tier_score": 62.89,
  "percentile": 95.29,
  "position_percentile": 95.29,
  "primary_metric": "nhl_xg_plus_a1_per_60",
  "primary_metric_value": 1.786
}
  • Player tier and percentile — one position-adjusted number per skater, built on expected goals plus primary assists per 60. This is the column that makes a draft board feel authoritative.
  • Elo rank and rating history — the free tier returns the rating; rank, the trend and win probability are Solo.
  • Season projections and rolling form — /v1/players/:id/season-projection and /v1/players/:id/rolling-stats, for in-season waiver decisions rather than draft-day ones.
  • Market data stays separate. Odds, line divergence and closing-line value sit on Edge, not Solo — they come from licensed market feeds rather than our models.

More tutorials

Other build guides

Known gaps

What is not here yet

Two honest gaps, so you can design around them instead of discovering them mid-build.

  • Live in-game scoring. Per-game NHL box scores are a post-game feed today — every stored row is a final. A live fantasy scoreboard that updates during a game is not something you can build on this endpoint yet.
  • The 2026-27 NHL schedule. Upcoming fixtures are sparse until the new season loads; the season opens 29 September 2026. Historical and completed-season data is unaffected, and that is what a draft board runs on.
  • Goalie leaderboards. /v1/nhl/leaders excludes goalies deliberately — a goalie scores zero goals and would flood a DESC ranking. Goalie expected-goals data is held; a dedicated endpoint is not wired yet.

Questions

Can I build a fantasy hockey app on the free tier?
Yes. The leaderboard, per-player rates, injuries, standings and team Elo ratings are all available on a free key, which is 1,000 requests a day (2,000 with GitHub connected) and needs no credit card. The Solo plan adds the modelled layer — player tiers, Elo rank and history, projections.
What NHL data does the API actually hold?
Twelve seasons, 2014-15 through 2025-26. Scores, standings, injuries and Elo ratings are live. The 5v5 expected-goals leaderboard is sourced from MoneyPuck and refreshes daily. Per-game box scores are post-game finals rather than in-game updates. Call GET /v1/coverage?sport=ice_hockey for the current machine-readable answer rather than trusting this paragraph.
Why does the leaderboard only show 5-on-5 numbers?
Because all-situations totals are dominated by power-play usage, which is a coaching decision rather than a player quality signal. 5v5 production is the more repeatable measure, and repeatability is what a draft board is trying to estimate.
Do I need the MCP server, or can I just call the REST API?
Either. The MCP server lets an AI agent read the data while it writes your code, which is faster if you are already working in an agentic editor. The REST API is the complete surface and every MCP tool is backed by it. The MCP server currently exposes five read tools; the leaderboard and tier endpoints are REST-only for now.
How do I handle player identity across endpoints?
You do not have to. Every endpoint returns the same player id, so the leaderboard row, the per-player stats call and the injury list join directly. Name reconciliation is the tax you pay when scraping several sources; it is the main thing this API removes.

Build it

Free key, no credit card, 1,000 requests a day. The first call in this tutorial works about thirty seconds after you sign up.