When Do NFL Odds Open? A 272-Fixture Study of the Board

When Do NFL Odds Open - OddsPapi API Blog
How To Guides September 8, 2026

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

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.