NFL Schedule API: Pull the Full 2026 Season in Python
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.
statusNamereachesFinishedand stops. Join out onbetradarIdorsofascoreId, 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.