Historical Odds API: Rebuild a Closing Line in 65 KB Calls

Historical Odds API
How To Guides October 27, 2026

If you search for a historical odds API, you want timestamped prices on played fixtures, from more than one bookmaker, without a sales call. OddsPapi’s /v4/historical-odds endpoint returns every snapshot the feed recorded on a fixture, for up to three bookmakers per call, on the free plan. On 21 October 2026, between 13:14 and 13:23 UTC, we pulled 42 played fixtures across the NFL, the Premier League, the NBA, the NHL and the MLB postseason, and rebuilt the moneyline close three ways on each one.

Where you cut the record decides what you call the close. Cut at the scheduled kick-off and Pinnacle’s NFL moneyline closes at a 2.60% margin. Cut at the recorded actual start and the same fixtures read 3.27%. Take the last active row and they read 3.85%. On the Premier League the actual-start cut reads 5.20% against 2.98%, because the record keeps running into the match. This guide shows the three cuts, what one call holds in each sport, the one-outcome filter that turns a 97 MB three-book payload into 340 KB (65 KB for one book), and the cache header that makes a re-read cost nothing.

Why an archive you start today starts today

If you record odds yourself, your history begins the day your poller goes live, and it covers the books you scrape. A missed hour is a hole you cannot fill. A bookmaker that changes its page layout is a week of gaps. And a closing line from one book tells you what that book thought; the market is the average of many.

OddsPapi has been recording every priced fixture it lists, across 350+ bookmakers, and the endpoint hands the record back as JSON. One fixture ID, one to three bookmaker slugs, and you get every price row the feed saw, each with createdAt, price, limit and an active flag. The same schema serves /v4/odds for live prices and the WebSocket feed for pushed updates, so the parser you write for history reads the live board too.

Record it yourself OddsPapi
Starts The day your poller goes live Already recorded on every priced fixture
Bookmakers The sites you scrape 350+ slugs under one schema, three per history call
Markets What your parser understands Every market the book posted: 829 DraftKings market IDs on one NFL fixture
Closing line Your last poll before kick-off Every snapshot, so you choose the cut and keep the limit
Re-reads Your disk ETag and a 304 on finished fixtures
Cost Your weekends Free plan, 250 requests a month

Step 1: find the played fixtures and read both clocks

Authentication is one query parameter, apiKey. The helper waits out a 429 for as long as the error body says, backs off on a 5xx, and returns None on a 404. Finished fixtures report hasOdds: false; that flag describes the live board, and the history is still there.

Every fixture carries two clocks. startTime is the scheduled kick-off. trueStartTime is the start the feed recorded, which lands after the scheduled time by an amount that depends on the sport. Step 3 shows why the difference matters.

import os, time, requests
from datetime import datetime, timezone

API_KEY = os.environ["ODDSPAPI_API_KEY"]
BASE = "https://api.oddspapi.io/v4"

def call(path, headers=None, **params):
    """One GET against v4. Waits out a 429 for as long as the body says."""
    params["apiKey"] = API_KEY
    for _ in range(8):
        r = requests.get(f"{BASE}/{path}", params=params, headers=headers or {}, timeout=180)
        if r.status_code == 429:
            time.sleep(r.json().get("error", {}).get("retryMs", 2000) / 1000 + 0.5)
            continue
        if r.status_code >= 500:
            time.sleep(2)
            continue
        return r
    raise RuntimeError(f"gave up on {path}")

def get(path, **params):
    """Parsed JSON, or None on a 404 (an empty window, or no history yet)."""
    r = call(path, **params)
    if r.status_code == 404:
        return None
    r.raise_for_status()
    return r.json()

def ts(s):
    return datetime.fromisoformat(s.replace("Z", "+00:00"))

# NFL is tournamentId 31. Finished fixtures report hasOdds: false; the history is still there.
played = get("fixtures", tournamentId=31, **{"from": "2026-10-16", "to": "2026-10-21"}) or []
played = [f for f in played if f["statusName"] == "Finished"]
print(f"{len(played)} finished NFL fixtures, 16 to 20 October 2026\n")
for f in sorted(played, key=lambda f: f["startTime"]):
    gap = (ts(f["trueStartTime"]) - ts(f["startTime"])).total_seconds() / 60
    name = f'{f["participant1Name"]} v {f["participant2Name"]}'[:46]
    print(f'{f["fixtureId"]}  {f["startTime"][:16]}Z  actual start {gap:+5.1f} min  '
          f'hasOdds={f["hasOdds"]}  {name}')
