Odds API for Market Makers: Size Moves Twice as Often as Price
A Price Without a Size Is Not a Quote
Every odds API will sell you a price. Open the docs of any feed vendor and you get decimal odds, American odds, fractional odds, a timestamp if you are lucky. For a bettor that is enough. For anyone quoting two-sided markets it is half the payload.
A market maker cannot act on 3.571 alone. The question is whether 3.571 stands in front of $133 or $16,453, because one of those is a quote and the other is a rounding error. That number exists in the OddsPapi feed. It is called limit, it is normalised across every venue that publishes one, and this post shows you how to pull it, how to read the depth ladder underneath it, and what it does that a price column cannot.
Everything below was measured against the live API on 14 September 2026, on a single Premier League fixture: Leeds United against Newcastle United, id1000001772221262, kick-off 19:00Z. One unfiltered /odds call on that fixture returned 286 bookmakers and 62.7 MB. Every number in this post comes out of that call or a second one ten minutes later.
The Headline: Size Moves Twice as Often as Price
Two snapshots of the same fixture, ten minutes apart, 14:22Z and 14:32Z. Seventy-five fully sized legs of the 1X2 market appear in both. Here is what changed between them:
| What moved | Legs | Share |
|---|---|---|
| Price changed | 14 | 19% |
| Stake limit changed | 27 | 36% |
| Limit moved while the price held still | 13 | 17% |
Read that last row again. On thirteen legs the price was byte-identical across both snapshots and the size behind it was not. A feed that ships prices alone reports nothing happened on those thirteen legs. Something very much happened.
The sharpest example is Polymarket on the away participant. At 14:22Z it quoted 3.333 with 9,126 behind it. At 14:32Z it quoted 3.333 with 1,022 behind it. The price did not move a tick. Eighty-nine percent of the size drained out from under it. ProphetX did the same thing on the same leg, 2,264 down to 695, price unchanged at 3.20.
It runs the other way too. Polymarket’s draw leg went 5,883 to 16,453 and Kalshi’s went 9,387 to 16,659, both at a frozen price. Size is the fast variable on an exchange board. Price is the slow one.
Why Line Shopping Alone Does Not Survive This
The standard pattern is to pull every book, sort by price and take the best number. On this fixture that pattern hands you Polymarket at 3.333 on the away participant at 14:32Z, which is the best available price, sitting on 1,022 of size. Meanwhile Betfair Exchange offers 3.20 on 4,205. If you need to move 4,000 the worse price is the only real one.
This is where a market-making read diverges from a line shopping read. Best price is a scalar. Best executable price is a function of the size you need to move.
| What you need | Building it yourself | OddsPapi |
|---|---|---|
| Stake limit per price | Scrape each book’s bet slip, different rules per venue | limit, normalised across every book that publishes one |
| Order book depth | Integrate Betfair, Polymarket, Kalshi and ProphetX APIs separately | exchangeMeta back and lay ladders in the same payload |
| Sharp reference price | Buy a second feed for Pinnacle | Pinnacle and SBOBET alongside the soft books |
| Quote age you can verify | Trust the vendor’s marketing number | Two timestamps on every price, subtract them yourself |
| Fill auditing after the fact | Build and run your own tick database | Free /historical-odds price timelines on every tier |
| Push rather than poll | Write a poller, miss every in-play move | WebSocket gateway at wss://v5.oddspapi.io/ws |
Step 1: Find the Books That Publish Size
Not every book tells you what it will take. The ones that do are the ones you price against: the sharp books, the exchanges and the prediction markets. Start by splitting the board.
import os, time, requests
API_KEY = os.environ["ODDSPAPI_API_KEY"]
BASE_URL = "https://api.oddspapi.io/v4"
def get_board(fixture_id, bookmakers=None):
"""One /odds call. Pass bookmakers to keep the payload small."""
params = {"apiKey": API_KEY, "fixtureId": fixture_id}
if bookmakers:
params["bookmakers"] = ",".join(bookmakers)
r = requests.get(f"{BASE_URL}/odds", params=params, timeout=300)
if r.status_code == 429:
time.sleep(r.json()["error"]["retryMs"] / 1000 + 0.5)
return get_board(fixture_id, bookmakers)
r.raise_for_status()
return r.json()["bookmakerOdds"]
def walk_prices(board):
"""bookmakerOdds -> markets -> outcomes -> players -> the price object."""
for slug, book in board.items():
for market_id, market in (book.get("markets") or {}).items():
for outcome_id, outcome in (market.get("outcomes") or {}).items():
for player_id, price in (outcome.get("players") or {}).items():
yield slug, market_id, outcome_id, price
def books_with_limits(board):
sized, unsized = [], []
for slug, book in board.items():
has = any(p.get("limit") is not None
for _, _, _, p in walk_prices({slug: book}))
(sized if has else unsized).append(slug)
return sorted(sized), sorted(unsized)
FIXTURE = "id1000001772221262" # Leeds United v Newcastle United
board = get_board(FIXTURE)
sized, unsized = books_with_limits(board)
print(f"bookmakers on fixture : {len(board)}")
print(f"publish a stake limit : {len(sized)}")
print(f"publish no limit : {len(unsized)}")
bookmakers on fixture : 286
publish a stake limit : 29
publish no limit : 257
Twenty-nine books, and the membership is the interesting part. The whole Pinnacle network is in. The whole Betfair network is in. Every exchange and prediction market on the fixture is in: Kalshi, Polymarket, Novig US, Prophet Exchange, SX Bet and 4casters. FanDuel is partial, carrying a limit on 179 of its 851 prices. Bet365 and DraftKings carry none.
That is the correct shape for this field. A retail book does not publish what it will take because its answer depends on who is asking. A sharp book publishes it because the number is the product.
Step 2: Read Every Ladder With One Function
Under the top-of-book price, every exchange and prediction market ships a depth ladder in exchangeMeta. Different venues built their books differently, so the field arrives in more than one arrangement. Write the reader once and you never think about it again.
def read_ladder(price):
"""Return (back, lay) as lists of {price, size}, best price first.
Handles every exchangeMeta arrangement in the v4 feed:
back / lay with {cents, limit, price, size} prediction markets
back / lay with {cents, limit, price} prophetx, no size key
availableToBack / Lay with {price, size} betfair network
null no ladder on this venue
"""
meta = price.get("exchangeMeta") or {}
if not isinstance(meta, dict):
return [], []
def rungs(*keys):
for key in keys:
levels = meta.get(key)
if isinstance(levels, list) and levels:
return [
{
"price": lv.get("price"),
# prophetx omits size, so fall back to limit
"size": lv.get("size", lv.get("limit")),
}
for lv in levels if isinstance(lv, dict)
]
return []
return rungs("back", "availableToBack"), rungs("lay", "availableToLay")
Three rules that will save you a debugging session. The ladder is live only: exchangeMeta is null on every historical snapshot, so for depth history you use the snapshot-level limit instead. Rung lists are ordered best price first, and the top of the back ladder equals the outcome’s main price. And tradedVolume on the Betfair network arrives as an integer on some rungs and a float on others inside the same fixture, so type it loosely.
Sportsbooks ship exchangeMeta: null, which is correct and expected. They have no order book to expose. If you want the two-sided view on a retail book you build it from the opposing outcome, not from this field.
Step 3: Rank the Board by Size Skew
Here is the measurement that changes how you read a board. Take every book quoting all three legs of the 1X2 with a live limit on each, then divide the widest leg by the thinnest.
def sized_market(board, market_id, outcome_ids):
"""Books quoting every leg active, with a limit on each."""
out = {}
for slug, book in board.items():
market = (book.get("markets") or {}).get(market_id)
if not market:
continue
legs = {}
for outcome_id in outcome_ids:
outcome = (market.get("outcomes") or {}).get(outcome_id) or {}
price = (outcome.get("players") or {}).get("0")
if not price or not price.get("active") or price.get("limit") is None:
legs = None
break
legs[outcome_id] = (price["price"], float(price["limit"]))
if legs and min(lim for _, lim in legs.values()) > 0:
out[slug] = legs
return out
def size_skew(legs):
"""Widest leg over thinnest. 1.0 means equal size on every outcome."""
limits = [lim for _, lim in legs.values()]
return max(limits) / min(limits)
MARKET, OUTCOMES = "101", ["101", "102", "103"] # soccer 1X2 fulltime
quotes = sized_market(board, MARKET, OUTCOMES)
for slug, legs in sorted(quotes.items(), key=lambda kv: -size_skew(kv[1])):
cells = " ".join(f"{p} @ {lim:,.0f}" for p, lim in legs.values())
print(f"{slug:18}{size_skew(legs):>7.1f}x {cells}")
Twenty-five of the 286 books clear that filter. The 14:22Z snapshot ranks them like this:
| Bookmaker | Leeds | Draw | Newcastle | Size skew |
|---|---|---|---|---|
| sx.bet | 2.353 @ 4,819 | 3.54 @ 114 | 3.226 @ 103 | 47.0x |
| novig.us | 2.353 @ 3,285 | 3.571 @ 133 | 3.226 @ 3,927 | 29.5x |
| polymarket | 2.381 @ 146,803 | 3.571 @ 5,883 | 3.333 @ 9,126 | 25.0x |
| betfair-ex | 2.38 @ 6,516 | 3.6 @ 17,357 | 3.25 @ 1,402 | 12.4x |
| kalshi | 2.381 @ 5,935 | 3.571 @ 9,387 | 3.226 @ 50,178 | 8.5x |
| prophetx | 2.38 @ 729 | 3.6 @ 301 | 3.2 @ 2,264 | 7.5x |
| paddypower | 2.30 @ 5,769 | 3.50 @ 3,000 | 3.25 @ 3,333 | 1.9x |
| pinnacle | 2.34 @ 10,000 | 3.50 @ 10,000 | 3.17 @ 10,000 | 1.0x |
Sort that table by price and the top six venues look like the same book. Every one of them quotes the home participant between 2.353 and 2.381. Sort by size and they are six different venues with six different appetites. Polymarket will take 146,803 on Leeds and 5,883 on the draw off the same screen.
Pinnacle is the control, and it is worth saying why. A flat 10,000 across all three legs is what a book with a real risk desk looks like: it has a position it is comfortable with and it will take that position on any outcome. That symmetry is also why Pinnacle stays the de-vig anchor for consensus fair value. Its 1X2 here runs a 2.85% overround, giving no-vig probabilities of 41.5% Leeds, 27.8% draw, 30.7% Newcastle.
Step 4: Watch Size Move While Price Holds Still
Run the board twice and diff it. This is the loop that produced the headline table at the top of this post.
def sized_legs(board, market_id, outcome_ids):
"""{(slug, outcome_id): (price, limit)} for every active, sized leg."""
out = {}
for slug, legs in sized_market(board, market_id, outcome_ids).items():
for outcome_id, (price, limit) in legs.items():
out[(slug, outcome_id)] = (price, limit)
return out
first = sized_legs(get_board(FIXTURE), MARKET, OUTCOMES)
time.sleep(600)
second = sized_legs(get_board(FIXTURE), MARKET, OUTCOMES)
both = set(first) & set(second)
price_moved = {k for k in both if first[k][0] != second[k][0]}
limit_moved = {k for k in both if first[k][1] != second[k][1]}
silent = limit_moved - price_moved
print(f"legs in both snapshots : {len(both)}")
print(f"price changed : {len(price_moved)}")
print(f"limit changed : {len(limit_moved)}")
print(f"limit moved, price did not : {len(silent)}")
for key in sorted(silent, key=lambda k: second[k][1] / first[k][1])[:4]:
slug, outcome_id = key
print(f" {slug:14} outcome {outcome_id} "
f"{first[key][1]:>10,.0f} -> {second[key][1]:>10,.0f}")
legs in both snapshots : 75
price changed : 14
limit changed : 27
limit moved, price did not : 13
polymarket outcome 103 9,126 -> 1,022
prophetx outcome 103 2,264 -> 695
prophetx outcome 101 729 -> 526
kalshi outcome 103 50,178 -> 38,536
Ten minutes of a quiet pre-match board and a third of the sized legs repriced their size. If your signal is built on price deltas you saw four of those thirteen events, because four of them also happened to move a tick. You missed the other nine.
This is the argument for the WebSocket gateway rather than a poll loop. A ten minute poll interval caught the net change on this fixture. It did not catch the path. On the v5 gateway limit changes arrive as ordinary odds updates on the odds channel, so every size move lands as its own message with its own cursor.
Step 5: Audit the Quote Age Yourself
Every price carries two timestamps. bookmakerChangedAt is the book’s own change time. changedAt is when OddsPapi observed it. Subtract them and you have the observation delay on that specific quote, computed from the payload, needing nobody’s word for it.
import statistics
from datetime import datetime
def parse(stamp):
return datetime.fromisoformat(stamp.replace("Z", "+00:00"))
def observation_delays(board, slug):
"""changedAt minus bookmakerChangedAt, per active price. Seconds."""
delays = []
for _, _, _, price in walk_prices({slug: board[slug]}):
if not price.get("active"):
continue
seen, moved = price.get("changedAt"), price.get("bookmakerChangedAt")
if seen and moved:
delays.append((parse(seen) - parse(moved)).total_seconds())
return delays
for slug in ["sx.bet", "betfair-ex", "draftkings", "kalshi", "pinnacle", "polymarket"]:
delays = observation_delays(board, slug)
if len(delays) >= 10:
print(f"{slug:14}{len(delays):>6} prices median {statistics.median(delays):.3f}s")
sx.bet 34 prices median 0.101s
betfair-ex 417 prices median 0.122s
draftkings 568 prices median 0.140s
kalshi 117 prices median 0.159s
pinnacle 135 prices median 0.284s
polymarket 154 prices median 0.388s
Read the median, not the tail. This is a pre-match board, so the upper percentiles are dominated by rungs nobody has touched for hours rather than by slow delivery. Note also that 73 of the 286 books publish bookmakerChangedAt on ten or more active prices, so this self-audit works on the books that timestamp themselves. For the rest you compare changedAt against your own receipt time.
If you want the vendor’s own figures rather than your own, the B2B documentation publishes per-book freshness bounds on GET /bookmakers: maxDelayLiveInSec, maxDelayPregameInSec and maxDelayPregameMainInSec.
One Trap Before You Build a Consensus
The board looks wider than it is. On this fixture 270 books quote a full active 1X2 and they collapse to 87 distinct price tuples, a 67.8% duplication rate. White-label platforms and crypto-casino groups share pricing engines, so a naive average weights one engine by however many skins it runs. The largest single group is 24 feeds quoting the identical 2.28 / 3.45 / 3.00.
from collections import Counter
def price_tuples(board, market_id, outcome_ids):
tuples = {}
for slug, legs in sized_market(board, market_id, outcome_ids).items():
tuples[slug] = tuple(p for p, _ in legs.values())
return tuples
all_quotes = {}
for slug, book in board.items():
market = (book.get("markets") or {}).get(MARKET)
if not market:
continue
legs = []
for outcome_id in OUTCOMES:
outcome = (market.get("outcomes") or {}).get(outcome_id) or {}
price = (outcome.get("players") or {}).get("0")
if not price or not price.get("active"):
legs = None
break
legs.append(price["price"])
if legs:
all_quotes[slug] = tuple(legs)
groups = Counter(all_quotes.values())
print(f"books quoting a full active 1X2 : {len(all_quotes)}")
print(f"distinct price tuples : {len(groups)}")
print(f"largest identical group : {groups.most_common(1)[0][1]} feeds")
books quoting a full active 1X2 : 270
distinct price tuples : 87
largest identical group : 24 feeds
Deduplicate on the price tuple before you average anything or quote a book count. The cloneOf field on /bookmakers identifies the venue families it knows about, including the Betfair agent network and the Pinnacle network, but it is a venue relationship rather than a pricing-engine fingerprint. The tuple is what you actually care about.
What This Post Does Not Cover
Three honest boundaries, because a pre-match snapshot is not a universal claim.
- This is pre-match. In-play size behaviour is a different study. Boards suspend, repost and reprice on a different clock.
- This is one fixture in one tournament. Exchange depth varies enormously by sport. Ladders that hold tens of thousands on a Premier League fixture hold single-digit dollars on a rugby union fixture. Measure your own sport before you size a strategy on it.
- Two venues need an entitlement.
betdexandmatchbookreturn HTTP 403 on a standard key. Plan that rather than discovering it in production.
Building on This
The pieces used here are all on the free tier: the full 350+ bookmaker catalogue, limit and exchangeMeta on every live board, and /historical-odds price timelines for fill auditing after the fact. Native market structures mean the Asian handicap and total ladders carry the same limit treatment as the 1X2, so the same four functions work on any market ID you point them at.
If you are running an automated strategy rather than researching one, the B2B feed is where this goes next: the WebSocket gateway pushes every size change as its own message with a resume cursor, and adds two flags this post could not use. staleOdds tells you the connection to a book is interrupted so its prices are unvalidated, and participantsRotated tells you a venue calls the other participant home, which reverses your outcome mapping and flips every handicap sign.
Get a free API key and run the Step 1 snippet against a fixture in your own sport. The only thing you need to change is the fixture ID.
Frequently Asked Questions
What is the difference between limit and size?
limit is the maximum stake still available to match at that price. size, inside an exchangeMeta rung, is the total resting at that rung including what is already matched. On a sportsbook there is no ladder, so limit is simply the maximum stake the book accepts.
Which bookmakers publish a stake limit?
On the fixture measured here, 29 of 286: the Pinnacle network, the Betfair network, and every exchange and prediction market present, including Kalshi, Polymarket, Novig US, Prophet Exchange, SX Bet and 4casters. FanDuel publishes one on part of its board. The number varies by fixture and by sport, so run the Step 1 snippet on your own fixture rather than assuming this one.
Is the order book ladder available on historical data?
No. exchangeMeta is live only and reads null on every historical snapshot. Historical depth is available one number wide through the snapshot-level limit field, which is enough to chart a liquidity ramp toward kick-off but not enough to reconstruct a full book.
How deep is the depth ladder?
Three rungs per side on the venues measured here, best price first, on both the back and the lay side. The top of the back ladder always equals the outcome’s main price.
Can I verify the feed latency myself?
Yes, on the books that timestamp their own changes. Subtract bookmakerChangedAt from changedAt on any active price and you have the observation delay for that quote. On the fixture measured here, 73 of 286 books publish bookmakerChangedAt on ten or more active prices.
Do I need the WebSocket feed for this?
Not to research it. Every measurement in this post came from the v4 REST API on a free key. You need the gateway when you want every size change delivered as it happens rather than the net change between two polls, which on this fixture was the difference between thirteen events and four.