NFL Schedule API: Pull the Full 2026 Season in Python

NFL Schedule API - OddsPapi API Blog
How To Guides August 21, 2026

The NFL does not publish a schedule API. ESPN’s old public endpoints are undocumented and break without notice, nflverse ships CSV dumps built for R users, and the licensed stats feeds put a sales call in front of a JSON file. None of that helps if you want the 2026 fixture list in your own database this afternoon.

This guide pulls the whole loaded NFL season out of the OddsPapi /v4/fixtures endpoint in 21 API calls and 52 seconds, on the free key. Every number below came off the live API on August 12, 2026. All eight code blocks ran end to end before this post went out.

What you get, and what you do not

The endpoint returns fixtures: teams, kickoff times, status, a coverage flag, and a block of third-party IDs for joining to other data. It does not return scores, and it does not return a week number. Both gaps are fixable and this post shows how.

The old way OddsPapi /v4/fixtures
Scrape ESPN’s undocumented JSON and re-fix the parser every season One documented endpoint, stable JSON shape
Download a CSV dump and wait for someone to update it Live feed, updatedAt on every fixture
Sales call before you see a schema Free key, curl it in 30 seconds
Schedule from one provider, odds from another, IDs that never match Schedule and odds keyed on the same fixtureId
No way to join to Betradar or Sofascore externalProviders on every fixture

Step 1: authenticate

The key goes in the query string. It is not a header, and passing it as one gets you a 401.

import datetime as dt
import time
import requests

API_KEY = "YOUR_API_KEY"
BASE_URL = "https://api.oddspapi.io/v4"


def fetch(path, **params):
    """GET a v4 endpoint. Returns None when the API says 'nothing here'."""
    params["apiKey"] = API_KEY
    for attempt in range(6):
        r = requests.get(f"{BASE_URL}/{path}", params=params, timeout=60)
        if r.status_code == 429:
            wait = r.json()["error"].get("retryMs", 1500) / 1000
            time.sleep(wait + 0.3)
            continue
        if r.status_code == 404:
            return None                      # empty window, not an error
        r.raise_for_status()
        return r.json()
    raise RuntimeError(f"rate limited on /{path}")


print(len(fetch("sports")), "sports")        # 69

Two things in that function matter more than they look. A 429 response carries a JSON body with retryMs, so the API tells you exactly how long to wait rather than making you guess. And a 404 from /fixtures means the window is empty, which happens 11 times in the season loop below. If you let raise_for_status() see it, your season pull dies in August.

The 404 is not an error

GET /v4/fixtures?sportId=14&tournamentId=31&from=2026-08-12&to=2026-08-22

404
{"error": {"message": "No fixtures found for the specified criteria.",
           "code": "FIXTURE_NOT_FOUND",
           "details": "Please check your filters and try again."}}

The NFL plays no games in late August, so the window is genuinely empty. Treat FIXTURE_NOT_FOUND as an empty list and move to the next window.

Step 2: find the NFL

American football is sportId=14, and that sport holds 29 tournaments. Six of them have fixtures loaded right now, including the college board with 3,507 of them. Filter on the wrong row and you get a schedule 26 times too big.

tours = fetch("tournaments", sportId=14)

for t in tours:
    if t["futureFixtures"]:
        print(f"{t['tournamentId']:>6}  {t['tournamentName']:<28} "
              f"{t['categoryName']:<14} {t['futureFixtures']} future")
    31  NFL                          USA            132 future
   233  NFL Preseason                USA             48 future
   790  CFL                          Canada          43 future
 27653  NCAA, Regular Season         USA           3507 future
 51578  EFA                          International    2 future
 51580  AFLE                         International    8 future

The NFL is tournamentId=31. Hardcode it after you have looked it up once. Preseason lives on its own ID (233), so a pull that expects one season list will silently miss August football.

tournamentId works on /fixtures, and it is worth using

The parameter is not in the endpoint docs but it filters server side. The same six-day window returned 324 American football fixtures unfiltered and 15 with tournamentId=31 attached. That is 20 times less JSON to download and parse.

Step 3: pull the season

/fixtures takes a date range and caps it at 10 days. A season is about 25 weeks, so you loop. Deduplicate on fixtureId as you go, because consecutive windows share a boundary day.

def pull_season(tournament_id, start, end, window=10):
    fixtures, calls, empty = {}, 0, 0
    day = dt.date.fromisoformat(start)
    last = dt.date.fromisoformat(end)
    while day < last:
        to = min(day + dt.timedelta(days=window), last)
        batch = fetch("fixtures", sportId=14, tournamentId=tournament_id,
                      **{"from": day.isoformat(), "to": to.isoformat()})
        calls += 1
        if batch is None:
            empty += 1
        else:
            for f in batch:
                fixtures[f["fixtureId"]] = f
        day = to
        time.sleep(1.0)                      # per-endpoint cooldown
    return list(fixtures.values()), calls, empty


