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 · X2 — case-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_odds — null 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_slugis"consensus"andbookmaker_nameis"Consensus", in place of the real bookmaker's identity;bookmaker_countis added, saying how many bookmakers formed that price;is_max_quoteis gone — "the best price across all bookmakers" means nothing when only one row is served. Filteringis_max_quote=truetherefore 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_countis currently served asnullon 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.confidenceis 0–1. This asymmetry is a frozen legacy contract kept for compatibility.
Predictions are cached ~2 minutes server-side.