Free key to first response

Use the free key to prove authentication, event discovery and your local Python setup before writing model logic.

  1. 1

    Create your key

    Create a free API key, then copy it from your account.

  2. 2

    Install two packages

    python -m pip install requests pandas

  3. 3

    Set the environment variable

    PowerShell: $env:ODDS_API_KEY="your_key"
    macOS or Linux: export ODDS_API_KEY="your_key"

  4. 4

    Run the first request

    Save the script, run python first_request.py, and confirm that real event IDs print.

first_request.py
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"],
    )
Success looks like this 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. 1. Freeze the featuresCapture odds at one fixed decision time. Never replace them later with closing prices.
  2. 2. Remove the vigConvert both sides of a two-way market into implied probabilities, then normalize them to 100%.
  3. 3. Add the label laterAfter the event is final, join GET /v1/events/{event_id}/results by event_id and set home_win.
  4. 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. 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_id across the saved odds row and final result.
train_baseline.py
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.

84,657 recently observed coverage combinations

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
Seen within 1 hour34.2%
Seen within 24 hours60.8%
Seen within 7 days89.3%

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.

LeagueBookmakers seen
within 24h
Market shapes
within 24h
Latest observationMain lines seen within 24hPlayer 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.

  1. 1DiscoverBounded event window
  2. 2SnapshotFiltered event odds
  3. 3ValidateParticipants, keys, lines
  4. 4RejectUnavailable or stale rows
  5. 5CompareIdentical selections only
python_model_pipeline.py
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.

validate_snapshot.py
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.

coverage_benchmark.py
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.

FieldWhat it meansPublic status
event_idCanonical event key shared by event, odds, history and result routes.Current contract
bookmakerNormalized bookmaker key used for filters and cross-book comparisons.Current contract
market_keyNormalized market identifier. Compare rows only when this matches.Current contract
bet_typeMarket family such as moneyline, handicap, total or player prop.Current contract
metricMeasured statistic, such as points, rebounds, goals or games.Current contract
periodFull game or named segment. Never mix periods in one comparison.Current contract
lineNumeric or text threshold. A different line is a different market comparison.Current contract
sideOutcome side such as home, away, over or under.Current contract
selection_keyStable selection identifier used to join snapshots and history.Current contract
oddsBookmaker price. Null means there is no usable price for the row.Current contract
fair_oddsNullable composite fair price when requested with price_fields=odds,fair.Current contract
is_availableWhether the selection is currently offered. Reject false rows before modeling.Current contract
as_of_ts_msUTC millisecond timestamp when the API assembled the snapshot.Current contract
ttl_secondsCache policy supplied with the snapshot. It is not a source-latency measurement.Current contract
resumeOpaque stream position to retain for reconnects and catch-up.Current contract
next_cursorPagination cursor. Keep filters unchanged until it becomes null.Current contract
snapshot_capture_ts_msUTC millisecond timestamp attached to the captured snapshot used to assemble the response.Current contract
bookmaker_as_of_ts_msTop-level map containing the last API acceptance time for each bookmaker included in the response.Current contract
oldest_bookmaker_as_of_ts_msOldest bookmaker acceptance timestamp in the top-level freshness map.Current contract
completeTrue 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.

Estimated requests / month 43,200

Starter · $30/month · 50,000 base API credits

Collection patternApprox. requests / 30 daysPlan fit
One league every five minutes8,640Starter · $30/month
Five leagues every five minutes43,200Starter · $30/month
Ten leagues every minute432,000Builder · $90/month
Snapshot plus pre-match streamOne snapshot per event connection or resync; reconnect volume variesStarter 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.