Football Data API: Grade Your Model on 250 Labelled Fixtures
A football model needs two things: prices to learn from and results to learn against. Most free feeds hand you one half. The stats feeds carry results and refresh their odds once a day. The odds feeds carry prices and drop the fixture at the final whistle.
OddsPapi carries both on the free tier. /v4/historical-odds replays every price snapshot a bookmaker posted, and /v4/scores returns the finished score that labels it. This study runs them together over 250 finished fixtures from the top five European tournaments, played between 1 August and 21 September 2026 and measured on 22 September.
The headline: all 250 fixtures returned a full-time score, and the de-vigged Pinnacle closing line named the winner on 52.2% of the 249 with a complete closing three-way. Its Brier score of 0.5778 is the number your model has to beat. If you have never run a backtest against price history, start with the walkthrough and come back when your pipeline runs.
Why “free football data API” usually means half a dataset
Search that phrase and you meet two kinds of product. One sells fixtures, lineups and results, then bolts on a pre-match odds field that updates daily. The other sells live prices from a handful of books and forgets the fixture once it finishes. Train on the first and your features are stale. Train on the second and you have nothing to train against.
The label is the expensive half. You can approximate a price from a competitor. You cannot approximate whether Arsenal won.
| What a model needs | Where it comes from | Measured on this sample |
|---|---|---|
| Full-time result label | /v4/scores, periods.result |
250 of 250 fixtures |
| Half-time result label | /v4/scores, periods.p1 |
250 of 250 fixtures |
| Price feature | /v4/historical-odds, market 101 |
249 complete closing three-ways |
| Closing price | Last active snapshot before kick-off | Median 32 seconds before kick-off |
| Margin to strip | Sum of the implied probabilities | Median 2.89% on Pinnacle |
| Stake size behind the price | limit on each snapshot |
Median 10,000 at the close |
Step 1: pull the finished fixtures
Filter /v4/fixtures on tournamentId. The filter is undocumented, it works, and it lifts the ten-day range cap that sportId alone runs into. Premier League is 17, LaLiga 8, Serie A 23, Bundesliga 35 and Ligue 1 34. Resolve those IDs on name plus category if you build the list yourself, because 35 tournaments in the catalogue are called “Premier League”.
import os, time, requests
API_KEY = os.environ["ODDSPAPI_API_KEY"]
BASE = "https://api.oddspapi.io/v4"
TOURNAMENTS = {17: "Premier League", 8: "LaLiga", 23: "Serie A",
35: "Bundesliga", 34: "Ligue 1"}
def get(path, **params):
"""One call, with the 429 backoff the API asks for."""
params["apiKey"] = API_KEY
for attempt in range(5):
r = requests.get(f"{BASE}/{path}", params=params, timeout=180)
if r.status_code == 429:
time.sleep(float(r.json()["error"].get("retryMs", 3000)) / 1000 + 1)
continue
r.raise_for_status()
return r.json()
raise RuntimeError(f"rate limited on /{path}")
def finished_fixtures(tournament_id, start, end):
rows = get("fixtures", sportId=10, tournamentId=tournament_id,
**{"from": start, "to": end})
return [f for f in rows if f["statusName"] == "Finished"]
fixtures = []
for tid, name in TOURNAMENTS.items():
rows = finished_fixtures(tid, "2026-08-01", "2026-09-22")
print(f"{name}: {len(rows)} finished")
fixtures += rows
time.sleep(1.3)
Premier League: 50 finished
LaLiga: 69 finished
Serie A: 50 finished
Bundesliga: 36 finished
Ligue 1: 45 finished
Set to to the day after the last day you want. It is a midnight-UTC instant, so a same-day query returns only the fixtures that start at 00:00Z. Finished fixtures come back with hasOdds: false, because the live board is gone. Their price history stays queryable, which is the reason this study runs on a free key at all. For the live side of the same endpoint, see the soccer fixtures guide.
The get helper earns its place on the first run. A 1.1-second gap between /fixtures calls still drew a 429 on the second tournament, and the error body carries a retryMs that tells you exactly how long to wait.
Step 2: label every fixture from /v4/scores
The scores endpoint nests under scores.periods, not a top-level periods key. Soccer returns three keys: p1 holds the half-time score, fulltime the ninety-minute score, and result the final score. On league football the last two agree. On a tie that goes to extra time they diverge, so read result for the settled outcome and fulltime for the ninety-minute one.
def label(fixture_id):
periods = get("scores", fixtureId=fixture_id)["scores"]["periods"]
full = periods["result"]
home, away = full["participant1Score"], full["participant2Score"]
half = periods["p1"]
return {
"full": "H" if home > away else ("A" if away > home else "D"),
"half": "H" if half["participant1Score"] > half["participant2Score"]
else ("A" if half["participant2Score"] > half["participant1Score"] else "D"),
"score": (home, away),
}
print(label(fixtures[0]["fixtureId"]))
{'full': 'H', 'half': 'H', 'score': (3, 0)}
All 250 fixtures returned a score, with no retries and no errors. The outcome split came out 43.2% home, 24.8% draw, 32.0% away. That split is your first baseline. A model that predicts the season base rate every week scores a Brier of 0.6486, and anything worse than that carries no information.
| Tournament | Fixtures | Home | Draw | Away |
|---|---|---|---|---|
| Premier League | 50 | 36.0% | 32.0% | 32.0% |
| LaLiga | 69 | 44.9% | 24.6% | 30.4% |
| Serie A | 50 | 44.0% | 18.0% | 38.0% |
| Bundesliga | 36 | 52.8% | 19.4% | 27.8% |
| Ligue 1 | 45 | 40.0% | 28.9% | 31.1% |
Six weeks is a small window, and the spread across tournaments shows it. Bundesliga home sides won 52.8% and Premier League home sides won 36.0%. Pull a full season before you treat any of those five rows as a prior.
The half-time key earns its place too. Someone led at the break in 149 of the 250 fixtures, and that side failed to win 40 of them, 26.8%. Train a half-time model on p1 and grade it on result, and the same two calls give you a second dataset.
Step 3: pull the closing price as your feature
/v4/historical-odds takes three bookmaker slugs per call and returns every snapshot each one posted. This study reads Pinnacle alone, because Pinnacle is the sharpest anchor in the top tier and its closing line is the standard benchmark. A wider consensus shifts the number: a 3-book closing line sits 0.67 percentage points from a 176-book one, measured in the book-count study.
Two traps live in this call. The historical endpoint’s top-level key is bookmakers, where the live endpoint uses bookmakerOdds. And players["0"] holds a list of snapshots here, where the live endpoint makes it a dict.
LEGS = {"101": "H", "102": "D", "103": "A"} # market 101 = Full Time Result
def closing_1x2(fixture_id, start_time, book="pinnacle"):
data = get("historical-odds", fixtureId=fixture_id, bookmakers=book)
market = data["bookmakers"][book]["markets"].get("101")
if not market:
return None
prices = {}
for outcome_id, leg in LEGS.items():
snaps = market["outcomes"][outcome_id]["players"]["0"]
pre = [s for s in snaps if s["createdAt"] < start_time and s["active"]]
if not pre:
return None
prices[leg] = max(pre, key=lambda s: s["createdAt"])["price"]
return prices
f = fixtures[0]
print(f["participant1Name"], "v", f["participant2Name"], f["startTime"])
print(closing_1x2(f["fixtureId"], f["startTime"]))
Arsenal FC v Coventry City 2026-08-21T19:00:00.000Z
{'H': 1.21, 'D': 7.15, 'A': 16.0}
Both filters matter. Snapshots keep arriving after kick-off, and a suspended price sits in the list with its last number and an active flag of false. Skip the filters and you grade your model against an in-play price. Across the 249 fixtures with a complete closing three-way, the last active snapshot landed a median 32 seconds before kick-off, with the tenth percentile at 7 seconds and the ninetieth at 195.
One fixture of the 250 returned 404 from /historical-odds. Catch the error and move on rather than letting it kill the loop.
Step 4: strip the margin
The three closing prices imply more than 100% probability. The excess is the bookmaker’s margin, and it has to come out before the number means anything. Proportional de-vig divides each implied probability by the total. The power method raises each one to a common exponent, which takes more weight off the longshot.
def fair_proportional(prices):
implied = {leg: 1 / price for leg, price in prices.items()}
total = sum(implied.values())
return {leg: p / total for leg, p in implied.items()}, total - 1
def brier(probs, actual):
return sum((probs[leg] - (1 if actual == leg else 0)) ** 2 for leg in "HDA")
prices = closing_1x2(f["fixtureId"], f["startTime"])
fair, overround = fair_proportional(prices)
print({k: round(v, 4) for k, v in fair.items()}, f"overround {overround:.2%}")
{'H': 0.8033, 'D': 0.1359, 'A': 0.0608} overround 2.88%
Pinnacle’s median closing overround across the sample was 2.89%, inside a range of 2.26% to 5.14%. Pinnacle also publishes a stake limit on every snapshot, and the median closing limit was 10,000. Most books leave that field null, so check it before you do arithmetic on it.
What the closing line scores
Run the loop over all 249 fixtures and grade the de-vigged closing probabilities against the labels. Lower is better on both scores.
| Predictor | Brier score | Log loss | Favourite correct |
|---|---|---|---|
| Season base rate (43.2 / 24.8 / 32.0) | 0.6486 | 1.0717 | 43.4% |
| Pinnacle opening line | 0.5875 | 0.9867 | 50.2% |
| Pinnacle closing line | 0.5778 | 0.9710 | 52.2% |
| Closing line, power de-vig | 0.5776 | 0.9701 | 52.2% |
The closing line beat the opening line by 1.7% on Brier and 1.6% on log loss, and it converted five more favourites over the same 249 fixtures. Weeks of money moved the price that far. The gap between the base rate and the close is larger: 10.9% on Brier. That gap is the whole value of knowing anything about the fixture, and your model has to clear it before it clears the margin.
The two de-vig methods finished within 0.0002 of each other on Brier. Pick either one and stay consistent. The choice matters more on three-way markets with a wide margin, where the tie leg moves by more than a point between methods.
The closing line is well calibrated
Bucket every de-vigged probability and compare it against how often that outcome happened. Each fixture contributes three legs, so 249 fixtures give 747 rows.
| Predicted band | Legs | Mean predicted | Realised |
|---|---|---|---|
| 0.0 to 0.1 | 28 | 6.5% | 0.0% |
| 0.1 to 0.2 | 106 | 15.6% | 17.9% |
| 0.2 to 0.3 | 271 | 25.7% | 26.9% |
| 0.3 to 0.4 | 136 | 33.8% | 30.1% |
| 0.4 to 0.5 | 85 | 44.7% | 48.2% |
| 0.5 to 0.6 | 59 | 54.6% | 44.1% |
| 0.6 to 0.7 | 29 | 65.1% | 69.0% |
| 0.7 to 0.8 | 20 | 74.9% | 80.0% |
| 0.8 to 0.9 | 11 | 82.7% | 100.0% |
| 0.9 to 1.0 | 2 | 91.6% | 100.0% |
The three fullest bands track their predictions within 1.3 percentage points. The 0.5 to 0.6 band came in ten points light on 59 legs, which is the size of gap six weeks of football produces by chance. Run the same table over a full season before you read a bias into any single row.
The full loop
Two calls per fixture, one sleep each, and the whole study fits on a free key.
import json
rows = []
for f in fixtures:
fid, start = f["fixtureId"], f["startTime"]
try:
prices = closing_1x2(fid, start)
time.sleep(4.6) # /historical-odds needs the wider gap
if not prices:
continue
y = label(fid)
time.sleep(1.3)
except (requests.HTTPError, RuntimeError):
continue
fair, overround = fair_proportional(prices)
rows.append({"fixtureId": fid, "tournament": f["tournamentName"],
"closing": prices, "fair": fair, "overround": overround,
"result": y["full"], "half": y["half"], "score": y["score"]})
json.dump(rows, open("labelled.json", "w"), indent=1)
print(len(rows), "labelled fixtures")
print("closing-line Brier:",
round(sum(brier(r["fair"], r["result"]) for r in rows) / len(rows), 4))
Sleep 4.6 seconds after a successful /historical-odds call and about 1.3 seconds elsewhere. Concurrency gets almost everything rate limited, whatever the worker count. Retry a failed batch once, one slug at a time. The 250-fixture run above took 25 minutes and returned 249 usable rows.
Where to take it next
You now hold features and labels in one file, which used to need two vendors. Three directions from here:
- Beat the close. Grade your own probabilities against 0.5778. Anything above that is worse than reading the market.
- Add books. Pinnacle is one opinion out of 350+ in the catalogue. Walk it three slugs at a time, deduplicate on the price tuple, and check your team ratings against the consensus.
- Add markets. Market 101 is the three-way. The same call carries totals, Asian handicaps and correct score, which is how you reconstruct the score distribution.
When you want to run the model forward instead of backward, the WebSocket feed pushes the live board under the same market IDs. For the live pre-match board on these five tournaments, see the football odds API guide.
FAQ
Does the OddsPapi free tier include match results?
Yes. /v4/scores returns p1, fulltime and result for a finished fixture. All 250 top-five-league fixtures tested on 22 September 2026 returned a full-time score.
How far back does the free price history go?
About eight months on Premier League fixtures. A January 2026 fixture still replays, and earlier ones return 404. Retention reaches deeper than one season on the tournaments tested.
Which market ID is the 1X2 in soccer?
Market 101, Full Time Result, with outcomes 101 home, 102 draw and 103 away. Resolve names from /v4/markets rather than hardcoding a set, because the same bet ships under more than one ID in several sports.
How accurate is a bookmaker closing line at predicting football results?
Across 249 top-five-league fixtures played between 1 August and 21 September 2026, the de-vigged Pinnacle closing line scored a Brier of 0.5778 and named the winner 52.2% of the time. The season base rate scored 0.6486 and 43.4%.
Can one API supply both features and labels?
Yes. /v4/historical-odds supplies the price features and /v4/scores supplies the labels, on the same fixture ID and the same free key. Two calls per fixture.
What is the difference between the fulltime and result period keys?
On soccer, fulltime holds the ninety-minute score and result holds the settled final score. They agree on league football and diverge on a tie decided in extra time, so grade ninety-minute markets on fulltime.
Get the data
The free key covers 69 sports, 350+ bookmakers in the catalogue and the full price history used above. Get a free API key and run the loop on your own tournament list. If you want the wider board instead of one sharp book, the free odds API guide covers catalogue discovery.