season, calls, empty = pull_season(31, "2026-08-12", "2027-03-05")
print(f"{len(season)} fixtures in {calls} calls ({empty} empty)")
# 132 fixtures in 21 calls (11 empty)

That run took 52 seconds, and 21 of those seconds are the deliberate sleep(1.0). The free tier rate-limits per endpoint, and a one-second gap between calls to the same path returns clean 200s every time. Do not thread this. Concurrency at any worker count gets almost everything rejected.

Set to to the day after the last day you want

The window is inclusive of both midnight instants, which means to behaves as a timestamp rather than a whole day. Asking for one day gets you only the games that kick off at exactly 00:00 UTC:

Query Fixtures returned
from=2026-09-13&to=2026-09-13 0
from=2026-09-13&to=2026-09-14 12

Both queries cover the same Sunday slate. The first one makes the NFL look cancelled.

Step 4: the fixture object

Here is one game, unedited:

{
  "fixtureId": "id1400003171515752",
  "sportId": 14,
  "tournamentId": 31,
  "tournamentName": "NFL",
  "tournamentSlug": "nfl",
  "categoryName": "USA",
  "seasonId": null,
  "statusId": 0,
  "statusName": "Pre-Game",
  "hasOdds": true,
  "startTime": "2026-09-10T00:20:00.000Z",
  "trueStartTime": null,
  "trueEndTime": null,
  "updatedAt": "2026-08-11T23:10:47.105Z",
  "participant1Id": 4430,
  "participant1Name": "Seattle Seahawks",
  "participant1ShortName": "Seattle",
  "participant1Abbr": "SEA",
  "participant2Id": 4424,
  "participant2Name": "New England Patriots",
  "participant2ShortName": "New England",
  "participant2Abbr": "NE",
  "externalProviders": {
    "betradarId": 71515752,
    "opticoddsId": "20260910B2546DE0",
    "sofascoreId": 16184611,
    "betgeniusId": 13906257,
    "flashscoreId": "GQtrH2RF",
    "pinnacleId": 1630865288,
    "mollybetId": null,
    "lsportsId": null,
    "txoddsId": null,
    "oddinId": null
  }
}

Three fields will bite you.

statusName is missing, not null, on some fixtures. Sixteen of the 132 NFL games have no statusName key at all, and the same 16 have statusId: null. Every one of them is a real game with a real kickoff time. Use f.get("statusName", "Unknown") and your parser survives.

seasonId is null on all 132. Do not build a season key off it.

There is no week number. The feed gives you a kickoff timestamp and nothing else. Derive the week by anchoring on opening day, since the NFL runs a clean Thursday-to-Wednesday cycle:

season.sort(key=lambda f: f["startTime"])


def utc(fixture):
    return dt.datetime.fromisoformat(fixture["startTime"].replace("Z", "+00:00"))


opening_day = utc(season[0]).date()


def week_of(fixture):
    return (utc(fixture).date() - opening_day).days // 7 + 1


weeks = {}
for f in season:
    weeks.setdefault(week_of(f), []).append(f)

for w in sorted(weeks):
    games = weeks[w]
    print(f"Week {w:>2}: {len(games):>2} games, "
          f"{sum(g['hasOdds'] for g in games):>2} with odds")
Week  1: 16 games, 16 with odds
Week  2: 16 games, 16 with odds
Week  3: 16 games, 16 with odds
Week  4: 16 games, 16 with odds
Week  5: 15 games, 15 with odds
Week  6: 14 games, 14 with odds
Week  7: 14 games, 14 with odds
Week  8: 14 games, 14 with odds
Week  9:  1 games,  1 with odds
Week 10:  1 games,  1 with odds
Week 11:  1 games,  0 with odds
Week 12:  4 games,  0 with odds
Week 16:  4 games,  0 with odds

The 16-game weeks are complete rounds for all 32 teams. Weeks 5 through 8 drop to 14 or 15 because byes start. After Week 8 the feed thins to a handful of marquee dates, and the four Week 12 games are Thanksgiving.

The season loads in front of you

132 fixtures is 48.5% of a 272-game regular season. Weeks 1 through 8 are fully loaded; the back half is not there yet, and no team has all 17 of its games on the feed. Re-run the loop weekly and merge on fixtureId rather than treating one pull as the finished article.

Step 5: kickoff times move a day

Every timestamp is UTC, and NFL prime-time kickoffs cross midnight when you convert. Twelve of the 132 games land on a Friday in UTC. None of them are Friday games.

EASTERN = dt.timezone(dt.timedelta(hours=-4))   # EDT during the season

for f in season[:5]:
    local = utc(f).astimezone(EASTERN)
    print(f"{local:%a %b %d %H:%M} ET  "
          f"{f['participant1Name']} v {f['participant2Name']}")