14 finished NFL fixtures, 16 to 20 October 2026

id1400003171515900  2026-10-16T00:15Z  actual start  +1.0 min  hasOdds=False  Denver Broncos v Seattle Seahawks
id1400003171488626  2026-10-18T13:30Z  actual start  +3.6 min  hasOdds=False  Jacksonville Jaguars v Houston Texans
id1400003171515902  2026-10-18T17:00Z  actual start  +3.5 min  hasOdds=False  Atlanta Falcons v Chicago Bears
id1400003171515904  2026-10-18T17:00Z  actual start  +4.4 min  hasOdds=False  Cleveland Browns v Baltimore Ravens
id1400003171515906  2026-10-18T17:00Z  actual start  +3.5 min  hasOdds=False  Indianapolis Colts v Tennessee Titans
id1400003171515908  2026-10-18T17:00Z  actual start  +4.4 min  hasOdds=False  New England Patriots v New York Jets
id1400003171515910  2026-10-18T17:00Z  actual start  +1.5 min  hasOdds=False  New York Giants v New Orleans Saints
id1400003171515912  2026-10-18T17:00Z  actual start  +3.5 min  hasOdds=False  Philadelphia Eagles v Carolina Panthers
id1400003171515914  2026-10-18T17:00Z  actual start  +3.5 min  hasOdds=False  Tampa Bay Buccaneers v Pittsburgh Steelers
id1400003171515916  2026-10-18T20:05Z  actual start  -1.3 min  hasOdds=False  Los Angeles Rams v Arizona Cardinals
id1400003171515918  2026-10-18T20:25Z  actual start  +0.6 min  hasOdds=False  Kansas City Chiefs v Los Angeles Chargers
id1400003171515920  2026-10-18T20:25Z  actual start  +0.6 min  hasOdds=False  Las Vegas Raiders v Buffalo Bills
id1400003171515922  2026-10-19T00:20Z  actual start  +2.0 min  hasOdds=False  Green Bay Packers v Dallas Cowboys
id1400003171515924  2026-10-20T00:15Z  actual start  +1.4 min  hasOdds=False  San Francisco 49ers v Washington Commanders

Across the five sports we read, the gap between the two clocks ran -1.3 to 4.4 minutes in the NFL, 0.5 to 3.6 in the Premier League, 8.3 to 13.0 in the NHL (the anthem and the warm-up), 0.9 and 18.0 on the two NBA openers, and under a minute on three of four MLB postseason games. Pull tournamentId from /v4/tournaments once; our moneyline odds API guide lists the Winner market ID for every sport, which you need for the outcomeId filter below.

Step 2: what one call holds, and how to make it small

A full call returns every market the book posted on the fixture. That is the point of the endpoint, and it is also why you filter. This step reads one played NFL fixture four ways and prints the payload size and the snapshot count behind it.

# Reuses call(), get() and ts() from Step 1.
FIXTURE = "id1400003171488626"      # Jaguars v Texans, 18 Oct 2026, played in London

def shape(r):
    """Markets, legs and snapshots per book in one /historical-odds response."""
    out = {}
    for slug, book in r.json()["bookmakers"].items():
        legs = snaps = 0
        for market in book["markets"].values():
            for outcome in market["outcomes"].values():
                for rows in outcome["players"].values():
                    legs += 1
                    snaps += len(rows)
        out[slug] = (len(book["markets"]), legs, snaps)
    return out

probes = [
    ("pinnacle, every market",        dict(bookmakers="pinnacle")),
    ("three books, every market",     dict(bookmakers="pinnacle,draftkings,bet365")),
    ("three books, outcomeId=141",    dict(bookmakers="pinnacle,draftkings,bet365", outcomeId=141)),
    ("same, active=true only",        dict(bookmakers="pinnacle,draftkings,bet365", outcomeId=141, active="true")),
]
for label, params in probes:
    r = call("historical-odds", fixtureId=FIXTURE, **params)
    time.sleep(5.0)                         # documented cooldown on /historical-odds is 5 s
    mb = len(r.content) / 1e6
    detail = "  ".join(f"{s}: {m} markets / {l} legs / {n:,} snapshots" for s, (m, l, n) in shape(r).items())
    print(f"{label:<30} {mb:7.2f} MB   {detail}")
