Tutorial · 9 min · endpoints verified 2026-09-04

How to build an NFL scouting tool

Nineteen defensive columns on every weekly row, 2020 to 2025, 112,333 rows. Rank a position group by pressure production in about forty lines.

Why this is hard

The problem with NFL defensive data

Offensive box scores are everywhere. Defensive ones are either behind a five-figure enterprise contract or reduced to a tackle count, which is the least informative number a defender produces. A scouting view needs the disruption stats — pressure, tackles for loss, coverage — at the week level, for enough seasons that one good year is visible as one good year.

  • Free sources stop at tackles and interceptions, which flatter box-score defenders and hide edge rushers.
  • Season totals cannot tell a 17-game grind from a six-game tear; you need the weekly rows.
  • Percentiles are the whole point of scouting and almost nobody exposes the population to compute them against.

Path 1 · recommended

Have your AI agent build it

The MCP server can find the player and pull their stats, which covers the single-player half of scouting. It cannot do the population half — ranking a whole position group — because that needs every defender’s rows at once and the tools are per-player. Start here, then drop to REST for the board.

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

  • find_players

    Find canonical player IDs by name for use with player-stat tools.

  • get_player_stats

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

  • 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
Using the Big Balls Sports MCP server, find Trey Hendrickson and pull his 2024 weekly stats. Total his sacks, QB hits and tackles for loss, then tell me which weeks he was held without a pressure.

What the agent cannot reach

The agent can answer for one player at a time. There is no leaders or percentile tool for the NFL, so "who ranks where" is a REST job — the manual path below builds the population and computes the percentiles from it.

Path 2 · hand-coded

Build it yourself

Three calls and a percentile. Roster to get the defenders, weekly stats per player, then rank them. Everything below ran against production on 2026-09-04 and the board is its real output.

  1. 01

    Get a roster and keep the defenders

    The roster endpoint returns player_id in the same format the stats route expects, so no id mapping is needed. Note that position here is coarse (DB) while the stats rows are specific (CB, SAF) — filter on depth_chart_position when you need the finer one.

    Roster for one teambash
    curl -s -H "x-api-key: $BBS_API_KEY" \
      "https://api.bigballsdata.com/v1/nfl/rosters?team=CIN&season=2024"

    Real response, trimmed

    jsonjson
    {
      "data": [
        {
          "season": 2024,
          "team": "CIN",
          "player_id": "00-0037753",
          "player_name": "Cam Taylor-Britt",
          "position": "DB",
          "depth_chart_position": "CB",
          "jersey_number": 29,
          "status": "ACT",
          "years_exp": 2
        }
      ]
    }
  2. 02

    Pull one defender’s weekly rows

    Seventeen rows for a full season, each carrying the nineteen defensive columns. Weekly is the unit that matters: a season total of 17.5 sacks does not tell you they came in eleven of seventeen weeks.

    Weekly defensive statsbash
    curl -s -H "x-api-key: $BBS_API_KEY" \
      "https://api.bigballsdata.com/v1/nfl/players/00-0033935/stats?season=2024"

    Real response, trimmed

    jsonjson
    {
      "data": [
        {
          "season": 2024,
          "week": 1,
          "player_name": "T.Hendrickson",
          "position": "LB",
          "tackles_solo": 0,
          "tackle_assists": 1,
          "tackles_for_loss": 1,
          "sacks": 0,
          "qb_hits": 2,
          "passes_defended": 0,
          "fumbles_forced": 0,
          "interceptions_caught": 0,
          "defensive_tds": 0
        }
      ]
    }
  3. 03

    Build the population, then the percentiles

    Percentiles need everybody, not one player — and there is no NFL leaders endpoint, so you assemble the population yourself. Thirty-two roster calls and one stats call per defender is enough for a league-wide board, and it caches well because completed seasons never change.

    scout.tsts
    const KEY = process.env.BBS_API_KEY!;
    const api = async (p: string) =>
      (await fetch(`https://api.bigballsdata.com${p}`, { headers: { 'x-api-key': KEY } })).json();
    
    const SEASON = 2024;
    const FRONT_SEVEN = new Set(['DE', 'DT', 'LB']);
    
    // 1. Every defender on every roster.
    const teams = ['ARI','ATL','BAL','BUF','CAR','CHI','CIN','CLE','DAL','DEN','DET','GB','HOU','IND',
                   'JAX','KC','LA','LAC','LV','MIA','MIN','NE','NO','NYG','NYJ','PHI','PIT','SEA','SF',
                   'TB','TEN','WAS'];
    const rosters = await Promise.all(
      teams.map((t) => api(`/v1/nfl/rosters?team=${t}&season=${SEASON}`)),
    );
    
    // 2. Their weekly rows, then season totals.
    type Row = { position: string; sacks: number | null; qb_hits: number | null;
                 tackles_for_loss: number | null; passes_defended: number | null;
                 interceptions_caught: number | null };
    
    const totals = new Map<string, { name: string; pos: string; pressure: number; tfl: number; cover: number; weeks: number }>();
    for (const r of rosters.flatMap((x) => x.data)) {
      const { data } = await api(`/v1/nfl/players/${r.player_id}/stats?season=${SEASON}`);
      const rows = (data as Row[]).filter((w) => FRONT_SEVEN.has(w.position));
      if (rows.length < 10) continue;               // a season, not a cup of coffee
      totals.set(r.player_id, {
        name: r.player_name,
        pos: rows[0]!.position,
        // sacks + QB hits is the cheapest honest pressure proxy on this data.
        pressure: sum(rows, 'sacks') + sum(rows, 'qb_hits'),
        tfl: sum(rows, 'tackles_for_loss'),
        cover: sum(rows, 'passes_defended') + sum(rows, 'interceptions_caught'),
        weeks: rows.length,
      });
    }
    function sum(rows: Row[], k: keyof Row) {
      return rows.reduce((n, r) => n + Number(r[k] ?? 0), 0);
    }
    
    // 3. Percentile within the population you just built.
    const board = [...totals.values()].sort((a, b) => b.pressure - a.pressure);
    const pct = (i: number) => Math.round((100 * (board.length - 1 - i)) / (board.length - 1));
    board.slice(0, 6).forEach((p, i) =>
      console.log(`${p.name.padEnd(16)} ${p.pos.padEnd(3)} pressure ${String(p.pressure).padStart(4)}  TFL ${String(p.tfl).padStart(3)}  pct ${pct(i)}`),
    );

    Real response, trimmed

    jsonjson
    T.Hendrickson    LB  pressure 53.5  TFL  19  pct 100
    Z.Allen          DE  pressure 51.5  TFL  17  pct 100
    G.Karlaftis      DE  pressure   48  TFL  13  pct  99
    M.Garrett        DE  pressure   42  TFL  22  pct  99
    L.Williams       DE  pressure   39  TFL  16  pct  99
    N.Bonitto        LB  pressure 38.5  TFL  16  pct  98