Wed Sep 09 20:20 ET  Seattle Seahawks v New England Patriots
Thu Sep 10 20:35 ET  Los Angeles Rams v San Francisco 49ers
Sun Sep 13 13:00 ET  Indianapolis Colts v Baltimore Ravens
Sun Sep 13 13:00 ET  Detroit Lions v New Orleans Saints
Sun Sep 13 13:00 ET  Pittsburgh Steelers v Atlanta Falcons

Converted to Eastern, the slate structure appears exactly where you expect it: 54 games at Sunday 13:00, 19 at Sunday 16:25, seven Sunday nighters at 20:20, seven Monday nighters at 20:15, and four Sunday morning kickoffs at 09:30 that are the London games. Group by UTC date and you get none of that.

Step 6: join keys for everything else

The feed has no scores and no player stats, so most real pipelines join it to something that does. externalProviders is how. Coverage is uneven and worth measuring before you pick a key:

from collections import Counter

coverage = Counter()
for f in season:
    for provider, value in (f.get("externalProviders") or {}).items():
        if value is not None:
            coverage[provider] += 1

print({k: f"{v}/{len(season)}" for k, v in coverage.most_common()})
Provider key Populated Type
betradarId 132 of 132 int
opticoddsId 117 of 132 str
sofascoreId 30 of 132 int
flashscoreId 30 of 132 str
betgeniusId 16 of 132 int
pinnacleId 16 of 132 int
lsportsId 4 of 132 int
mollybetId 1 of 132 int

Betradar is the only key present on every fixture, so build your join on that and fall back to team names plus kickoff date. Sofascore and Flashscore, the two free scoreboards a hobby project would reach for, cover 23%. The IDs also arrive at different types, so cast before you compare.

Step 7: hasOdds tells you a book exists, not that a market does

123 of the 132 fixtures carry hasOdds: true, which reads like the whole season is priced four months out. It is not. Measure the board instead of trusting the flag:

def board_depth(fixture_id):
    payload = fetch("odds", fixtureId=fixture_id)
    books = (payload or {}).get("bookmakerOdds") or {}
    live = {s: b for s, b in books.items() if not b.get("suspended")}

    quotes = {}
    for slug, book in live.items():
        ml = book.get("markets", {}).get("141")        # Winner (incl. overtime)
        if not ml:
            continue
        price = tuple(sorted(
            (oid, round(o["players"]["0"]["price"], 4))
            for oid, o in ml["outcomes"].items()
            if o.get("players", {}).get("0", {}).get("active")))
        if len(price) == 2:
            quotes.setdefault(price, []).append(slug)

    markets = sum(len(b.get("markets", {})) for b in live.values())
    return len(live), len(quotes), markets

Note where active lives. An outcome object has exactly one key, players. The price, the active flag and the limit all sit one level deeper at players["0"], so outcome["active"] is always undefined. On game lines the key is the string "0"; on player props the same dict is keyed by player ID.

Run it across three fixtures on each of the first three Sunday slates, so the comparison is like for like:

Sunday slate Days out hasOdds Books Independent quotes Markets
Week 1, Sep 13 32 true 18 13 to 14 167 to 177
Week 2, Sep 20 39 true 4 3 20
Week 3, Sep 27 46 true 3 2 10 to 11

One week further out costs you four fifths of the board. Week 1 carries Pinnacle, SBOBet, Kalshi, Polymarket, Bet365, Circa and eleven others. Week 3 carries Caesars, William Hill and DraftKings, and Caesars and William Hill are the same feed quoting byte-identical prices. Two independent opinions, both flagged hasOdds: true.

This is how sportsbooks work rather than a gap in the feed. Books hang the opening week first and fill the rest in as it approaches, and one desk carrying season-long placeholder numbers is enough to flip the flag. Prime-time standalone games hold depth longer than the Sunday slate around them: the Week 2 Friday game had 10 books while its Sunday slate had four.

Rule: a fixture count is not a coverage number. If your pipeline picks games to price, filter on measured depth, not on hasOdds.

What Week 1 looks like when the board is real

Seattle at New England, the first game on the feed, carried 174 markets across 18 bookmakers and 72 distinct market IDs. The moneyline, sorted by margin:

Bookmaker Seattle New England Margin
polymarket 1.493 2.941 0.98%
kalshi 1.515 2.778 2.00%
pinnacle 1.490 2.770 3.22%
circasports 1.526 2.650 3.27%
fanduel 1.530 2.600 3.82%
caesars = williamhill 1.508 2.640 4.19%
draftkings 1.521 2.600 4.21%
bet365 1.500 2.650 4.40%
hardrockbet 1.526 2.550 4.75%
sbobet 1.480 2.660 5.16%

