Sports Odds API for Python Models
Create a free key, fetch a real event, turn normalized bookmaker prices into pandas rows, then check freshness before your model sees them.
Need the full methodology? Read how to build a sports betting model or build an odds comparison site.
# One request. Normalized rows.
events = odds.events(
sport="american football",
league="NFL",
)
Free key to first response
Use the free key to prove authentication, event discovery and your local Python setup before writing model logic.
-
1
Create your key
Create a free API key, then copy it from your account.
-
2
Install two packages
python -m pip install requests pandas -
3
Set the environment variable
PowerShell:
$env:ODDS_API_KEY="your_key"
macOS or Linux:export ODDS_API_KEY="your_key" -
4
Run the first request
Save the script, run
python first_request.py, and confirm that real event IDs print.
import os
import requests
response = requests.get(
"https://api.odds-api.net/v1/events",
headers={"X-API-Key": os.environ["ODDS_API_KEY"]},
params={
"sport": "american football",
"league": "NFL",
"limit": 3,
},
timeout=20,
)
response.raise_for_status()
for event in response.json()["items"]:
print(
event["event_id"],
event["away_team"],
"at",
event["home_team"],
)
809386301 Houston Texans at New England Patriots
Once event IDs print, continue to the full pipeline. A 401 means the key is missing or invalid.
Build a baseline probability model
Save one pre-game row per event, attach the result after the game, then test predictions on newer events the model never trained on.
- 1. Freeze the featuresCapture odds at one fixed decision time. Never replace them later with closing prices.
- 2. Remove the vigConvert both sides of a two-way market into implied probabilities, then normalize them to 100%.
- 3. Add the label laterAfter the event is final, join
GET /v1/events/{event_id}/resultsbyevent_idand sethome_win. - 4. Split by timeTrain on the oldest 80% of events and test on the newest 20%. A random split can leak future form into the past.
- 5. Beat the market baselineCheck log loss and Brier score before acting on an apparent edge. Profit in one short sample proves very little.
One row per event
- Features known before start
- Home and away decimal odds, bookmaker count, minutes to start and any lagged form.
- Target added after settlement
home_win: 1 for a home win, 0 for an away win. Exclude draws and pushes from this two-way example.- Join key
event_idacross the saved odds row and final result.
import pandas as pd
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import brier_score_loss, log_loss
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
rows = (
pd.read_parquet("training_rows.parquet")
.sort_values("start_time")
.dropna(subset=["home_decimal_odds", "away_decimal_odds", "home_win"])
)
# Remove the bookmaker margin from a two-way winner market.
home_raw = 1 / rows["home_decimal_odds"]
away_raw = 1 / rows["away_decimal_odds"]
rows["market_home_probability"] = home_raw / (home_raw + away_raw)
rows["market_overround"] = home_raw + away_raw - 1
features = [
"market_home_probability",
"market_overround",
"bookmaker_count",
"minutes_to_start",
]
# Keep the future out of training data.
split_at = int(len(rows) * 0.8)
if split_at == 0 or split_at == len(rows):
raise ValueError("Collect more settled events before training")
train = rows.iloc[:split_at]
test = rows.iloc[split_at:].copy()
if train["home_win"].nunique() != 2:
raise ValueError("Training data must contain home wins and away wins")
model = make_pipeline(
SimpleImputer(strategy="median"),
StandardScaler(),
LogisticRegression(max_iter=2_000),
)
model.fit(train[features], train["home_win"])
test["model_home_probability"] = model.predict_proba(test[features])[:, 1]
test["edge"] = (
test["model_home_probability"] - test["market_home_probability"]
)
print("model log loss:", log_loss(test["home_win"], test["model_home_probability"], labels=[0, 1]))
print("market log loss:", log_loss(test["home_win"], test["market_home_probability"], labels=[0, 1]))
print("model Brier score:", brier_score_loss(test["home_win"], test["model_home_probability"]))
print(test.loc[test["edge"] >= 0.03, [
"event_id", "model_home_probability", "market_home_probability", "edge"
]].to_string(index=False))
Install with python -m pip install pandas scikit-learn pyarrow. The free key is enough to test collection; training needs a sample of pre-game rows joined to settled results.
Keep the model only if it beats the market probability on several later time windows and remains calibrated. The 3% edge filter is a screen, not proof of profit.
Python model-readiness benchmark
Calculated server-side from the current public GET /v1/coverage response.
One combination is a unique bookmaker, sport, league, bet type, metric and period tuple seen during the 30-day window.
- Registered bookmakers
- 169
- Bookmakers seen within 24h
- 92
- Sports seen within 24h
- 13
- Leagues seen within 24h
- 150
- Normalized market shapes
- 502
Snapshot . These are catalogue observations, not event counts, update counts or guaranteed future availability.
League readiness explorer
Representative leagues, calculated from the same coverage snapshot.
| League | Bookmakers seen within 24h | Market shapes within 24h | Latest observation | Main lines seen within 24h | Player or team metrics |
|---|---|---|---|---|---|
| NFL | 62 | 135 | 2026-09-17T06:39:06Z | moneyline, spread, over/under | 36 |
| NBA | 0 | 0 | Not observed in the coverage window | None observed within 24 hours | 0 |
| MLB | 58 | 144 | 2026-09-17T06:39:02Z | moneyline, spread, over/under | 19 |
| EPL | 64 | 81 | 2026-09-17T06:39:01Z | moneyline, spread, over/under | 21 |
| NRL | 40 | 33 | 2026-09-17T06:37:26Z | moneyline, spread, over/under | 7 |
Showing 5 representative leagues.
Tested Python pipeline
Discover one event, request a bounded snapshot, flatten it into pandas and reject stale or unavailable rows.
- 1DiscoverBounded event window
- 2SnapshotFiltered event odds
- 3ValidateParticipants, keys, lines
- 4RejectUnavailable or stale rows
- 5CompareIdentical selections only
import os
import time
from datetime import datetime, timezone
import pandas as pd
import requests
BASE_URL = os.getenv("ODDS_API_BASE_URL", "https://api.odds-api.net/v1").rstrip("/")
HEADERS = {"X-API-Key": os.environ["ODDS_API_KEY"]}
MAX_AGE_SECONDS = 120
def get_json(path, **params):
response = requests.get(
f"{BASE_URL}{path}",
headers=HEADERS,
params=params,
timeout=20,
)
response.raise_for_status()
return response.json()
now_s = int(time.time())
events = get_json(
"/events",
sport="american football",
league="NFL",
start_from=now_s,
start_to=now_s + 7 * 24 * 60 * 60,
limit=25,
)
event = next(
item for item in events["items"]
if item.get("home_team") and item.get("away_team")
)
snapshot = get_json(
f"/events/{event['event_id']}/odds/snapshot",
bookmakers="pinnacle,draftkings,fanduel",
market_keys="moneyline,handicap,total",
periods="full time",
price_fields="odds,fair",
include_unavailable="true",
limit=2000,
)
# Snapshot and per-bookmaker timing fields are in the public contract.
snapshot_as_of = snapshot.get("as_of_ts_ms")
snapshot_capture = snapshot.get("snapshot_capture_ts_ms", snapshot_as_of)
bookmaker_as_of = snapshot.get("bookmaker_as_of_ts_ms") or {}
oldest_bookmaker_as_of = snapshot.get("oldest_bookmaker_as_of_ts_ms")
rows = []
for item in snapshot.get("items", []):
rows.append({
"event_id": snapshot["event_id"],
"home_team": event["home_team"],
"away_team": event["away_team"],
"bookmaker": item.get("bookmaker"),
"market_key": item.get("market_key"),
"bet_type": item.get("bet_type"),
"metric": item.get("metric"),
"period": item.get("period"),
"line": item.get("line"),
"side": item.get("side"),
"selection_key": item.get("selection_key"),
"odds": item.get("odds"),
"fair_odds": item.get("fair_odds"),
"is_available": item.get("is_available", True),
"as_of_ts_ms": snapshot_as_of,
"snapshot_capture_ts_ms": snapshot_capture,
"bookmaker_as_of_ts_ms": bookmaker_as_of.get(item.get("bookmaker")),
"oldest_bookmaker_as_of_ts_ms": oldest_bookmaker_as_of,
"target_refresh_interval_seconds": snapshot.get("target_refresh_interval_seconds"),
"ttl_seconds": snapshot.get("ttl_seconds"),
"resume": snapshot.get("resume"),
"next_cursor": snapshot.get("next_cursor"),
})
model_rows = pd.DataFrame(rows)
if model_rows.empty:
raise RuntimeError("No odds rows returned for the selected event and filters")
now_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
source_time = pd.to_numeric(model_rows["bookmaker_as_of_ts_ms"], errors="coerce")
snapshot_time = pd.to_numeric(model_rows["as_of_ts_ms"], errors="coerce")
model_rows["freshness_basis_ts_ms"] = source_time.fillna(snapshot_time)
model_rows["row_age_seconds"] = (now_ms - model_rows["freshness_basis_ts_ms"]) / 1000
usable = model_rows[
model_rows["is_available"].eq(True)
& model_rows["odds"].notna()
& model_rows["selection_key"].notna()
& model_rows["row_age_seconds"].le(MAX_AGE_SECONDS)
].copy()
comparison_key = [
"event_id", "market_key", "bet_type", "metric",
"period", "line", "side", "selection_key",
]
best_prices = (
usable.sort_values("odds", ascending=False)
.groupby(comparison_key, dropna=False, as_index=False)
.first()
)
print(best_prices[
comparison_key + ["bookmaker", "odds", "fair_odds", "row_age_seconds"]
].to_string(index=False))
print("resume:", snapshot["resume"])
Install with python -m pip install requests pandas. The response supplies bookmaker_as_of_ts_ms as a top-level map keyed by bookmaker. If one key is absent, the code falls back to snapshot assembly time and keeps that limitation visible.
Validation before modeling
These checks prove that the response satisfies your model contract. They do not prove that every underlying bookmaker price is correct.
def validate_snapshot(event, snapshot, frame, max_age_seconds=120):
issues = []
if not event.get("home_team") or not event.get("away_team"):
issues.append("missing home or away participant")
if frame["selection_key"].isna().any():
issues.append("missing selection_key")
unavailable = (~frame["is_available"].eq(True) | frame["odds"].isna()).sum()
if unavailable:
issues.append(f"{unavailable} unavailable or suspended prices")
stale = frame["row_age_seconds"].gt(max_age_seconds).sum()
if stale:
issues.append(f"{stale} rows exceed the freshness threshold")
duplicate_key = [
"bookmaker", "market_key", "period", "metric",
"line", "side", "selection_key",
]
duplicates = frame.duplicated(duplicate_key, keep=False).sum()
if duplicates:
issues.append(f"{duplicates} duplicate bookmaker-market-selection rows")
outcome_group = ["bookmaker", "market_key", "bet_type", "period", "metric", "line"]
for key, group in frame.groupby(outcome_group, dropna=False):
bet_type = str(key[2] or "").lower()
outcomes = group["selection_key"].dropna().nunique()
expected = {"moneyline": 2, "moneyline 3w": 3, "handicap": 2, "total": 2}
if bet_type in expected and outcomes != expected[bet_type]:
issues.append(f"unexpected outcome count {outcomes} for {key}")
periods = frame["period"].dropna().astype(str).unique()
if len(periods) > 1:
issues.append("mixed periods: filter or group by period before comparison")
line_group = ["market_key", "bet_type", "period", "metric", "side"]
mismatched = (
frame.groupby(line_group, dropna=False)["line"]
.nunique(dropna=False)
.gt(1)
.sum()
)
if mismatched:
issues.append(f"{mismatched} market groups contain different line values")
return issues
What each check prevents
- Participants
- Stops reversed or incomplete fixtures entering team features.
- Outcome count
- Stops two-way and three-way moneylines being treated as the same market.
- Period
- Stops first-half prices being compared with full-game prices.
- Line
- Stops -2.5 spreads being ranked against -3.0 spreads.
- Selection key
- Protects snapshot-to-history joins.
- Availability
- Removes suspended and null prices before ranking.
- Freshness
- Rejects snapshots beyond the model's age threshold.
- Duplicates
- Prevents one bookmaker price being counted twice.
Reproduce the coverage figures
The public coverage route needs no private data or parser access.
import pandas as pd
import requests
payload = requests.get(
"https://api.odds-api.net/v1/coverage",
timeout=30,
).json()
rows = pd.DataFrame(payload["markets"])
as_of = pd.Timestamp(payload["as_of"])
rows["last_seen_at"] = pd.to_datetime(rows["last_seen_at"], utc=True)
rows["last_seen_age_hours"] = (as_of - rows["last_seen_at"]).dt.total_seconds() / 3600
rows["within_1h"] = rows["last_seen_age_hours"].le(1)
rows["within_24h"] = rows["last_seen_age_hours"].le(24)
shape_fields = ["bet_type", "metric", "period"]
rows["market_shape"] = rows[shape_fields].fillna("").astype(str).agg("|".join, axis=1)
recent = rows[rows["within_24h"]]
league_readiness = (
recent.groupby("league", dropna=False)
.agg(
bookmakers_24h=("bookmaker", "nunique"),
market_shapes_24h=("market_shape", "nunique"),
latest_observation=("last_seen_at", "max"),
)
.sort_values("bookmakers_24h", ascending=False)
)
print({
"coverage_snapshot": payload["as_of"],
"one_hour_observation_rate": round(rows["within_1h"].mean() * 100, 1),
"twenty_four_hour_observation_rate": round(rows["within_24h"].mean() * 100, 1),
})
print(league_readiness.head(20).to_string())
Data contract for a model
Use the snapshot fields for response state and the top-level bookmaker map for per-book freshness.
| Field | What it means | Public status |
|---|---|---|
event_id | Canonical event key shared by event, odds, history and result routes. | Current contract |
bookmaker | Normalized bookmaker key used for filters and cross-book comparisons. | Current contract |
market_key | Normalized market identifier. Compare rows only when this matches. | Current contract |
bet_type | Market family such as moneyline, handicap, total or player prop. | Current contract |
metric | Measured statistic, such as points, rebounds, goals or games. | Current contract |
period | Full game or named segment. Never mix periods in one comparison. | Current contract |
line | Numeric or text threshold. A different line is a different market comparison. | Current contract |
side | Outcome side such as home, away, over or under. | Current contract |
selection_key | Stable selection identifier used to join snapshots and history. | Current contract |
odds | Bookmaker price. Null means there is no usable price for the row. | Current contract |
fair_odds | Nullable composite fair price when requested with price_fields=odds,fair. | Current contract |
is_available | Whether the selection is currently offered. Reject false rows before modeling. | Current contract |
as_of_ts_ms | UTC millisecond timestamp when the API assembled the snapshot. | Current contract |
ttl_seconds | Cache policy supplied with the snapshot. It is not a source-latency measurement. | Current contract |
resume | Opaque stream position to retain for reconnects and catch-up. | Current contract |
next_cursor | Pagination cursor. Keep filters unchanged until it becomes null. | Current contract |
snapshot_capture_ts_ms | UTC millisecond timestamp attached to the captured snapshot used to assemble the response. | Current contract |
bookmaker_as_of_ts_ms | Top-level map containing the last API acceptance time for each bookmaker included in the response. | Current contract |
oldest_bookmaker_as_of_ts_ms | Oldest bookmaker acceptance timestamp in the top-level freshness map. | Current contract |
complete | True when there are no more bookmaker-complete pages for the current filters. | Current contract |
Snapshot freshness is not source latency
now - as_of_ts_ms measures how old the assembled API snapshot is. Use bookmaker_as_of_ts_ms[bookmaker] to measure how long ago that bookmaker's data was accepted by the API, then compare it with target_refresh_interval_seconds. This is API acceptance age, not full source-to-consumer latency or proof of when the source first changed its price.
Model request calculator
Estimate standard polling calls over a 30-day month. Large payloads can consume additional weighted units.
| Collection pattern | Approx. requests / 30 days | Plan fit |
|---|---|---|
| One league every five minutes | 8,640 | Starter · $30/month |
| Five leagues every five minutes | 43,200 | Starter · $30/month |
| Ten leagues every minute | 432,000 | Builder · $90/month |
| Snapshot plus pre-match stream | One snapshot per event connection or resync; reconnect volume varies | Starter for validation; Builder for broader concurrent collection |
Streams also use plan-specific concurrent connection, stream-hour and data allowances. One starting snapshot does not make every subsequent stream update another REST request.
Where the API fits
Use it when normalized pre-match prices and reproducible Python processing matter more than source-native execution latency.
- General sportsbook coverage is pre-match.
- The API supplies data. It does not place bets.
- Streaming delivery does not guarantee source-native latency.
- Catalogue observations do not guarantee every market on every event.
- Historical access is a separate add-on.
- Ultra-low-latency execution users should consider direct licensed feeds.
Python model questions
Direct answers for integration and procurement.
What is the best sports odds API for a Python model?
Choose an API that returns stable event, market, line, selection and freshness fields for the leagues and bookmakers you need. odds-api.net is a fit for normalized pre-match models that can validate the current coverage catalogue and reject stale snapshots.
How do I convert bookmaker odds into a pandas DataFrame?
Request one bounded event odds snapshot, pass its items list to pandas.DataFrame, then retain the top-level event_id, as_of_ts_ms, ttl_seconds and resume values on every row.
How do I compare the same market across bookmakers?
Group only rows with identical market_key, period, metric, line, side and selection_key values. A spread at -2.5 is not comparable with a spread at -3.0.
How do I detect stale odds?
Use the top-level bookmaker_as_of_ts_ms map to calculate each bookmaker's accepted-data age. Keep as_of_ts_ms as the assembled snapshot time and compare bookmaker age with target_refresh_interval_seconds and your own product threshold.
Can I retrieve Pinnacle odds with Python?
Yes when Pinnacle is present for the requested event and your account coverage. Pass bookmakers=pinnacle to the event odds snapshot and confirm current catalogue observations first.
Can I track line movement?
Yes with a history add-on. Keep the selection_key from the current snapshot and query a bounded UTC history window for that event and selection.
Can I backtest a model with historical odds?
Yes for retained pre-match line movement when the relevant history add-on is active. Join settled results separately by event_id and prevent prices recorded after the model decision time from entering the test.
Which plan should a Python model use?
Start with Starter at US$30 per month for 50,000 base API credits. Builder at US$90 per month is the usual next step for broader collection and includes 2 million base API credits. Use a higher plan only when the request calculation requires it.
Get your free API key.
Enter your email, then run the Python quickstart with real pre-match odds.