Boxing Odds API: Merge Three Market IDs Into One Fight Price
Boxing looks like the easiest sport an odds API will ever hand you. Two fighters, one winner, one price each way. Then you write the parser, point it at a title fight, and it reports a fraction of the board.
The prices are not missing. Your key is wrong. Boxing ships the same bet under two different market IDs, ships one bet under two different market names, and flags live markets as inactive. This guide walks all three traps with live data pulled on 24 August 2026, then gives you a resolver that survives them.
What the boxing board holds
A window on sportId=21 sampled on 24 August 2026 returned 22 fixtures with odds, spread across 147 distinct bookmakers. The median fixture carried 73.5 books, the thinnest 20 and the deepest 142. Depth splits hard by billing position:
| Fight | Books | Distinct market IDs |
|---|---|---|
| Itauma v Hrgovic (main event) | 142 | 36 |
| Tszyu v Mahoney (main event) | 128 | 22 |
| Cameron v Mayer | 127 | 6 |
| Dubois v Moore | 126 | 5 |
| Harper v Reyes | 122 | 4 |
| Noakes v Berinchyk | 113 | 5 |
| Youmbi v Perez | 110 | 4 |
Undercards in the same window ran 20 to 51 books on 2 to 5 market IDs. A main event gets a hundred books and three dozen market IDs; the four-rounder underneath it gets a moneyline and nothing else.
Bookmaker presence across the 22 fights: bet3000 on 21, Betika 21, bet365 20, Stake 20, 1xBet 19, Pinnacle 17, FanDuel 10, Kalshi 9, Caesars 7, SBOBet 7, Hard Rock Bet 5. Polymarket, BetMGM and Circa Sports did not appear on any of the 22 fights.
One number to keep: 92.0% of the 6,614 prices sampled were active: true. Boxing boards are clean compared with college football, where 58% of the Week 1 board sits suspended. The problems here are structural, not stale.
Discovery has exactly one door
The catalogue lists 57 boxing tournaments. One carries future fixtures. Every real fight sits under tournamentId=24327, named International Matchups, holding 46 future fixtures. The named-event rows you would reach for first are empty: BKFC 81 through 91, BKFC Fight Night, BKB 46 through 57, Power Slap, Olympic Tournament, La Velada del Ano, all zero.
So you cannot filter boxing by promoter or card. There is no Matchroom row, no Top Rank row, no Riyadh Season row. You pull the sport and filter on fighter names or dates yourself.
Old way vs OddsPapi
| Job | Scraping bookmaker sites | OddsPapi |
|---|---|---|
| Books per fight | One per scraper you maintain | Up to 142 in a single call |
| Sharp benchmark | Pinnacle is closed to the public | pinnacle slug on the free tier |
| Round totals | Different DOM per book | One market family, one schema |
| Price history | You build the recorder first | /historical-odds, free tier, back to the market open |
| Stake ceiling | Invisible until the bet is rejected | limit field on Pinnacle and the exchanges |
| Live updates | Poll and hope | WebSocket push |
Step 1: Authenticate and handle the rate limit
The key rides as a query parameter. Not a header. The free tier limits per endpoint and returns a real HTTP 429 with the wait built into the body, so read retryMs rather than guessing. An empty fixture window returns 404, which is a normal answer and not a failure.
import requests, time
from collections import defaultdict
API_KEY = "YOUR_API_KEY"
BASE_URL = "https://api.oddspapi.io/v4"
BOXING = 21
def get(path, **params):
params["apiKey"] = API_KEY
for attempt in range(5):
r = requests.get(f"{BASE_URL}{path}", params=params, timeout=90)
if r.status_code == 200:
time.sleep(1.0) # per-endpoint cooldown
return r.json()
if r.status_code == 404: # empty window, not an error
return []
if r.status_code == 429:
wait = r.json().get("error", {}).get("retryMs", 2000) / 1000
time.sleep(wait + 0.3)
continue
r.raise_for_status()
raise RuntimeError(f"gave up on {path}")
print(len(get("/sports")), "sports") # 69
Do not thread this. Twelve concurrent /odds calls come back with eleven 429s. One second between calls runs clean.
Step 2: Find the fights
from datetime import date, timedelta
tours = get("/tournaments", sportId=BOXING)
live = [t for t in tours if t.get("futureFixtures")]
print(f"{len(tours)} tournaments, {len(live)} carry fixtures")
for t in live:
print(t["tournamentId"], t["tournamentName"], t["futureFixtures"])
today = date.today()
fixtures = get("/fixtures", sportId=BOXING,
**{"from": str(today), "to": str(today + timedelta(days=10))})
priced = [f for f in fixtures if f.get("hasOdds")]
print(f"{len(priced)} fixtures flagged hasOdds")
57 tournaments, 1 carry fixtures
24327 International Matchups 46
22 fixtures flagged hasOdds
Set to to the day after the last day you want. The window is from midnight UTC to to midnight UTC, so a same-day query returns only the fixtures starting at exactly 00:00Z.
Step 3: The trap, and the resolver that beats it
Here is the whole problem. Across the 22 fights sampled on 24 August 2026, the winner market arrives under two different IDs:
| Market ID | marketName | Books quoting it | Quotes |
|---|---|---|---|
| 211 | Winner | 96 | 1,175 |
| 201 | Winner | 31 | 313 |
Markets 211 and 201 are the same bet. Same outcome names, same price range, quoted by different books. Key your parser on 211 and you drop 313 of 1,488 quotes, 21.0% of the board.
The three-way does it again, and worse. 1X2, the version that prices the draw, ships under three market IDs. Measured on 24 August 2026 across ten fixtures:
| Market ID | marketName | Books quoting it | Quotes | Who is in there |
|---|---|---|---|---|
| 213 | 1X2 | 40 | 140 | 1xbet, 22bet, 7bet.lt, apuestatotal, bcgame, bet365 and its regional slugs |
| 203 | 1X2 | 33 | 81 | 888sport.it, atg.se, ballybet, betcity.nl, betmgm.co.uk, betparx, betplay, betrivers |
| 313 | 1X2 | 2 | 2 | coral, ladbrokes |
That is 223 book-quotes on one bet. Key on 213 and you miss 83 of them, 37.2% of the three-way board. Key on 203 and you miss 62.8%. Market 313 is the part that should worry you: two books, Coral and Ladbrokes, and it is the reason a parser patched for “the two IDs” still comes up short.
Round totals break the other way. On Itauma v Hrgovic, the 6.5-round line ships as Total Rounds under market 2118, on 68 books, and as Over Under Rounds under market 2018, on 32. Two names, one bet, and neither name alone finds the board. So keying on the market name fails exactly where keying on the ID succeeds, and the reverse.
One more edge before you write the resolver. Winner and 1X2 look like duplicates and are not. Boxing draws happen, so 1X2 carries a third outcome for the draw. Across the sample Winner appeared on 132 books and 1X2 on 108. Merge those two and your margin maths goes wrong in both directions.
Resolve on the two things that identify a bet: the normalised family name and the handicap. Read the names out of /v4/markets and collect every ID that maps to the name you want, rather than hardcoding a set of IDs. A set of two was wrong for the three-way today, and nothing stops a fourth ID appearing on the next card.
catalogue = get("/markets", sportId=BOXING)
MARKET = {m["marketId"]: m for m in catalogue}
OUTCOME = {(m["marketId"], o["outcomeId"]): o["outcomeName"]
for m in catalogue for o in m.get("outcomes", [])}
ALIASES = {
"winner": "WINNER", # two-way, no draw
"1x2": "WINNER_DRAW", # three-way, draw priced
"total rounds": "ROUNDS",
"over under rounds": "ROUNDS", # same bet, different name
}
def family(market_id):
"""Collapse the marketName and marketId variants into one betting family."""
m = MARKET.get(int(market_id))
if not m:
return None
key = ALIASES.get(m["marketName"].strip().lower())
if key is None:
return None
return (key, m.get("handicap"))
for a, b in [(211, 201), (2118, 2018)]:
print(f"family({a})={family(a)} family({b})={family(b)} same={family(a)==family(b)}")
print("Winner vs 1X2 stay apart:", family(211), family(213))
family(211)=('WINNER', 0.0) family(201)=('WINNER', 0.0) same=True
family(2118)=('ROUNDS', 6.5) family(2018)=('ROUNDS', 6.5) same=True
Winner vs 1X2 stay apart: ('WINNER', 0.0) ('WINNER_DRAW', 0.0)
Resolving by family instead of by market ID recovers 313 winner quotes and 83 three-way quotes that a hardcoded ID would have dropped, along with the books behind them. Hold the 21.0% loosely until Step 5, because a large share of what it recovers turns out to be the same feed under other names.
Step 4: Ignore marketActive, trust the price
The payload carries three liveness flags. Two of them will delete your data.
The first version of the parser below filtered on market["marketActive"], which reads like the obvious guard. It deleted round-total markets whose every price was active: true.
Counted across the 24 August 2026 sample: 713 of 3,134 market records carry marketActive: false while every price inside them is live, 22.8% of the records sampled. Another 116 carry marketActive: true with every price dead. Book-level bookmakerIsActive is no better, reading false on 144 book records. suspended fired on 18.
Filter on the price-level active flag and nothing else.
def board(fixture_id):
"""{family: {slug: {outcome_name: price}}} for live quotes only."""
payload = get("/odds", fixtureId=fixture_id).get("bookmakerOdds") or {}
out = defaultdict(dict)
for slug, book in payload.items():
if book.get("suspended"): # reliable; marketActive is not
continue
for market_id, market in book.get("markets", {}).items():
fam = family(market_id)
if fam is None:
continue
prices = {}
for outcome_id, outcome in market["outcomes"].items():
for price in outcome["players"].values():
if price["active"]: # the only flag worth trusting
name = OUTCOME.get((int(market_id), int(outcome_id)), outcome_id)
prices[name] = price["price"]
if prices:
out[fam][slug] = prices
return out
FIGHT = "id2102432772308448" # Itauma v Hrgovic, 29 Aug
win = board(FIGHT)[("WINNER", 0.0)]
print(f"WINNER quoted by {len(win)} books")
WINNER quoted by 118 books
That is 118 live winner quotes out of 142 books in the payload. The rest are books that appear on the fight without a live price on the market you asked for, and the active flag is what tells them apart.
The same filter earns its keep on the sharp book. A quote with the favourite and the underdog the wrong way round is almost always a placeholder rather than a price, and it usually arrives with both sides flagged active: false. Cross-check each book’s implied favourite against the field before you trust it, and filter on active first, so a placeholder never reaches your model.
Step 5: Deduplicate, then believe the count
Now the honest version of the headline. Resolving the two market IDs lifts the raw quote count by 21.0%, but the raw count was never the interesting number. Across the 22 fights, 1,488 slug-quotes on the winner market collapse to 558 independent prices, a 62.5% collapse. Nearly two of every three bookmaker rows on a fight are a duplicate feed.
def dedupe(quotes):
seen, keep = {}, {}
for slug, prices in quotes.items():
sig = tuple(sorted(prices.items()))
if sig in seen:
seen[sig].append(slug)
continue
seen[sig] = [slug]
keep[slug] = prices
groups = [v for v in seen.values() if len(v) > 1]
return keep, groups
indep, groups = dedupe(win)
print(f"{len(win)} slugs -> {len(indep)} independent quotes")
print("largest shared feed:", max((len(g) for g in groups), default=0), "slugs")
118 slugs -> 45 independent quotes
largest shared feed: 28 slugs
On the deepest fight of the card that is a 61.9% collapse, and the largest groups are not small:
| Slugs | Price | Members |
|---|---|---|
| 28 | 1.09 / 8.00 | ballybet, betcity.nl, betmgm.co.uk, betparx, betplay, betrivers, betuk, bingoal.be, expekt.se, fourwinds, grosvenor, jacks.nl, leovegas, leovegas.es and 14 more |
| 14 | 1.111 / 6.00 | bet365 and its six regional slugs, plus 7bet.lt, apuestatotal, betinia.dk, bovada.lv, estrelabet, fezbet, lottoland |
| 10 | 1.10 / 6.00 | betboo.bet.br, bwin, bwin.dk, bwin.es, duel, partypoker, sportingbet, sportingbet.bet.br, sportsinteraction, winbet.ro |
| 7 | 1.09 / 6.20 | bcgame, betfury, blaze, gamdom, megadice, rainbet, roobet |
| 6 | 1.11 / 5.60 | admiralbet.it, admiralbet.rs, bet3000, betika, wettarena, yesplay |
All of them report cloneOf: null, so the catalogue will not warn you. The pairs you may already know, BallyBet with BetParx and FourWinds, or Caesars with William Hill, are still real and are now small subsets of much larger families.
The resolver is still worth having. Market 201 is 31 books on this sample rather than one feed wearing three names, and a wider sample is a better consensus. Just publish the deflated number. Say 45 independent prices, not 142 bookmakers.
One more thing the price tuple catches. On Itauma v Hrgovic, three slugs quoted an identical 1.12 and 6.64: pinnacle, pinnacle+30 and pinnacle+5. The last two are internal test feeds rather than bookmakers, and a naive count treats the sharp book as three books. Drop any slug matching pinnacle+ explicitly, and dedupe on the price tuple, which removes them anyway.
Do all of this per fixture rather than from a hardcoded clone list. Two books landing on the same round number is common on a two-outcome market, and a static list will merge books that merely agreed.
Step 6: Margins, and who is sharp
def two_sided(prices):
vals = [p for p in prices.values() if p and p > 1.0]
if len(vals) != 2:
return None
total = sum(1 / p for p in vals)
return None if total <= 1.0 else (total - 1) * 100
for slug, prices in sorted(win.items(), key=lambda kv: two_sided(kv[1]) or 99):
m = two_sided(prices)
if m is not None:
print(f"{slug:14} {prices} margin={m:.2f}%")
Median winner-market margin, for the 117 books that appeared on at least 5 of the 22 fights:
| Rank | Book | Median margin | Fights |
|---|---|---|---|
| 1 | kalshi | 0.98% | 9 |
| 2 | paddypower | 3.69% | 7 |
| 3 | betway.es | 4.16% | 7 |
| 4 | pinnacle | 4.34% | 17 |
| 5 | betway | 4.34% | 6 |
| 7 | draftkings | 5.49% | 15 |
| 8 | hardrockbet | 5.57% | 5 |
| 29 | thescore | 5.67% | 9 |
| 42 | caesars | 6.52% | 7 |
| 44 | bovada.lv | 6.67% | 10 |
| 60 | fanduel | 7.04% | 10 |
| 70 | bet365 | 7.49% | 13 |
| 99 | 1xbet | 8.03% | 19 |
| 105 | sbobet | 8.29% | 7 |
| 117 | netbet | 17.48% | 16 |
Pinnacle ranks 4th of 117, not first. Two sportsbooks beat it on the median: Paddy Power at 3.69% and betway.es at 4.16%, both on seven fights. That is the finding rather than a caveat. Pinnacle priced 17 of the 22 fights, more than any other sharp-priced book on the card, at a median 4.34%. Use it as the benchmark because of that consistency, then strip the vig before you compare anything to it, and check the two books above it on the fight in front of you.
Kalshi at 0.98% is the interesting one, because on boxing it is tight and deep. Across the sample its top-rung stake capacity ran to a median of $1,623.55 and a maximum of $8,715.86. On Itauma v Hrgovic it held $2,401.63 behind the favourite at 1.163 and $356.05 behind the underdog at 6.667, across three ladder levels. On the NFL the tightest prediction-market quote carried $7.22. Same venue, different sport, opposite conclusion, so screen on ladder depth every time rather than carrying a rule across.
# exchange depth: back is a list of levels, best first
meta = price.get("exchangeMeta") or {}
for level in meta.get("back", []):
print(level["price"], "stake capacity", level["limit"])
assert round(level["size"] * level["cents"], 2) == round(level["limit"], 2)
Step 7: Round totals
Round totals are boxing's native market structure, and they carry the name collision in full. Itauma v Hrgovic, 29 August:
LADDER = "id2102432772308448" # Itauma v Hrgovic
rungs = sorted((f[1], q) for f, q in board(LADDER).items() if f[0] == "ROUNDS")
for h, quotes in rungs:
print(f"{h:5} books={len(quotes)} {sorted(quotes)}")
Before the resolver merges them, the ladder looks like two ladders:
| Rung | Total Rounds | Books | Over Under Rounds | Books |
|---|---|---|---|---|
| 3.5 | 216, period=fulltime |
26 | 2012 | 32 |
| 4.5 | 2110 | 29 | 2014 | 32 |
| 5.5 | 2114 | 32 | 2016 | 32 |
| 6.5 | 2118 | 68 | 2018 | 32 |
| 7.5 | 2122 | 35 | 2020 | 32 |
| 8.5 | 2126 | 29 | 2022 | 32 |
| 9.5 | 2130 | 28 | 2024 | 32 |
Seven rungs, each one shipped twice. Note rung 3.5: it arrives as market 216 with period=fulltime while every sibling rung carries period=result. That is why the resolver keys on the normalised family name and the handicap, and leaves period out of the key entirely. Put period in the key and rung 3.5 falls out of your ladder.
The 6.5 line is the consensus rung. It is the only line that pulls extra books to the Total Rounds name, 68 against 26 to 35 on the wings, while the Over Under Rounds name sits flat at 32 books on every rung. Take the consensus line as the one the field agrees on, which is the rung with the most books, and shop the price only there.
Step 8: Limits say something different on boxing
Pinnacle publishes a limit on every price. On MLB the implied base swings 20x inside one fixture and reads as a confidence signal. On boxing it does not move at all.
def limit_base(price, limit):
"""Pinnacle caps max WIN, so back out the base."""
if limit is None:
return None
return round(limit if price >= 2 else limit * (price - 1), 1)
Run that over every Pinnacle-priced outcome in the 24 August 2026 sample, across all 17 fights it quoted, and the base comes back with one value: $125. A main event and a six-round undercard bout carry the identical ceiling. For comparison, the same calculation returns $7,500 on an MLB moneyline and $1,500 on a La Liga opener.
So the limit-as-confidence signal does not transfer to boxing. It tells you Pinnacle wants no size on the sport, and it tells you nothing about which fight it has an opinion on. Only Pinnacle and the exchanges populate the field at all; every other book returns null, so null-check before the arithmetic.
Step 9: Free historical odds, and who opens the market
/historical-odds returns every snapshot back to the first quote, on the free tier, three bookmakers per call. Boxing payloads run 0.01 MB to 0.09 MB per fight, so you can walk a whole card in a couple of minutes.
Watch the shape change. The live endpoint keys on bookmakerOdds and players["0"] holds one dict. The historical endpoint keys on bookmakers and players["0"] holds a list.
from datetime import datetime
def open_clock(fixture_id, first_bell, books="pinnacle,bet365,draftkings"):
data = get("/historical-odds", fixtureId=fixture_id, bookmakers=books)
bell = datetime.fromisoformat(first_bell.replace("Z", "+00:00"))
rows = {}
for slug, book in (data.get("bookmakers") or {}).items():
stamps = [s["createdAt"]
for m in book.get("markets", {}).values()
for o in m["outcomes"].values()
for snaps in o["players"].values() for s in snaps]
if not stamps:
continue
first = min(datetime.fromisoformat(s.replace("Z", "+00:00")) for s in stamps)
rows[slug] = round((bell - first).total_seconds() / 86400, 2)
return rows
print(open_clock("id2102432772308448", "2026-08-29T22:00:00.000Z"))
Run that across the eight deepest fights on the card and the ordering is the opposite of football:
| Book | Median first quote | Range |
|---|---|---|
| bet365 | T-37.29 days | 22.21 to 77.92 days |
| draftkings | T-14.45 days | - |
| pinnacle | T-5.30 days | 1.97 to 5.34 days |
On a Serie A opener Pinnacle quotes 59 days out and the prediction markets arrive last. On boxing bet365 opens the market, sometimes more than two months out, and Pinnacle turns up around five days before the fight.
Late arrival is not idleness. On Itauma v Hrgovic, bet365 has been quoting since 62.33 days out and recorded 2,011 snapshots with 119 price changes. Pinnacle recorded 8 snapshots in five days and 6 of them were price changes. bet365 moves its price on roughly one snapshot in seventeen; Pinnacle moves on three in four. It shows up late, prices small, and then trades.
Two consequences for your code. Run a coverage audit a week out and you will report that boxing has no sharp benchmark, which is wrong by about five days. And if you are exporting history for a backtest, filter snapshots to createdAt < startTime, because the recorder keeps running through the fight and in-play prices will poison a closing line.
What this gets you
Up to 142 bookmakers on a marquee fight, collapsing to 45 independent prices once you dedupe, Pinnacle as the sharp benchmark, Kalshi as a near-zero-margin reference with real money behind it, a seven-rung round-total ladder, and price history back to the market open. All of it on the free tier, from one endpoint per job.
The catalogue runs to 350+ bookmakers across 69 sports, so the resolver you just wrote carries straight over. MMA uses the same two-way winner shape with method-of-victory markets on top. Football side markets use the same family-plus-handicap resolver. Anything you need in real time moves to the WebSocket feed instead of a polling loop.
Two honest limits. There is no scores endpoint, so the API will not grade a fight for you, though the final historical snapshot collapses toward 1.00 on the winner and is usable as a label. And boxing carries no method-of-victory or round-group markets on this board, only winner, draw and round totals.
Get your key
Stop scraping fight cards. Grab a free OddsPapi key, point the resolver above at sportId=21, and you will have every price on the card in about thirty seconds. No sales call, no enterprise tier, and historical odds included.
New to the API? Start with the free odds API guide. Comparing providers first? The 2026 odds API comparison covers the field. Want the margin maths in full, the vig calculator walks it step by step.
FAQ
Is there a free boxing odds API?
Yes. OddsPapi's free tier covers boxing under sportId=21, including Pinnacle, bet365, DraftKings, FanDuel and Kalshi, plus full price history through the historical-odds endpoint. A window sampled on 24 August 2026 returned 22 fights with odds across 147 distinct bookmakers, with 142 books on the deepest board and a median of 73.5 per fight.
Why does my parser only find half the bookmakers on a fight?
Boxing ships one bet under several market IDs. The winner market arrives as 211 and 201: across 22 fights sampled on 24 August 2026, market 211 carried 1,175 quotes from 96 books and market 201 carried 313 quotes from 31 books, so keying on 211 alone drops 21.0% of the winner board. The three-way 1X2 market is the worse offender, arriving as 213, 203 and 313, where keying on 213 alone drops 37.2%. Resolve on the market name from the markets endpoint and collect every ID that maps to it, then deduplicate on the price tuple, because 62.5% of the bookmaker rows on the winner market are duplicate feeds.
What is the difference between Winner and 1X2 in boxing?
Winner is the two-way market with no draw. 1X2 is the three-way market that prices the draw. Across the 24 August 2026 sample the Winner family appeared on 132 books and the 1X2 family on 108. They are separate bets, so never merge them when calculating margin.
Why are Total Rounds and Over Under Rounds both in the payload?
They are the same bet under two names. On Itauma v Hrgovic the 6.5-round line arrived as market 2118 on 68 books and as market 2018 on 32 books. Map both names to one family, keyed on the family and the handicap, before you count books or build a consensus.
Should I filter on the marketActive flag?
No. Across 3,134 boxing market records sampled on 24 August 2026, 713 carried marketActive set to false while every price inside them was live, and another 116 carried it as true with every price dead. Filtering on it deletes real markets. Use the price-level active flag instead.
Does Pinnacle price boxing?
Yes, on 17 of the 22 fights sampled on 24 August 2026, at a median winner-market margin of 4.34%, which ranks 4th of the 117 books measured. It arrives a median 5.3 days before the fight rather than weeks out, and its stake limit implies a flat 125 dollar base on every fight regardless of billing, so treat the limit as a sport-level ceiling and not a per-fight confidence signal.