Soccer Fixtures API: The Dutch Second Division Draws 254 Bookmakers
Pull two days of soccer fixtures from the OddsPapi API and you get 951 games across 283 competitions. Ask which of them a bookmaker has actually priced and the answer is not the one you would guess. The deepest board in the sample belongs to Helmond Sport against VVV Venlo in the Dutch second division: 254 bookmakers, 442 market IDs, 59,728 prices on a single fixture. England’s FA Cup qualifying round on the same weekend drew two books. NCAA women’s soccer drew none.
So the job is to tell those groups apart before you spend the calls, because /odds is one fixture per request and the free tier wants a second between them. Calling all 951 takes 16 minutes and a quarter of it is wasted. This post measures 180 of those fixtures live, tests four filters against the result, and shows what a 254-book board contains once you open it.
What two days of soccer actually looks like
The /fixtures endpoint takes a sport and a date range. Note the date quirk: to is a midnight-UTC instant, not a whole day, so pass the day after the last one you want.
import requests, time, collections
API_KEY = "YOUR_API_KEY"
BASE_URL = "https://api.oddspapi.io/v4"
def get(path, **params):
"""One call, with the retry the free tier asks for."""
for _ in range(5):
params["apiKey"] = API_KEY
r = requests.get(f"{BASE_URL}/{path}", params=params)
if r.status_code == 429:
time.sleep(r.json()["error"].get("retryMs", 1500) / 1000 + 1.2)
continue
if r.status_code == 404:
return 404, [] # empty window, not an error
return r.status_code, r.json()
return r.status_code, []
# `to` is a midnight-UTC instant: pass the day AFTER your last day.
status, fixtures = get("fixtures", sportId=10,
**{"from": "2026-09-03", "to": "2026-09-05"})
comps = collections.Counter((f["categoryName"], f["tournamentName"]) for f in fixtures)
print(len(fixtures), "fixtures in", len(comps), "competitions")
for (cat, name), n in comps.most_common(5):
print(f" {n:>4} {cat} / {name}")
951 fixtures in 283 competitions
134 USA / NCAA Regular Season, Women
47 USA / NCAA, Regular Season
45 Finland / Kolmonen
21 Romania / Liga 3
19 Brazil / U20 Paulista
College soccer, Finnish third-tier and Brazilian under-20 football lead the calendar. That is the shape of world football on a Thursday in September, and none of the four biggest blocks carries a betting market worth scanning. Meanwhile 873 of the 951 fixtures report hasOdds: true, which is 91.8% of them, so that flag will not sort the list for you.
The 180-fixture measurement
I sampled 180 fixtures, 25 from each bucket of the externalProviders key count, and called /odds on every one. Excluding the 14 that had already kicked off, here is the pre-match board distribution across 166 fixtures.
| Bookmakers on the fixture | Fixtures | Share |
|---|---|---|
| 0 | 27 | 16.3% |
| 1 to 9 | 16 | 9.6% |
| 10 to 99 | 27 | 16.3% |
| 100 to 199 | 37 | 22.3% |
| 200 or more | 59 | 35.5% |
The median non-empty board carries 178 bookmakers and the deepest carries 287. Soccer has no cliff. Our college football census found a slate that was either fully covered or barely covered, with nothing at all between 10 and 49 books. Soccer fills every rung of that ladder.
Four filters, tested
Every fixture ships an externalProviders object mapping it to third-party data vendors. On college football, counting the populated keys sorted the slate perfectly: two or more providers meant a real board, every time, no exceptions. That rule cut 136 calls to 54 and lost nothing.
It does not survive the move to soccer.
| Filter | Calls kept | Boards found | Wasted calls |
|---|---|---|---|
hasOdds is true |
164 of 166 | 139 of 139 | 25 |
| 2 or more providers | 144 of 166 | 132 of 139 | 12 |
| 3 or more providers | 120 of 166 | 118 of 139 | 2 |
pinnacleId is populated |
135 of 166 | 126 of 139 | 9 |
Two providers now saves 13% of the calls and misses seven boards. Three providers gets the precision back, with only two wasted calls, and costs you 15% of the boards on the way. One of the ones it drops is an Azerbaijani First Division game carrying betradarId and nothing else, priced by 124 bookmakers.
pinnacleId is the best single field. Populated, the median board is 167 books and Pinnacle itself turns up in the payload 100 times out of 145. Absent, the median board is zero. It is a good default, not a clean rule.
def worth_a_call(f):
providers = {k: v for k, v in (f.get("externalProviders") or {}).items() if v}
return bool(providers.get("pinnacleId")) or len(providers) >= 3
shortlist = [f for f in fixtures if worth_a_call(f)]
print(len(fixtures), "->", len(shortlist)) # 951 -> 472
The competition is the stable unit, and the clock is the trap
Thirty-six competitions came up more than once in the pre-match sample. Thirty-two of them gave the same answer every time: either every sampled fixture had a board, or none did. Coverage is a property of the competition, not of the individual game, which means one probe per competition answers for the whole calendar. That is 283 calls instead of 951 on day one, and none at all on day two if you cache the verdict.
The four that flipped are worth reading, because one of them explains most of what looks like missing coverage. Finland’s Kolmonen, the third tier, returned this:
| Hours to kick-off | Bookmakers |
|---|---|
| 26.3 | 0 |
| 25.8 | 0 |
| 4.1 | 138 |
| 3.8 | 142 |
| 2.3 | 128 |
Same competition, same sample, same afternoon. The two fixtures a day out read as uncovered and the three inside four hours carry 128 books or more. Books open a small league close to the whistle, so a scan run 26 hours early records a zero that will be a full board by lunchtime. Our African football census put that window at roughly 36 hours; on Finnish third-tier football it is shorter still.
The other timing trap sits at the front of your window. Fixtures that have already started drop their live prices: 11 of the 14 kicked-off fixtures in the sample returned an empty payload. Filter on startTime before you call, or your scan will report this morning’s games as unpriced.
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
upcoming = [f for f in shortlist
if datetime.fromisoformat(f["startTime"].replace("Z", "+00:00")) > now]
Depth does not follow prestige
The Premier League tops the sample at 287 books and LaLiga sits one behind on 286. After that the ordering stops matching reputation.
| Competition | Tier | Bookmakers |
|---|---|---|
| England, Premier League | 1 | 287 |
| Spain, LaLiga | 1 | 285 to 286 |
| Saudi Arabia, Saudi Pro League | 1 | 224 to 264 |
| Germany, 2. Bundesliga | 2 | 261 |
| Netherlands, Eerste Divisie | 2 | 253 to 254 |
| Brazil, Brasileiro Serie B | 2 | 252 to 254 |
| Ireland, First Division | 2 | 233 to 242 |
| Wales, Cymru Premier | 1 | 211 to 219 |
| Switzerland, Challenge League | 2 | 187 |
| Egypt, 2. Division A | 2 | 142 to 158 |
| England, FA Cup qualifying | amateur | 2 |
A second-division Dutch fixture outdraws every competition below the big five, and the Welsh top flight beats the Swiss second tier. What decides it is whether the competition runs a fixed, licensed, year-round schedule that European and Asian books can price in volume. Prestige has little to do with it, which is why OddsPapi carries 350+ bookmakers rather than the 20 or 30 a US-facing feed ships.
Inside a 254-book board
Helmond Sport against VVV Venlo, tournamentId 131, kicking off at 18:00 UTC. One call returns 254 bookmakers, 442 distinct market IDs and a median of 66 markets per book. Here is how to read the three-way off it without tripping the two flags that will otherwise poison your average.
status, odds = get("odds", fixtureId="id1000013172044614")
books = odds["bookmakerOdds"]
def three_way(book):
"""Return (home, draw, away) if the book has a live, complete 1X2."""
if book.get("suspended") or book.get("bookmakerIsActive") is False:
return None
market = (book.get("markets") or {}).get("101")
if not market:
return None
prices = []
for outcome_id in ("101", "102", "103"):
outcome = (market.get("outcomes") or {}).get(outcome_id)
if not outcome:
return None
price = outcome["players"]["0"]
if not price.get("active") or not price.get("price"):
return None
prices.append(price["price"])
return tuple(prices)
quotes = {slug: t for slug, book in books.items() if (t := three_way(book))}
print(len(books), "books,", len(quotes), "complete and active") # 254 books, 231
Two flags do the work there. bookmakerIsActive is false on 21 of the 254 books on this fixture, and the price-level active field catches the rest. Skip either check and you will average dead numbers into a fair price. The book-level and price-level flags disagree often enough that you need both, as our Bwin study found when one regional feed shipped a parked board with every price marked live.
Dedupe before you average
231 books is not 231 opinions. Group on the price tuple and the board collapses.
groups = collections.defaultdict(list)
for slug, t in quotes.items():
groups[t].append(slug)
print(len(groups), "independent prices",
f"({100 * (1 - len(groups) / len(quotes)):.1f}% collapse)")
# 67 independent prices (71.0% collapse)
Sixty-seven independent prices out of 231 slugs, a 71.0% collapse, and the largest identical group is 23 separate slugs all quoting 2.50 / 3.35 / 2.45. Feed the raw 231 into a consensus and you have weighted one syndicate’s opinion 23 times. Our 176-book closing-line study found the same collapse on Premier League fixtures and put the point of diminishing returns at 25 independent books.
Rank the board on margin
def margin(t):
return (sum(1 / p for p in t) - 1) * 100
ranked = sorted(groups.items(), key=lambda kv: margin(kv[0]))
for t, slugs in ranked[:3]:
print(f" {margin(t):5.2f}% {t} {len(slugs)} feed(s): {', '.join(sorted(slugs)[:3])}")
| Book | Price (H / D / A) | Margin | Rank of 67 |
|---|---|---|---|
betfair-ex and 4 clones |
2.62 / 3.85 / 2.56 | 3.20% | 1 |
polymarket |
2.632 / 3.571 / 2.632 | 3.99% | 2 |
betika |
2.60 / 3.55 / 2.55 | 5.85% | 3 |
betpawa.cm |
2.60 / 3.58 / 2.52 | 6.08% | 4 |
pinnacle |
2.57 / 3.47 / 2.58 | 6.49% | 8 |
bet365 |
2.55 / 3.40 / 2.55 | 7.84% | 25 |
kalshi |
2.50 / 3.125 / 2.50 | 12.00% | 58 |
betway |
2.30 / 3.10 / 2.30 | 19.21% | 66 |
Median margin across the 67 independent prices is 8.86%. Pinnacle takes 6.49% here, against the 2.95% to 3.20% it charges on a Premier League close. That is the pattern our African football census recorded: Pinnacle’s margin widens with the obscurity of the league, so it stops being an automatic fair-value anchor once you leave the big five. On this board two Kenyan and Cameroonian books, betika and betpawa.cm, price the Dutch second division tighter than Pinnacle does. De-vig the deduped consensus instead, using one of the three de-vigging methods, and rank every book on the board you are actually betting into.
Best price across the whole board
best = [max(t[i] for t in quotes.values()) for i in range(3)]
book_for = [max(quotes.items(), key=lambda kv: kv[1][i])[0] for i in range(3)]
print(list(zip(("home", "draw", "away"), best, book_for)))
print(f"composite overround: {(sum(1 / p for p in best) - 1) * 100:.2f}%")
[('home', 2.632, 'polymarket'), ('draw', 3.85, 'betfair-ex'), ('away', 2.632, 'polymarket')]
composite overround: 1.96%
Taking the best number on each outcome from 231 books leaves a 1.96% overround, against 8.86% at the median book. That is the size of the line shopping gap on a fixture nobody writes about. Watch the sanity check on that composite: a sum below 100% on a thin board is a stale price, not free money, and a single book never offers an arb against itself.
The pipeline, end to end
| Step | Endpoint | Calls for two days of soccer |
|---|---|---|
| Pull the calendar | /fixtures |
1 |
| Drop started fixtures | local | 0 |
| Probe one game per competition | /odds |
283, then cached |
| Read the boards that pay | /odds |
as many as you need |
| Backfill closing prices | /historical-odds |
3 books per call |
That last row is the free part that competitors charge for. /historical-odds keeps the full price history after a fixture finishes, including the books you were not watching live, so you can grade a scanner against real closing lines without paying for a data plan. Snapshots run past kick-off, so filter on createdAt < startTime for a true close.
FAQ
How many soccer fixtures does the API return per day?
Around 475 a day in early September 2026, or 951 across the two-day window measured here, spread over 283 competitions. The count rises during the European club season and falls during international breaks.
Which field tells me a fixture has a bookmaker board?
pinnacleId inside externalProviders is the strongest single signal: populated, the median board is 167 bookmakers. Combine it with a three-provider threshold and you keep 92% of the boards. No field is exact on soccer, so probe one fixture per competition and cache the verdict.
Why does a fixture return no bookmakers when hasOdds is true?
Two reasons. The fixture has already kicked off and live prices have been dropped, or the market has not opened yet. Small leagues open close to kick-off: Finnish third-tier fixtures read empty 26 hours out and carried 128 or more books inside four hours.
Do 254 bookmakers mean 254 different prices?
No. On the Eerste Divisie fixture measured here, 231 complete and active three-way quotes collapsed to 67 independent prices, a 71.0% reduction, with 23 slugs sharing one tuple. Group on the price tuple before you average or you will overweight a single feed.
Is Pinnacle the fair-value benchmark on second-division soccer?
Not reliably. Pinnacle charged 6.49% on this Dutch second-division fixture and ranked eighth of 67 independent prices, behind Betfair Exchange, Polymarket, Betika and betPawa. Its margin widens as the league gets more obscure, so de-vig the deduped consensus rather than trusting one book.
Get your key
Every number here came off the free tier: one /fixtures call, 181 /odds calls and a second of patience between them. Grab a free API key and pull your own two days of soccer, or start with our free odds API guide if this is your first call.