pinnacle, every market            4.24 MB   pinnacle: 265 markets / 562 legs / 40,659 snapshots
three books, every market        96.66 MB   bet365: 400 markets / 1299 legs / 48,991 snapshots  draftkings: 829 markets / 3311 legs / 860,193 snapshots  pinnacle: 265 markets / 562 legs / 40,659 snapshots
three books, outcomeId=141        0.34 MB   bet365: 1 markets / 1 legs / 649 snapshots  draftkings: 1 markets / 1 legs / 2,003 snapshots  pinnacle: 1 markets / 1 legs / 664 snapshots
same, active=true only            0.24 MB   bet365: 1 markets / 1 legs / 356 snapshots  draftkings: 1 markets / 1 legs / 1,449 snapshots  pinnacle: 1 markets / 1 legs / 504 snapshots

Two parameters change the shape. outcomeId keeps one outcome of one market and drops the rest, which took the three-book payload from 97 MB to 340 KB in our run, and a single-book call to 65 KB. active=true drops the suspended rows on top of that. The docs list both, along with playerId for a single player prop. marketId is not a filter on this endpoint; pass it and you get the full payload back. One call per leg is the cheap way to read a closing line, and three bookmakers per call still applies with the filter on.

The full payload per sport, Pinnacle alone, on the first fixture we read in each:

Fixture (played) Pinnacle market IDs Legs Snapshots Payload
NFL, Broncos v Seahawks, 16 Oct 2026 266 626 33,763 3.5 MB
Premier League, Everton v Chelsea, 17 Oct 157 408 53,834 5.6 MB
NBA, Pistons v Celtics, 20 Oct 500 1,076 90,009 9.3 MB
NHL, Flames v Hurricanes, 19 Oct 99 265 16,874 1.7 MB
MLB, Dodgers v Brewers, 16 Oct 162 437 28,682 3.0 MB

On the Jaguars fixture above, DraftKings alone held 829 market IDs and 860,193 snapshots; on the Broncos fixture in the table it held 757 and 578,058, and bet365 held 434 market IDs. A snapshot is an observation, so the feed writes a row whether or not the price moved; collapse consecutive identical prices before you count line movement, as our bet365 historical odds guide measures book by book.

Step 3: rebuild the closing line three ways

The closing line is the last price a book took before the market went in-play. The record gives you three candidates for it: the last active row before startTime, the last active row before trueStartTime, and the last active row of all. This step pulls both legs of the NFL moneyline for 14 played fixtures, three bookmakers per call, and prints the median margin under each cut, with the day each book first quoted the market.

# Reuses call(), get(), ts() and `played` from Step 1.
from statistics import median
BOOKS = "pinnacle,draftkings,bet365"
MONEYLINE = [141, 142]                   # NFL Winner: home, away

def legs_for(fixture_id, outcome_id):
    """Snapshots per book for one outcome, oldest first."""
    hist = get("historical-odds", fixtureId=fixture_id, bookmakers=BOOKS, outcomeId=outcome_id)
    time.sleep(5.0)                    # documented cooldown is 5 s
    out = {}
    for slug, book in (hist or {}).get("bookmakers", {}).items():
        for market in book["markets"].values():
            for outcome in market["outcomes"].values():
                out[slug] = sorted(outcome["players"]["0"], key=lambda s: s["createdAt"])
    return out

def price_at(snaps, cutoff=None):
    """Last active price before cutoff; the last active row of all when cutoff is None."""
    rows = [s for s in snaps if s["active"] and (cutoff is None or s["createdAt"] < cutoff)]
    return rows[-1]["price"] if rows else None

def margin(prices):
    return (sum(1 / p for p in prices) - 1) * 100

cuts = {"scheduled startTime": [], "actual trueStartTime": [], "last active row": []}
stats = {slug: {k: [] for k in cuts} | {"opened_days_out": []} for slug in BOOKS.split(",")}
for f in sorted(played, key=lambda f: f["startTime"]):
    legs = [legs_for(f["fixtureId"], oid) for oid in MONEYLINE]
    for slug in stats:
        if not all(slug in leg for leg in legs):
            continue
        for label, cutoff in [("scheduled startTime", f["startTime"]),
                              ("actual trueStartTime", f["trueStartTime"]),
                              ("last active row", None)]:
            prices = [price_at(leg[slug], cutoff) for leg in legs]
            if all(prices):
                stats[slug][label].append(margin(prices))
        first = min(leg[slug][0]["createdAt"] for leg in legs)
        stats[slug]["opened_days_out"].append((ts(f["startTime"]) - ts(first)).total_seconds() / 86400)

