When Do NFL Odds Open? A 272-Fixture Study of the Board
The full 2026 NFL regular season is loaded: 272 fixtures, every one of them flagged hasOdds: true out to Week 12. That flag is the first thing most people build on, and it tells you almost nothing.
Pull the actual board and the picture is different. Week 1 carries 167 bookmakers and 1,679 distinct market IDs. Week 2 drops to 27. Week 8 is eight books. Week 14 onward is empty. And Pinnacle, the sharp benchmark everyone calibrates against, prices Week 1 and nothing after it — confirmed on 5 of 5 Week 1 fixtures and 0 of 20 across Weeks 2 to 5.
This post measures how an NFL board actually fills in, across the whole season, on the free tier. There is one finding in it I did not expect, and it is about London.
Step 1: pull the whole season
American football is sportId 14 and carries NCAA, CFL and the European leagues alongside the NFL, so filter on tournamentId 31. The /fixtures window maxes out at 10 days, so walk it.
Two traps will break a naive loop. An empty window returns HTTP 404, not an empty array, so raise_for_status() kills the run partway through — five of my twenty windows came back 404. And to is a midnight-UTC instant rather than a whole day, so set it to the day after the last day you want.
import requests, time, collections
from datetime import datetime, timezone, timedelta
API_KEY = "YOUR_API_KEY"
BASE_URL = "https://api.oddspapi.io/v4"
def get(path, **params):
for _ in range(5):
params["apiKey"] = API_KEY
r = requests.get(f"{BASE_URL}/{path}", params=params)
if r.status_code == 429:
time.sleep(r.json()["error"].get("retryMs", 1500) / 1000 + 1.2)
continue
return r.status_code, r.json()
return r.status_code, {}
fixtures = {}
cursor = datetime(2026, 9, 3, tzinfo=timezone.utc)
end = datetime(2027, 3, 1, tzinfo=timezone.utc)
while cursor < end:
nxt = cursor + timedelta(days=9)
status, data = get("fixtures", sportId=14, tournamentId=31,
**{"from": cursor.strftime("%Y-%m-%d"),
"to": nxt.strftime("%Y-%m-%d")})
if status == 200 and isinstance(data, list): # 404 == empty window
for f in data:
fixtures[f["fixtureId"]] = f
cursor = nxt
time.sleep(1.3)
print(len(fixtures), "unique fixtures") # 272
There is no week or round field on a fixture, so derive it from startTime against opening day. For the NFL that reproduces the real week grid exactly.
OPENING_DAY = datetime(2026, 9, 10, tzinfo=timezone.utc)
def week(f):
start = datetime.fromisoformat(f["startTime"].replace("Z", "+00:00"))
return (start - OPENING_DAY).days // 7 + 1
What the board actually looks like, week by week
Sampling two fixtures per week and counting books with a two-sided active moneyline, plus distinct market IDs and the share of prices that are live:
| Week | Days out | Books | Independent prices | Market IDs | Active |
|---|---|---|---|---|---|
| 1 | 14 | 167 | 57 | 1,679 | 38.6% |
| 2 | 22 | 105 | 34 | 196 | 38.6% |
| 2 | 24 | 27 | 13 | 192 | 24.5% |
| 3 | 29 | 26 | 14 | 192 | 20.6% |
| 5 | 43 | 25 | 13 | 192 | 20.5% |
| 7 | 57 | 17 | 9 | 192 | 28.8% |
| 8 | 64 | 8 | 6 | 101 | 100.0% |
| 10 | 78 | 8 | 6 | 104 | 99.6% |
| 12 | 91 | 43 | 20 | 114 | 98.8% |
| 13 | 99 | 2 | 1 | 3 | 100.0% |
| 14–18 | 106+ | 0 | 0 | 0 | — |
Measured 26 August 2026. Live counts drift by a percent or two between calls, so re-run rather than quoting these back at the API.
Three things in that table are worth pulling out.
The derivative menu is a Week 1 phenomenon
Market IDs go 1,679 in Week 1 to 192 in Week 2, and settle around 101 to 137 from Week 8. Week 1 carries a spread and total ladder for every quarter, both halves, and both team totals. Week 2 carries the main lines and little else. Whatever you build against the Week 1 payload will find a fifth of the market space the following week.
The active rate inverts with board width
This is the counterintuitive one. A Week 8 fixture 64 days out is 100% active across 8 books. The Week 1 opener 14 days out is 38.6% active across 167. More books does not mean more live prices, it means proportionally fewer.
The mechanism is simple once you see it: the eight books quoting a game two months out are quoting a moneyline and a couple of lines, and they mean it. The 167 books on Week 1 have each posted a full derivative menu and switched most of it off. So a coverage score that ignores the price-level active flag rewards exactly the wrong fixtures.
def board(fixture_id):
status, odds = get("odds", fixtureId=fixture_id)
books = {s: b for s, b in odds.get("bookmakerOdds", {}).items()
if not s.startswith("pinnacle+") and s != "demo"} # drop test feeds
total = active = 0
market_ids, moneylines = set(), {}
for slug, book in books.items():
for market_id, market in book.get("markets", {}).items():
market_ids.add(market_id)
for outcome in market["outcomes"].values():
price = outcome["players"].get("0")
if not price:
continue
total += 1
active += 1 if price.get("active") else 0
ml = book.get("markets", {}).get("141")
if ml:
legs = {o: p for o, out in ml["outcomes"].items()
if (p := out["players"].get("0")) and p.get("active") and p.get("price")}
if len(legs) == 2:
moneylines[slug] = (legs["141"]["price"], legs["142"]["price"])
return {"books": len(books), "market_ids": len(market_ids),
"active_pct": 100 * active / total if total else 0,
"independent": len(set(moneylines.values()))} # dedupe on the price tuple
Note the dedupe on the last line. Several distinct slugs ship byte-identical prices while the catalogue reports cloneOf: null, so raw book counts always overstate. On the Week 1 opener, 150 complete quotes collapse to 57 independent prices.
The board horizon is about 12 weeks
Weeks 14 to 18 return zero bookmakers even though every fixture reports hasOdds: true. Week 13 returns two books on one fixture and nothing on the other. If you are backfilling a season, there is no point polling past roughly 90 days out.
Pinnacle prices exactly one week of the season
The sharp benchmark is the thing most models calibrate against, so its coverage matters more than any other book's. Test presence properly by passing a single slug and reading the status code: 403 means your key cannot see the book, 200 with no data means the book genuinely has not priced it.
def prices_it(fixture_id, slug="pinnacle"):
"""403 = gated. 200 with no payload = genuinely unpriced."""
status, data = get("odds", fixtureId=fixture_id, bookmakers=slug)
if status == 403:
return "gated"
if status != 200:
return f"http {status}"
return "priced" if data.get("bookmakerOdds", {}).get(slug) else "no data"
| Week | Pinnacle result |
|---|---|
| 1 | priced, 5 of 5 |
| 2 | no data, 0 of 5 |
| 3 | no data, 0 of 5 |
| 4 | no data, 0 of 5 |
| 5 | no data, 0 of 5 |
Not gated. Unpriced. Pinnacle opens the NFL one week at a time, the same way European books open a football league one round at a time. The other reference venues run slightly deeper and then stop too: Kalshi through Week 2, Polymarket through Week 4, DraftKings through Week 7.
The practical consequence: any model that grades itself against a sharp line can only do so on the current week. If you want a sharp benchmark on Week 6, you have to wait until Week 6 is roughly a week away, or use the closing lines of past weeks from /historical-odds instead.
The finding I did not expect: London games are three times deeper
Book counts inside a single week are not flat. In Week 6, one fixture carried 58 books while the rest of that Sunday carried 17 or 18. Same day, same time-to-kickoff, three times the board.
The deep fixture kicks off at 09:30 ET. There are exactly six such fixtures in the 272, and they are the NFL's international games.
| Sunday | 09:30 ET international game | Same-day domestic median | Ratio |
|---|---|---|---|
| 18 Oct | 58 books | 18 books | 3.2x |
| 15 Nov | 49 books | 21 books | 2.3x |
The extra books are not random. Diffing the book sets for 18 October, the 42 slugs that appear on the international game and not on that week's domestic fixture are overwhelmingly European and UK brands: bet365 plus seven regional skins, 888sport plus four, betano plus five, paddypower, unibet.fr, codere.es, sportwetten.de, pamestoixima.gr, mrgreen, tiptorro, alongside draftkings, fanduel and thescore.
That is a European book base pricing a game that kicks off in a European afternoon. It is the single best time of the NFL season to run cross-market work, because you get a European board and a US board on the same fixture. Six games a year.
def kickoff_et(f):
"""NFL prime-time games land on the next calendar day in UTC. Convert first."""
utc = datetime.fromisoformat(f["startTime"].replace("Z", "+00:00"))
offset = 4 if utc < datetime(2026, 11, 1, tzinfo=timezone.utc) else 5 # EDT then EST
return datetime.fromtimestamp(utc.timestamp() - offset * 3600, timezone.utc)
international = [f for f in fixtures.values()
if kickoff_et(f).strftime("%a") == "Sun" and kickoff_et(f).hour < 12]
print(len(international), "international kickoffs") # 6
Do the timezone conversion before you group anything. In UTC, twenty of the 272 fixtures land on a Friday, and seventeen of those are Thursday night games in Eastern time. Group by the UTC date and you invent a Friday slate that does not exist.
When did each book actually open? Mind the 100-day wall
/historical-odds gives you every book's first snapshot for free, which looks like a clean way to measure opening times. It is, but only inside a window.
On the Week 1 opener, DraftKings, Pinnacle, Caesars and Circa all show a first snapshot at exactly T-100.0 days, within minutes of each other. On a Week 6 fixture with a completely different kick-off date, DraftKings, bet365 and Caesars again land on exactly T-100.0 days. A third and fourth fixture reproduce it.
Independent books do not agree to the minute by chance. T-100d is where price collection starts, not where the books opened. Any book sitting exactly on that boundary was already pricing the game before the history begins, so treat it as censored rather than as an opening time.
Books that first appear inside the window are real observations:
| Book | First quote, Week 1 opener | Snapshots | Price changes |
|---|---|---|---|
bet365 |
T-97.3d | 851 | 529 |
888sport |
T-92.1d | 25 | 1 |
betano |
T-38.1d | 102 | 4 |
fanduel |
T-32.0d | 120 | 5 |
paddypower |
T-32.0d | 15 | 5 |
caesars |
T-100.0d (censored) | 3,457 | 6 |
Count price changes, not snapshots. Caesars logged 3,457 snapshots and moved its price six times. bet365 logged a quarter as many snapshots and moved 529 times. The feed records on a cadence, and most snapshots simply repeat the previous price, so snapshot volume measures the recorder rather than the market.
def opening(fixture_id, slugs):
"""Max 3 bookmakers per call. A batch containing one unreadable slug
returns nothing at all, so retry a failed batch one slug at a time."""
status, data = get("historical-odds", fixtureId=fixture_id,
bookmakers=",".join(slugs[:3]))
if status != 200:
return {}
out = {}
for slug, book in data.get("bookmakers", {}).items():
ml = book.get("markets", {}).get("141")
if not ml:
continue
# players["0"] is a LIST here, not a dict. Historical shape != live shape.
snaps = ml["outcomes"]["141"]["players"]["0"]
changes = sum(1 for a, b in zip(snaps, snaps[1:]) if a["price"] != b["price"])
out[slug] = {"first": snaps[0]["createdAt"], "snapshots": len(snaps),
"changes": changes}
return out
Two more limits worth knowing before you loop this. /historical-odds takes a maximum of three bookmakers per call and cooldown is around 4.5 seconds, not the ~1 second that /odds wants. And exchange history is enormous: one NFL fixture filtered to kalshi alone came back at 259 MB. Never loop an exchange across a slate.
Old way vs OddsPapi
| Scraping the schedule and books | OddsPapi | |
|---|---|---|
| Full-season schedule | Separate source, separate IDs to reconcile | 20 calls, 272 fixtures, one ID space |
| Board depth per fixture | One scraper per book | Up to 167 books in one JSON call |
| Opening lines | Only if you started recording early | Free history back to the collection window |
| Suspended vs live | Rendered identically on the site | Explicit active flag per price |
| Sharp benchmark | Pinnacle account required | pinnacle in the same payload |
What to do with this
- Poll by horizon, not by fixture count. Past ~90 days out there is nothing to fetch. Between 90 and 20 days out you are polling 8 to 60 books. Inside two weeks the board explodes.
- Re-pull weekly and merge on
fixtureId. The season grew from 132 fixtures in mid-August to all 272 now. - Do not calibrate against Pinnacle beyond the current week. It is not there.
- Put the six international games in your calendar. They carry two to three times the board of any other regular-season fixture.
- Never report an opening time of exactly 100 days. That is the wall, not the book.
Where to go next
- NFL schedule API — the season pull itself, in more detail.
- NFL alternate lines API — what those 1,679 Week 1 market IDs actually contain.
- NFL prediction market liquidity — the depth behind the exchange prices on the same board.
- NFL odds API guide — lines, spreads and totals from scratch.
- Kalshi & Polymarket NFL odds — how far into the season each prediction market quotes.
- How many bookmakers does your backtest need? — why 8 books and 167 books give different answers.
- Build an odds database in SQLite — where a weekly re-pull should land.
- Expected value and CLV in Python — grading against a closing line you can actually get.
- College football odds API — the same measurement on a 3,500-fixture board.
- Dynamic odds — tracking movement once a line is open.
Poll the board, not the flag
hasOdds: true is a flag on 272 NFL fixtures and a real board on about 190 of them. The difference is one /odds call and one active filter, across 350+ bookmakers including Pinnacle, Circa, bet365, DraftKings and Kalshi, with free historical odds behind every one of them.
Get your free API key and run the season walk before Week 1 kicks off.