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

How to build a fantasy basketball app

A draft board that ranks every qualified NBA player on scoring, efficiency and full Hollinger PER — built on live endpoints, in about fifteen minutes. Two paths: hand it to your AI agent, or write it yourself.

Why this is hard

The problem with fantasy basketball data

Fantasy basketball is decided by rate and role, not by totals. The players who win leagues are the ones whose efficiency and usage are about to outrun their name recognition — and that is precisely the number a scraped build never has.

  • Points per game rewards volume, not quality. A high-usage player on a bad team outscores a more efficient one and drafts too early every year.
  • True shooting and PER need league-wide context — team pace, league totals, minutes — so you cannot compute them from one player page at a time.
  • Per-player scraping does not scale to a draft board. You need the league ranked in one call, already qualified for minimum games, or you are making 400 requests to sort a table.
  • 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.

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 basketball draft board.

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

Then:
1. Pull the current NBA standings with get_standings (sport: basketball).
2. Call get_team_elo for each team so a player's schedule strength is visible
   next to their production.
3. Render a sortable table, best available at the top.

The league leaderboard is NOT an MCP tool — fetch it over plain HTTP from
GET /v1/nba/leaders?stat=per with the same API key, and use that as the
ranking spine.

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. The two endpoints this tutorial leans on hardest — the NBA leaderboard and the player tier score — have no MCP tool today, so an agent-built board still needs those over plain HTTP. Ask your agent to fetch them with the same key; it is the same gateway. Note also that get_player_stats reads the per-player endpoint, which is sparsely populated for the NBA (see the roadmap below) — the leaderboard is the dense source.

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=basketball" \
      -H "x-api-key: $BBS_API_KEY"

    Real response, trimmed

    jsonjson
    { "data": { "generated_at": "2026-08-15T18:40:42.039Z", "sports": [ { "name": "Basketball", "slug": "basketball", "leagues": [ { "key": "nba", ... } ] } ] } }
  2. 02

    Pull the league leaderboard — this is the spine

    GET /v1/nba/leaders ranks the league in one call and applies a games qualifier for you, so small-sample flukes never reach your board. Pass stat=pts, reb, ast, fg_pct, ts_pct or per.

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

    Real response, trimmed

    jsonjson
    {
      "data": {
        "stat": "pts",
        "period": "season",
        "season": 2025,
        "qualified_min_games": 15,
        "leaders": [
          {
            "rank": 1,
            "player": {
              "id": "58a31261-…",
              "name": "Luka Doncic",
              "headshot_url": "https://cdn.nba.com/headshots/nba/latest/260x190/1629029.png"
            },
            "team": { "id": "490c5fad-…", "abbreviation": "LAL" },
            "value": 33.5,
            "games_played": 64
          }
        ]
      }
    }
  3. 03

    Switch stat=per — this is the actual edge

    The same endpoint serves full Hollinger PER, computed with per-player team context and league totals rather than approximated. Ranking by PER instead of points is what separates a draft board from a scoring leaderboard: it prices efficiency and role together, and it is the number a scraped build cannot reproduce from one player page at a time.

    Rank by efficiency, not volumetypescript
    type Leader = {
      rank: number;
      player: { id: string; name: string; headshot_url: string | null };
      team: { id: string; abbreviation: string };
      value: number;
      games_played: number;
    };
    
    const base = 'https://api.bigballsdata.com/v1/nba/leaders';
    const headers = { 'x-api-key': process.env.BBS_API_KEY! };
    
    // Two calls, two boards: volume and efficiency.
    const [pts, per] = await Promise.all(
      ['pts', 'per'].map(async (stat) => {
        const res = await fetch(`${base}?stat=${stat}&limit=100`, { headers });
        const { data } = (await res.json()) as { data: { leaders: Leader[] } };
        return data.leaders;
      }),
    );
    
    // Players who rank far better by PER than by points are the value picks:
    // efficient producers whose raw totals under-sell them.
    const ptsRank = new Map(pts.map((l) => [l.player.id, l.rank]));
    const value = per
      .filter((l) => (ptsRank.get(l.player.id) ?? 999) - l.rank > 15)
      .map((l) => ({ name: l.player.name, per: l.value, gp: l.games_played }));
  4. 04

    Filter out the unavailable

    GET /v1/injuries returns the current basketball injury list with status, expected return date and a written note. Join it to the leaderboard on player id — no name matching. Note the page size caps at 200, so read the count field and page if you want all of them.

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

    Real response, trimmed

    jsonjson
    {
      "data": {
        "sport": "basketball",
        "count": 207,
        "injuries": [
          {
            "player": {
              "id": "ff21c2f1-…",
              "name": "Aaron Gordon",
              "team": { "id": "bf31614a-…", "name": "Denver Nuggets", "abbreviation": "DEN" }
            },
            "status": "Probable",
            "injury_type": null,
            "return_date": "2026-10-01",
            "comment": "Gordon attempted to warm up ahead of Thursday's clash…",
            "updated_at": "2026-07-26T03:46:13.158Z"
          }
        ]
      }
    }
  5. 05

    Add team context with standings and Elo

    Standings give you the season table and the team ids; Elo gives you a strength rating to weight schedule difficulty. Both are free. Read the note field on the Elo response — out of season it tells you plainly that the rating has not moved since the last game played.

    GET /v1/standings and /v1/teams/:id/elobash
    curl -s "https://api.bigballsdata.com/v1/standings?league=NBA" \
      -H "x-api-key: $BBS_API_KEY"
    
    curl -s "https://api.bigballsdata.com/v1/teams/bf31614a-…/elo" \
      -H "x-api-key: $BBS_API_KEY"

    Real response, trimmed

    jsonjson
    {
      "data": {
        "team_id": "bf31614a-…",
        "name": "Denver Nuggets",
        "abbreviation": "DEN",
        "elo_rating": 1618.2,
        "elo_rank": 7,
        "games_counted": 87,
        "last_match_date": "2026-04-30",
        "note": "off-season — rating unchanged since the last match played on 2026-04-30 (107 days ago); last_computed reflects when the ratings job last ran, not new results"
      }
    }

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/nba/leaders?stat=pts|reb|ast|fg_pct|ts_pct|perRanked league leaders with value, games_played, team and headshotThe draft board itself. One call ranks the league, with a minimum-games qualifier already applied.Free
GET/v1/injuries?sport=basketballCurrent injuries with status, expected return date and a written noteFilters unavailable players off the board before your draft, not during it.The NBA is out of season. All 207 rows were last refreshed on 26 July 2026 and reflect end-of-season status; this feed updates through the season.Free
GET/v1/standings?league=NBALeague 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 counted, and an explicit off-season noteSchedule-strength weighting. The rating is free; see "where the free tier stops" below.Free
GET/v1/players/:id/stats?sport=basketballPer-game rates — ppg, rpg, apg, spg, bpg, fg_pct, fg3_pct, ft_pctPer-player detail for a specific player page. The ?sport= parameter is required.NBA coverage here is partial: in a 30-player sample, 5 returned games played and the rest returned zeros with matches_counted: 0. Check matches_counted before rendering, and use /v1/nba/leaders as the dense source.Free
GET/v1/players/:id/tierTier 1-5, tier label, tier score, percentile and the metric behind itA single sortable number per player, position-adjusted — the draft-board column you would otherwise have to model yourself.Solo
GET/v1/matches?sport=basketballMatches with tipoff, status, score, linescoreSchedule and results. Pass &league=nba — sport=basketball also spans NCAAB and the WNBA.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": 1618.2,
  "elo_rank": null,
  "upgrade": { "locked": ["elo_rank", "elo/history", "win_probability"] }
}

