Growing Discord community — direct access to the developer, live coverage & picks. Join the Discord Join now →
Matches Leagues Predictions The Edge Money News Stats Coverage
Docs / Football API free

Odds & predictions#

Free multi-bookmaker football odds plus model forecasts. All prices are decimal and, for now, pre-match only. For odds across other sports with tick history, see the separate Odds API product, which is being retired on 2026-09-01 — per-bookmaker prices move to each sport's own API.

Odds feed#

GET /api/v2/odds/

One row per event × market × outcome × bookmaker. How often those rows change is in Refresh cadence below.

Param Type Description
event_id int One match
league_id / season_id / team_id int Scope filters
market string 1x2 · over_under_15 · over_under_25 · over_under_35 · btts · double_chance · total_corners — see Markets in the feed
outcome string HOME · DRAW · AWAY · over · under · yes · no · 1X · 12 · X2case-sensitive, and the 1X2 outcomes are the uppercase ones
bookmaker_slug string One bookmaker (see /bookmakers/)
is_max_quote bool Only rows that are the current best price for their outcome
movement string SHORTENING · DRIFTING — see below
min_decimal_odds / max_decimal_odds float Price band
updated_after datetime Only rows updated since this ISO timestamp
limit / offset int Pagination

market and outcome are validated against each other: 1x2 accepts only HOME/DRAW/AWAY, btts only yes/no, double_chance only 1X/12/X2, and so on. A value outside the list, or a pair that cannot occur, returns 400 naming what is valid.

Markets in the feed#

Everything quoted today, with the periods and lines actually written. The market parameter below is the legacy vocabulary, which is full-time only — the half-time and second-half rows exist in the data and come back on the per-match endpoints, but this parameter cannot name them.

What it prices Periods Lines
Match result (1X2) full time · 1st half · 2nd half
Total goals full time · 1st half · 2nd half 0.5 · 1.5 · 2.5 · 3.5 · 4.5
Both teams to score full time · 1st half · 2nd half
Double chance full time
Draw no bet full time
Asian handicap full time −3.5 … +3.5, in quarters
Total corners full time 7.5 · 8.5 · 9.5 · 10.5 · 11.5
Clean sheet (home / away) full time
Win to nil (home / away) full time

Named on the market parameter: 1x2 · over_under_15 · over_under_25 · over_under_35 · btts · double_chance · draw_no_bet · asian_handicap · total_corners. The rest are reachable per match through /api/v2/events/{id}/odds/ and /odds/comparison/.

An Asian handicap row carries its own push field — none, half or full — because the price alone does not tell you what happens to the stake when the adjusted result lands on the line. Both legs of a line share the stored line value and it is home-relative: line: -1.25 means the home team starts 1.25 goals down, whichever outcome you are reading.

Accepted but not currently quoted. european_handicap, corners_1x2, total_red_cards and red_card remain part of the contract and still answer — with historical rows only, since no new prices are written for them. We would rather say that plainly than let you build against an empty result and wonder why. Cards markets in general are not currently priced.

updated_at is when we last saw the price, not when it last moved#

This is the part that catches people out. updated_at is the timestamp of our most recent observation of that line. It advances every time we re-read the price, whether or not the number changed — so a fresh updated_at is not evidence that anything moved, and two rows re-read in the same pass share a timestamp regardless of which of them actually shifted.

To tell whether the price moved, read the price fields, not the clock:

Question Field to read
Has this line ever moved? previous_decimal_oddsnull means never
Which way did it last move? movement (SHORTENING / DRIFTING)
How far since the last change? decimal_odds against previous_decimal_odds
How far since it opened? decimal_odds against opening_decimal_odds

previous_decimal_odds is the step before the current one; opening_decimal_odds is the first price we ever recorded for that selection at that bookmaker, with opening_at as its timestamp. A line can sit unchanged for hours — empty movement, previous_decimal_odds null — and still be a long way from where it opened. Total drift is the open-to-current gap, not the last step.

Both fields are always present: the underlying columns are NOT NULL, so every line carries an opening price and an opening timestamp.

Read the other way round, an old updated_at does mean something: it says we have not re-read that line recently, which for a fixture a week out is ordinary and expected — see the cadence table above. It does not mean the price is wrong, and it does not mean the row is stale in the cache sense.

For when we last asked about a whole match, and when the next re-read is due, use last_update_at and next_update_at on /api/v2/events/{id}/odds/.

How to poll#

Keep the newest updated_at you have seen and pass it back as updated_after. You then receive only the lines we have re-read since then instead of the whole book, which is far cheaper on both sides.

GET /api/v2/odds/?league_id=17&updated_after=2026-08-16T20:32:56Z

Because updated_at is an observation, that delta includes lines we re-read and found unchanged. If you only want the moves, filter the result on previous_decimal_odds != null && decimal_odds != previous_decimal_odds, or ask the API for one direction with movement=SHORTENING / movement=DRIFTING.

With no event_id, league_id, season_id, team_id or updated_after at all, the feed defaults to the last 48 hours of observations rather than the entire history — worth knowing before you conclude that older rows have vanished.

Polling faster than the cadence above buys nothing — the rows are simply not there yet. On top of the cadence there is a server-side cache, so the worst case for a given response is cadence plus cache:

Endpoint Cached for
/api/v2/odds/, /api/v2/odds/{id}/, /odds/comparison/ ~3 minutes
/api/v2/odds/best/ ~5 minutes
/api/v2/events/{id}/polymarket/ ~1 minute
/api/v2/bookmakers/ ~1 hour