Reference

Every endpoint this tutorial uses

All on the gateway at api.bigballsdata.com. Verified against production on 2026-09-04.

MethodPathReturnsWhy you need itPlan
GET/v1/nfl/rosters?team=&season=player_id, player_name, position, depth_chart_position, jersey_number, status, years_expThe only way to enumerate a position group. player_id comes back in the format the stats route wants, so there is no id-mapping step.Free
GET/v1/nfl/players/:id/stats?season=One row per week with 19 defensive columns alongside the offensive onesThe weekly grain is what makes a scouting view different from a leaderboard. Populated on 112,319 of 112,333 rows across 2020-2025.Free

Pricing, honestly

Where the free tier stops

A free key builds the board above for one season. The archive is the paid part, and for scouting it is the part that matters: one season ranks players, six seasons show whether a breakout held.

Free key

jsonjson
One season of weekly defensive rows, 1,000 requests a day.

Solo

jsonjson
All six seasons, 2020-2025, and the request budget to walk 32 rosters without pacing.
  • A league-wide board is ~1,700 calls at one per defender per season — comfortable on a paid key, a full day of a free one.
  • Completed seasons never change, so the archive caches permanently. You pay for it once and re-read it forever.
  • Six seasons is the difference between "had a good year" and "is good".

More tutorials

Other build guides

Known gaps

What is not here yet

Three things a scouting build would reasonably expect, which this API does not honestly provide today. None of them is coming-soon copy for something that already works.

  • There are no NFL percentiles or tiers to call. /v1/players/:id/tier serves percentile and position_percentile for hockey, basketball and soccer; player_tiers holds nothing for american_football. The tutorial computes percentiles from the population it builds, which is why step 3 exists at all.
  • nfl_player_stats.interceptions is permanently null. It is in the response, it is 0% populated across all 112,333 rows, and the loader documents why: nflverse has no such column. Use interceptions_caught, which is populated like the rest. A field that is always null is worse than an absent one, so it is named here rather than left to be found.
  • There are no snap counts, no coverage grades and no pressure rate denominators. "Pressure" above is sacks plus QB hits, which is a count, not a rate — without snaps you cannot say per-opportunity, and this API does not hold them.
  • No college data links to these players. The NFL rows stand alone; there is no draft-year or NCAA production join.

Questions

How many defensive columns are there really?
Nineteen, and they were counted per column rather than inherited from a changelog: eighteen arrived together (tackles solo and assists, tackles with assist, tackles for loss and their yards, sacks and sack yards, QB hits, passes defended, forced fumbles, interception yards, defensive touchdowns, safeties, punt/PAT/FG blocks, and the two defensive two-point fields) and interceptions_caught arrived separately. All nineteen are populated on 112,319 of 112,333 rows.
Why does the code compute percentiles instead of calling an endpoint?
Because there is no NFL percentile endpoint. The tier route exists and serves percentiles for other sports, but the underlying table has no american_football rows. Computing them from a population you assembled is also more honest for scouting: you can see exactly which players are in the comparison set, which matters when the answer changes depending on whether you included special-teamers.
Can I scout offensive players with the same code?
Yes, and it is the same shape — swap the position filter and the columns. The offensive side is what the fantasy football tutorial builds on, including precomputed PPR points, so if that is your target start there instead.
How far back does it go?
Six seasons, 2020 through 2025, 112,333 weekly rows. 2026 rows begin landing after the season opens on 2026-09-09.

Build it

Free key, no credit card, 1,000 requests a day. Every call in this tutorial works on it.