Solo

jsonjson
// Solo — /v1/players/:id/tier
{
  "tier": 3,
  "tier_label": "All-Star",
  "tier_score": 58.4,
  "percentile": 91.1,
  "position_percentile": 90.9,
  "primary_metric": "per",
  "primary_metric_value": 20.3,
  "review_method": "computed"
}
  • Player tier and percentile — one position-adjusted number per player, built on PER. This is the column that makes a draft board feel authoritative, and it carries review_method so you know whether it was computed or reviewed.
  • 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

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

  • Per-player stats coverage is partial. /v1/players/:id/stats returned zeros for 25 of 30 sampled NBA players, with matches_counted: 0 — the underlying box-score rows are not loaded for most of the roster. The leaderboard aggregates the rows that do exist and is dense; build on it, and treat the per-player endpoint as a detail view that you null-check.
  • The NBA is out of season. The last game played was 30 April 2026, Elo says so in its own note field, and the injury list was last refreshed on 26 July 2026. Historical and completed-season data is unaffected, and that is what a draft board runs on — but nothing here is live right now.
  • Live in-game scoring. A live fantasy scoreboard that updates during a game is not something these endpoints support today.

Questions

Can I build a fantasy basketball app on the free tier?
Yes. The league leaderboard, injuries, standings, matches 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 NBA data does the API actually hold?
Scores, standings, injuries, team Elo and an aggregated league leaderboard including full Hollinger PER. Per-player box-score coverage is partial — see the roadmap above. Call GET /v1/coverage?sport=basketball for the current machine-readable answer rather than trusting this paragraph.
Why rank by PER instead of points per game?
Because points per game prices volume, and volume is largely a function of usage and team quality. PER folds efficiency, rebounding, assists and turnovers into one pace-adjusted number, so it estimates the thing a draft is actually trying to buy. We compute it with real per-player team context and league totals, not an approximation.
Does sport=basketball mean the NBA?
Not on its own. The basketball sport key also spans NCAAB and the WNBA, so a bare sport=basketball query on /v1/teams or /v1/matches will return college and WNBA rows too. Add &league=nba when you want the NBA specifically. The /v1/nba/* routes are already NBA-scoped.
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 injury list and the tier call 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.