For prices that move while you watch, use the WebSocket rather than a tighter poll.

What a free key returns#

The odds feed answers without a paid plan, but it answers with the consensus price only — one row per event × market × outcome, rather than one per bookmaker. On a single match's 1X2 that is 3 rows instead of 198. Football Unlimited is what unlocks per-bookmaker identity.

On those consensus rows the shape stays valid, which is deliberate — nothing disappears and turns your parser into a KeyError:

  • bookmaker_slug is "consensus" and bookmaker_name is "Consensus", in place of the real bookmaker's identity;
  • bookmaker_count is added, saying how many bookmakers formed that price;
  • is_max_quote is gone — "the best price across all bookmakers" means nothing when only one row is served. Filtering is_max_quote=true therefore returns an empty list rather than an error.

Three things return 403 with "code": "bookmakers_not_entitled" instead of degrading: filtering /api/v2/odds/ by bookmaker_slug, /api/v2/odds/best/, and /api/v2/events/{id}/odds/comparison/. Branch on that code rather than on the status alone.

The comparison grid refuses rather than degrades because every field in it is per-bookmaker — there is no consensus-shaped version of "what each bookmaker is paying". A thinner grid with the same shape would be indistinguishable from live data, which is worse than a clear no.

Note: bookmaker_count is currently served as null on every row while the count is rewired. Read it defensively; the field is there and the value will follow.

Best prices#

GET /api/v2/odds/best/
Param Type Description
market string Required in practice — which market to rank
league_id / season_id / team_id int Scope
date_from / date_to date Kickoff window
limit / offset int Pagination (max 200)

Per event, the best available decimal price per outcome with the bookmaker offering it.

Bookmakers#

GET /api/v2/bookmakers/    →  [ { "slug": "pinnacle", "name": "Pinnacle" }, … ]

The endpoint lists every bookmaker we have ever recorded a price from, which is more than the set currently quoting. 21 books are being priced right now:

Pinnacle · Bet365 · 1xBet · William Hill · Unibet · betfair · BetVictor · 10bet · 888Sport · Bwin · Betsson · Interwetten · ladbrokes · coral · MansionBet · dafabet · CloudBet · Sbo · MelBet · Cashpoint · Macauslot

Coverage is per match, not global: a given fixture carries the subset of those books that quoted it. The count is on each row as bookmaker_count when you read consensus prices.

The same slugs work on the WebSocket bookmaker_slug subscription.

Per-match shortcuts#

On any event: /api/v2/events/{id}/odds/ (consensus, free), /odds/comparison/ (full grid, Football Unlimited), /polymarket/ (prediction-market probabilities) — see Events & live scores.

Polymarket#

GET /api/v2/events/{id}/polymarket/

Implied probabilities (0–1 floats, not decimal odds) per market, plus trading-signal fields for matches with an active prediction market.

{
  "event_id": 223510,
  "markets": {
    "1x2": { "home": 0.41, "draw": 0.29, "away": 0.30 },
    "btts": { "yes": 0.52, "no": 0.48 },
    "over_under": { "over_25": 0.55, "under_25": 0.45 }
  },
  "liquidity": {
    "volume_24hr": 18420.5, "volume_1wk": 96210.0,
    "volume_1mo": 210500.0, "volume_1yr": 890100.0,
    "open_interest": 42110.0,
    "liquidity_clob": 8900.0, "liquidity_amm": 1200.0
  },
  "pricing": {
    "best_bid": 0.40, "best_ask": 0.42,
    "change_1d": 0.015, "change_1wk": -0.03, "change_1mo": 0.08
  },
  "goalscorers": { "...": "..." },
  "exact_scores": { "...": "..." },
  "updated_at": "2026-08-02T14:52:11+00:00"
}

liquidity and pricing sub-fields are null when that trading data isn't available. The endpoint itself returns 404 for matches with no active Polymarket market.

Predictions#

GET /api/v2/predictions/
GET /api/v2/predictions/{id}/
GET /api/v2/events/{id}/prediction/     # same shape, by event
Param Type Description
status string Filter by match status
league_id / season_id / team_id int Scope
date_from / date_to date Kickoff window
min_confidence float Minimum model confidence (0–1)
recommended bool Only picks the model flags as value
limit / offset int Pagination
{
  "id": 88123,
  "event": { "id": 223510, "home_team": "Aldosivi", "away_team": "Gimnasia y Esgrima", "...": "..." },
  "markets": {
    "match_result": { "prob_home": 34.1, "prob_draw": 30.2, "prob_away": 35.7, "predicted": "away" },
    "expected_goals": { "home": 1.12, "away": 1.24 },
    "over_under": { "prob_over_15": 71.4, "prob_over_25": 44.8, "prob_over_35": 21.9 },
    "btts": { "prob_yes": 48.5 },
    "score": { "most_likely": "1-1" },
    "draw_no_bet": { "prob_home": 48.9 },
    "corners": { "prob_over_85": 61.0, "prob_over_95": 49.2, "prob_over_105": 37.5 }
  },
  "recommendations": { "favorite": "away", "favorite_prob": 35.7, "over_25": false, "btts": false },
  "model": { "confidence": 0.62, "version": "v1" }
}

Note: market probabilities are 0–100; model.confidence is 0–1. This asymmetry is a frozen legacy contract kept for compatibility.

Predictions are cached ~2 minutes server-side.

View this page as Markdown · Found a mistake? Tell us