Polymarket and Kalshi price the opener tighter than Pinnacle does, which reproduces what the prediction markets have been doing on college football and La Liga all summer. Read those as best available prices and nothing more; exchange quotes need a depth check on the exchangeMeta ladder before you treat them as real.

Deduplicating the 18 books on the exact price tuple leaves 12 independent quotes. Some of those collapses are known shared feeds (Caesars with William Hill, BetMGM with Borgata, BetParx with BallyBet and FourWinds) and some are coincidence at a round number. Dedupe on the tuple first, then confirm against /v4/bookmakers, because cloneOf does not flag every shared feed.

Step 8: write it to CSV

import csv

with open("nfl_2026_schedule.csv", "w", newline="") as fh:
    writer = csv.writer(fh)
    writer.writerow(["week", "kickoff_utc", "kickoff_et", "home", "away",
                     "status", "has_odds", "fixture_id", "betradar_id"])
    for f in season:
        writer.writerow([
            week_of(f),
            f["startTime"],
            utc(f).astimezone(EASTERN).isoformat(),
            f["participant1Name"],
            f["participant2Name"],
            f.get("statusName", "Unknown"),
            f["hasOdds"],
            f["fixtureId"],
            (f.get("externalProviders") or {}).get("betradarId"),
        ])

132 rows, nine columns, ready for pandas or a SQLite table. Keep fixtureId in the file. It is the key for every odds call you make later, and for the free /v4/historical-odds endpoint that gives you the full price history of any fixture back to its opening line.

The same loop, other competitions

Nothing in pull_season is NFL-specific past the tournament ID. Swap it and the loop works on any of the 69 sports:

Competition tournamentId Fixtures loaded With odds
NFL 31 132 123
NFL Preseason 233 48 48
NCAA regular season 27653 3,507 future 97 in the opening 10 days
CFL 790 43 not measured

College is the interesting one. 3,507 future fixtures sounds like total coverage until you look at the opening window, where 416 games loaded and 97 had a price. The NCAAF board also runs 42% active, meaning most quoted outcomes are suspended lines a book posted early and stopped taking.

What is not in the feed

Being straight about the gaps saves you a wasted afternoon.

  • No scores or results. statusName reaches Finished and stops. Join out on betradarId or sofascoreId, or recover win and loss labels from the way historical prices collapse toward 1.00 after kickoff.
  • No player stats, no injuries, no venue, no weather. Teams, time, status, IDs.
  • No week number and no seasonId. Derive the week as shown above.
  • Half the season. 132 of 272 games today, front-loaded on Weeks 1 to 8.
  • No NFL outrights. The catalogue has no Super Bowl winner or division market. The only futures-shaped NFL market is To Win the Coin Toss.

Where the schedule pays off

A fixture list on its own is a calendar. Joined to the odds endpoint it becomes the spine of everything else: 350+ bookmakers on one fixtureId, free historical price history back to the opening line on the same key, and a WebSocket feed that pushes changes instead of making you poll for them. The Week 1 opener alone carries 72 distinct market IDs, including spreads, totals, team totals and anytime touchdown props keyed by player.

Start with the NFL odds guide for the market IDs, key numbers for what a half point is worth on the spread, and the free sports data API guide if you want the same loop pointed at the other 68 sports. New to the API? Make your first call first.

FAQ

Is there a free NFL schedule API?

Yes. The OddsPapi /v4/fixtures endpoint returns the NFL schedule as JSON on the free tier. The full loaded 2026 season took 21 calls and 52 seconds on a free key.

What is the NFL tournament ID?

31, with slug nfl under category USA. Preseason is 233 and the college regular season is 27653. Sport ID 14 covers all of them plus the CFL, XFL and UFL, so filter on the tournament.

Why does my date query return nothing?

Two reasons. to is a midnight UTC instant, so set it to the day after the last day you want. And an empty window returns HTTP 404 with code FIXTURE_NOT_FOUND, which your fetch wrapper should treat as an empty list.

Does the API include NFL scores?

No. Fixtures carry teams, kickoff time and status only. Use the externalProviders block to join to a scores source. betradarId is populated on all 132 fixtures; sofascoreId and flashscoreId on 30 each.

How far ahead are NFL odds available?

Books price the opening week properly and thin out fast after it. Week 1 fixtures carried 18 bookmakers and up to 177 markets; the Week 3 Sunday slate carried three books and 10 markets. Every one of those fixtures reports hasOdds: true, so measure the board rather than trusting the flag.

Can I get last season’s NFL schedule and odds?

/v4/historical-odds keeps deep price history per fixture, but 2025 regular-season NFL history has aged out. January 2026 playoff games still return data from retail books, and Super Bowl LX is fully retained.

Get your key

The schedule loop above runs on the free tier with no card and no sales call. Grab a free API key, point pull_season at tournament 31, and have the 2026 fixture list in your database before kickoff.