print(f"{len(played)} NFL fixtures, moneyline margin by closing-line cut (median)\n")
print(f"{'book':<12}{'opened':>10}{'startTime':>12}{'trueStart':>12}{'last row':>12}")
for slug, s in stats.items():
    print(f"{slug:<12}{median(s['opened_days_out']):>8.1f}d "
          + "".join(f"{median(s[k]):>11.2f}%" for k in cuts))
14 NFL fixtures, moneyline margin by closing-line cut (median)

book            opened   startTime   trueStart    last row
pinnacle         6.6d        2.60%       3.27%       3.85%
draftkings      37.8d        4.27%       5.61%       4.84%
bet365          11.6d        6.13%       6.24%       6.25%

Use startTime. The scheduled kick-off is when the books take the pre-match market down, so the last active row before it is the close. The recorded actual start lands a few minutes later, and in those minutes the feed records the first in-play prices. On the Jaguars v Texans fixture, Pinnacle’s last active row before startTime was 1.746 / 2.21, a 2.52% margin; its last active row before trueStartTime was 1.632 / 2.30, a 4.75% in-play margin on a game already under way. The actual-start cut changed Pinnacle’s close on 10 of the 14 NFL fixtures and on 10 of 10 in the Premier League.

The last active row is worse. On Eagles v Panthers, DraftKings’ final active moneyline was 48.0 / 1.079, the price it posted as the game ended; the margin on that pair is negative, because the market had settled. On 13 of the 42 fixtures we read, DraftKings’ last active row carries a margin under 1%, which is a settled price in every case. We ran the three cuts on all 42 fixtures:

Sport (played, 16 to 20 Oct 2026) Fixtures Book First quote Close at startTime Close at trueStartTime Last active row
NFL 14 pinnacle 6.6 days out 2.60% 3.27% 3.85%
draftkings 37.8 days 4.27% 5.61% 4.84%
bet365 11.6 days 6.13% 6.24% 6.25%
Premier League 10 pinnacle 26.8 days 2.98% 5.20% 4.52%
draftkings 26.5 days 4.82% 5.79% 3.07%
bet365 28.3 days 6.06% 6.06% 6.06%
NHL 12 pinnacle 1.1 days 2.60% 2.60% 3.88%
draftkings 1.3 days 4.27% 4.27% 3.81%
bet365 1.0 days 6.94% 6.47% 5.68%
MLB postseason 4 pinnacle 0.8 days 2.01% 2.01% 4.33%
draftkings 0.8 days 2.76% 2.84% 3.72%
bet365 0.5 days 6.35% 6.35% 5.71%
NBA opening night 2 pinnacle 22.8 days 2.31% 2.31% 3.49%
draftkings 69.4 days 4.40% 4.40% 0.99%
bet365 69.2 days 7.80% 7.40% 4.60%

Median margin on the moneyline, two-way markets, three-way 1X2 in the Premier League. Read the table in rows. Under the startTime cut Pinnacle closes between 2.01% and 2.98% in every sport, DraftKings between 2.76% and 4.82%, and bet365 between 6.06% and 7.80%, which is the order you expect. Under the other two cuts the order breaks: DraftKings “closes” the Premier League at 3.07% and the NBA at 0.99% because its last row is a settled price, and bet365 “tightens” in the NHL because its last row is an in-play price on a game already decided. The NHL rows match across the first two cuts because the puck drops 8 to 13 minutes after the scheduled time and the books keep the pre-match market up through the anthem; the two cuts differ by a tick or two on six of the twelve fixtures. In the NFL and the Premier League the books flip the market at the scheduled time, so the minutes before the recorded start are already in-play.

The first-quote column is the day the book first put a price on the moneyline, read from the oldest row in the record. Pinnacle posts the NFL moneyline a week out and DraftKings five weeks out; both post the Premier League about four weeks out; the NHL and the MLB postseason open the day before. Our study of when NFL odds open measured the whole 272-fixture season on the same field. The record itself starts 100 days before kick-off, so a first row at exactly 100 days is the edge of the window, not an opening price.

One book’s close is one opinion. Dedupe and average the board before you call it the market, as our consensus odds guide does, and read our 176-book study for how many slugs a backtest needs before the consensus stops moving.

Step 4: re-read a finished fixture for free

A finished fixture’s history does not change, so the endpoint returns an ETag on it and honours If-None-Match. Send the tag back and an unchanged fixture comes back as a 304 with an empty body. Cache the first payload on disk, and your backtest re-runs without re-downloading the record.

# Reuses call() from Step 1.
FIXTURE = "id1400003171515900"      # Broncos v Seahawks, 16 Oct 2026
params = dict(fixtureId=FIXTURE, bookmakers="pinnacle", outcomeId=141)

first = call("historical-odds", **params)
etag = first.headers.get("ETag")
print(f"first call   {first.status_code}  {len(first.content):,} bytes  "
      f"ETag {etag}  Cache-Control: {first.headers.get('Cache-Control')}")

time.sleep(5.0)                                 # a 304 still counts against the cooldown
again = call("historical-odds", headers={"If-None-Match": etag}, **params)
print(f"second call  {again.status_code}  {len(again.content):,} bytes  ETag {again.headers.get('ETag')}")
first call   200  64,873 bytes  ETag "1a3147db31013ab5"  Cache-Control: private, max-age=259200
second call  304  0 bytes  ETag "1a3147db31013ab5"

The documented cooldown on this endpoint is 5 seconds between calls, and a 304 counts against it, so the tag saves bandwidth and parse time rather than quota. Cache-Control reads private, max-age=259200, three days, so a cache you keep in a shared layer should respect that. Live and upcoming fixtures send no tag, because their record is still growing. If you store the payloads, our SQLite odds database guide has the schema, and the CSV and Excel export guide flattens the nested JSON.

How far back the record goes

The collection is a rolling window, and a fixture past its edge returns 404 NOT_FOUND with the message No historical odds found for every slug you try. Before you scope a backtest, walk /v4/fixtures backwards with tournamentId and one cheap outcomeId call per matchday until the first 404; that is your window for that competition. Depth inside the window varies by fixture too, so probe the exact fixtures your model needs rather than planning around one number.

Each fixture carries up to 100 days of pre-match record, and a fixture that sat inside the window’s edge while it was being recorded has a record that starts at the edge. In both cases the first row is a window boundary; read the first quote from the book’s own first price only when it lands inside the 100 days, as the first-quote column in Step 3 does. Our backtesting guide turns the closes from Step 3 into yield, ROI and drawdown.

Historical odds API FAQ

Is there a free historical odds API?

Yes. OddsPapi’s /v4/historical-odds serves on the free plan, which includes 250 requests a month. Pass a fixture ID and up to three bookmaker slugs, and the response holds every timestamped price the feed recorded on that fixture, with the stake limit where the book publishes one.

How many bookmakers can one historical odds call return?

Three. A fourth slug returns 400 TOO_MANY_BOOKMAKERS, and a batch that includes a slug outside your plan returns nothing for the whole batch, so retry a failed batch one slug at a time. The live /v4/odds endpoint has no such cap.

How do I get the closing line from historical odds?

Take the last row with active: true whose createdAt is before the fixture’s scheduled startTime. The record keeps running into the match, so the last row of all is an in-play or settled price, and a cut at the recorded actual start can include the first in-play prices. On 14 NFL fixtures the scheduled-time cut gave Pinnacle a 2.60% median margin; the last-row cut gave 3.85%.

Can I filter the historical odds API to one market?

Use outcomeId, one call per outcome. It cut a three-book NFL payload from 97 MB to 340 KB in our test, and a one-book call to 65 KB. active=true drops suspended rows and playerId isolates one player prop. marketId is not a filter on this endpoint and returns the full payload.

How far back does the historical odds data go?

Each fixture holds up to 100 days of pre-match record, and the collection as a whole is a rolling window that moves forward with the calendar. Probe the competition you need with one cheap outcomeId call per matchday until the first 404, and scope the backtest to the fixtures that return.

Rebuild your own closes from one key

Every call in this guide runs on the free key: 350+ bookmakers under one schema, free historical odds on every priced fixture, native market structures from the moneyline to 829 DraftKings market IDs, and a WebSocket feed when you move from history to the live board. Get a free API key and run Step 3 on the competition you model.