Volleyball Odds API: 214 Books Price a EuroVolley Fixture
What a Volleyball Odds API Returns on a Real Fixture
France v Romania, CEV EuroVolley, two hours before first serve. One call to /odds returned 214 bookmakers and 20 distinct market IDs: match winner, five individual set winners, eleven set handicap rungs and three total-sets lines. Pinnacle sat on the board at 1.168 for France with a limit of 4,464 on the Romania side.
Three days out, a German 2. Bundesliga fixture on the same feed returned 14 books and 3 markets.
Both numbers are true, and the gap between them is the thing nobody tells you before you scope a volleyball project. Board width tracks the tier of the competition and the clock. Write your code for the fixture you actually plan to trade, then check it against the tier you actually plan to cover.
Board Width Across One Snapshot
Every priced volleyball fixture on the board at 15:00 UTC on 16 September 2026, pre-match only:
| Competition | Fixture | Time to start | Books | Market IDs |
|---|---|---|---|---|
| CEV EuroVolley | France v Romania | 2h | 214 | 20 |
| CEV EuroVolley | Poland v Bulgaria | 1h | 212 | 20 |
| CEV EuroVolley | Netherlands v Estonia | 2h | 209 | 20 |
| CEV EuroVolley | Czechia v Slovenia | 4h | 142 | 20 |
| South American Championship | Argentina v Colombia | 2h | 151 | 15 |
| Pan American Cup, Women | USA v Canada | 11h | 107 | 19 |
| Pan American Cup, Women | Guatemala v Venezuela | 7h | 82 | 20 |
| 2. Bundesliga (GER) | TUS Mondorf v Etv Hamburg | 74h | 19 | 3 |
| 2. Bundesliga (GER) | MLK Volleys Koln v Usc Braunschweig | 73h | 14 | 3 |
Pinnacle priced 9 of the 17 pre-match fixtures in that snapshot. It anchors the national-team boards and skips the German second tier, which matches how it treats lower divisions in other sports.
The Volleyball Market Board Is 20 Definitions
Volleyball carries 20 market definitions in the feed, across 8 families. That is the whole board, and the EuroVolley fixtures above carried all 20 of them. Counts are the number of books posting each market on France v Romania:
| Market ID | Market | Period | Books |
|---|---|---|---|
231 |
Winner | result |
203 |
233 / 235 / 237 / 239 / 2311 |
First through Fifth Set Winner | p1 to p5 |
170 down to 43 |
2313 / 2315 / 2317 |
Total Sets Over Under 3.5 / 4.0 / 4.5 | result |
171 / 45 / 168 |
2319 to 2339 |
Set Handicap, 11 rungs from -2.5 to +2.5 | result |
147 at -1.5 |
Two things to know before you hardcode anything. The set handicap ladder carries whole-number rungs (-2.0, -1.0, 0.0, +1.0, +2.0) alongside the half rungs, and a whole rung can push. The individual set winners carry a period of p1 through p5 while everything else is result, so group on period and not on the market name.
Step 1: Find the Tournaments That Have Fixtures
/tournaments needs sportId. Leave it out and you get MISSING_PARAMETER rather than the full list.
import os, requests
API_KEY = os.environ["ODDSPAPI_API_KEY"]
BASE_URL = "https://api.oddspapi.io/v4"
tournaments = requests.get(
f"{BASE_URL}/tournaments",
params={"apiKey": API_KEY, "sportId": 23},
).json()
live = [t for t in tournaments
if t["upcomingFixtures"] or t["futureFixtures"] or t["liveFixtures"]]
live.sort(key=lambda t: t["futureFixtures"] + t["upcomingFixtures"], reverse=True)
print(f"{len(tournaments)} volleyball tournaments, {len(live)} with fixtures on the board")
for t in live[:8]:
print(f' {t["tournamentId"]:>6} {t["tournamentName"][:38]:40} '
f'future={t["futureFixtures"]:>4} upcoming={t["upcomingFixtures"]}')
That returned 313 tournaments, 58 of them holding fixtures: NCAA Women’s Regular Season (778 scheduled), the German 2. Bundesliga (326), Italy’s Serie A2 Women (272) and Japan’s SV League Women (263), which opens on 10 October.
A scheduled fixture is not a priced fixture. Sportsbooks open a volleyball board somewhere between a few days and a few hours out, so a fixture list that runs months ahead will sit at hasOdds: false until the books arrive. Check the flag, then call /odds to find out how wide the board really is.
Step 2: Measure the Board, Don’t Assume It
import os, requests, time
from datetime import date, timedelta
API_KEY = os.environ["ODDSPAPI_API_KEY"]
BASE_URL = "https://api.oddspapi.io/v4"
def call(path, **params):
"""A 429 body is valid JSON, so check the error key before you read a payload."""
for _ in range(5):
payload = requests.get(f"{BASE_URL}/{path}",
params={"apiKey": API_KEY, **params}, timeout=120).json()
error = payload.get("error") if isinstance(payload, dict) else None
if error and error["code"] == "RATE_LIMITED":
time.sleep(error.get("retryMs", 2000) / 1000 + 0.5)
continue
return payload
raise RuntimeError("rate limited five times")
today = date.today()
fixtures = call("fixtures", sportId=23,
**{"from": today.isoformat(),
"to": (today + timedelta(days=9)).isoformat()})
priced = [f for f in fixtures if f["hasOdds"]]
print(f"{len(fixtures)} fixtures in the window, {len(priced)} carry odds\n")
for fixture in priced:
time.sleep(1.0)
books = call("odds", fixtureId=fixture["fixtureId"])["bookmakerOdds"]
market_ids = {mid for b in books.values() for mid in b["markets"]}
print(f'{len(books):>4} books {len(market_ids):>2} markets '
f'pinnacle={"pinnacle" in books!s:5} '
f'{fixture["participant1Name"]} v {fixture["participant2Name"]}')
Two gotchas are baked into that snippet. /odds takes a fixtureId and returns a single object keyed on bookmakerOdds; pass sportId instead and you get a 400. And a rate-limit response is a 200-shaped JSON body with an error key, so a script that reads bookmakerOdds straight out of the payload will read a throttle as an empty board.
Step 3: Read the Market Definitions
/markets takes sportId and ignores it. You get all 33,115 definitions in the feed, every sport, every rung. Filter on the sportId field yourself.
definitions = requests.get(f"{BASE_URL}/markets", params={"apiKey": API_KEY}).json()
volleyball = {str(m["marketId"]): m for m in definitions if m["sportId"] == 23}
print(f"{len(definitions)} market definitions in the feed, {len(volleyball)} of them volleyball")
FIXTURE = "id2300062465840714" # France v Romania, CEV EuroVolley
books = requests.get(f"{BASE_URL}/odds",
params={"apiKey": API_KEY, "fixtureId": FIXTURE}).json()["bookmakerOdds"]
posted = {}
for book in books.values():
for market_id in book["markets"]:
posted[market_id] = posted.get(market_id, 0) + 1
for market_id, count in sorted(posted.items(), key=lambda kv: -kv[1]):
d = volleyball[market_id]
line = f' {d["handicap"]:+.1f}' if d["marketName"] in ("Set Handicap", "Total Sets Over Under") else ""
print(f' {market_id:>5} {count:>3} books {d["marketName"]}{line} [{d["period"]}]')
Step 4: Guard the Quote Before You Rank It
214 books is the payload. It is not the number of prices you can bet into.
Run the set handicap at -1.5 through four checks and the field drops to 135. Sixty-seven books were suspended or had the market switched off. Eleven posted two legs whose changedAt stamps sat hours apart, which is a quote that moved on one side and went stale on the other. One shipped half the market: 1xbet returned First Set Winner with a single outcome attached, and a one-legged market computes a margin of minus 12.74%, which is how a naive scanner invents an arb that was never on the screen.
import os, requests, statistics
from collections import Counter
from datetime import datetime
API_KEY = os.environ["ODDSPAPI_API_KEY"]
BASE_URL = "https://api.oddspapi.io/v4"
FIXTURE = "id2300062465840714" # France v Romania, CEV EuroVolley
MARKET = "2323" # Set Handicap -1.5
definitions = {str(m["marketId"]): m
for m in requests.get(f"{BASE_URL}/markets", params={"apiKey": API_KEY}).json()
if m["sportId"] == 23}
legs_expected = definitions[MARKET]["marketLength"]
books = requests.get(f"{BASE_URL}/odds",
params={"apiKey": API_KEY, "fixtureId": FIXTURE}).json()["bookmakerOdds"]
quotes, dropped = {}, Counter()
for slug, book in books.items():
market = book["markets"].get(MARKET)
if book["suspended"] or not market or not market["marketActive"]:
dropped["suspended or market off"] += 1
continue
prices, stamps = {}, []
for outcome_id, outcome in market["outcomes"].items():
price = outcome["players"]["0"]
if price["active"] and price["price"] > 1.0:
prices[outcome_id] = price["price"]
stamps.append(datetime.fromisoformat(price["changedAt"].replace("Z", "+00:00")))
# A book can ship half a market. Count against the definition, not against what arrived.
if len(prices) != legs_expected:
dropped["partial market"] += 1
continue
# Legs repriced hours apart are not a quote you can bet into.
if (max(stamps) - min(stamps)).total_seconds() > 600:
dropped["stale leg"] += 1
continue
quotes[slug] = prices
print(f"{len(books)} books on the fixture, {len(quotes)} quote a complete {MARKET}")
for reason, count in dropped.items():
print(f" dropped {count}: {reason}")
margins = {s: sum(1 / v for v in p.values()) - 1 for s, p in quotes.items()}
print(f"\nmedian margin {statistics.median(margins.values()) * 100:.2f}%, "
f"tightest {min(margins, key=margins.get)} at {min(margins.values()) * 100:.2f}%")
tuples = Counter(tuple(sorted(p.items())) for p in quotes.values())
print(f"{len(quotes)} quotes, {len(tuples)} distinct price pairs, "
f"largest cluster {tuples.most_common(1)[0][1]} books")
for outcome_id in sorted(next(iter(quotes.values()))):
slug, price = max(((s, p[outcome_id]) for s, p in quotes.items()), key=lambda x: x[1])
print(f" outcome {outcome_id}: best {price} at {slug}, "
f"pinnacle {quotes.get('pinnacle', {}).get(outcome_id)}")
Output on the EuroVolley fixture:
214 books on the fixture, 135 quote a complete 2323
dropped 67: suspended or market off
dropped 11: stale leg
dropped 1: partial market
median margin 7.80%, tightest asports.bet at 4.75%
135 quotes, 24 distinct price pairs, largest cluster 33 books
outcome 2323: best 1.416 at asports.bet, pinnacle 1.416
outcome 2324: best 3.15 at winbet.bg, pinnacle 2.93
Now dedupe. Those 135 guarded quotes carry 24 distinct price pairs, and the largest single cluster is 33 slugs posting the same two numbers. cloneOf does not catch this; independent brands land on identical prices because they buy the same feed. Collapse on the price tuple before you average anything or claim a book count, or your consensus is one trading desk counted 33 times.
On match winner the same fixture ran 203 books, a median margin of 6.95%, and 54 distinct price pairs. Betfair’s exchange was tightest at 3.26%, Pinnacle next at 4.81% with limits of 750 and 4,464.
Step 5: Check the Participant Order
One more guard, and it costs nothing to run. On Sweden v Slovakia earlier the same day, 199 books quoted match winner and 19 of them had the two participants the other way round: 180 books had Sweden around 1.54, those 19 had the same outcome ID at 2.02 to 2.95. Take the top price off that board without checking and you have backed Slovakia while your spreadsheet says Sweden.
Anchor on the majority, or on Pinnacle when it is present, and drop the books that disagree about which side is favourite:
from collections import Counter
favourite = Counter(min(p, key=p.get) for p in quotes.values()).most_common(1)[0][0]
agreed = {s: p for s, p in quotes.items() if min(p, key=p.get) == favourite}
print(f"{len(quotes) - len(agreed)} books list the participants in the opposite order")
Free Historical Volleyball Odds
Test coverage on a fixture that has been played. A live /odds call outside the market-open window reports a false zero, and for a sport where books open the board hours rather than weeks ahead, that is most of the calendar.
Poland v Serbia, CEV EuroVolley Women, played on 6 September. Pinnacle shipped 11 markets, 798 snapshots and 617 price moves on that one fixture:
PLAYED = "id2300062374375050" # Poland v Serbia, 6 Sep 2026
payload = requests.get(f"{BASE_URL}/historical-odds",
params={"apiKey": API_KEY, "fixtureId": PLAYED,
"bookmakers": "pinnacle"}).json()
# /historical-odds keys on "bookmakers". /odds keys on "bookmakerOdds".
series = payload["bookmakers"]["pinnacle"]["markets"]["231"]["outcomes"]
for outcome_id, outcome in series.items():
snapshots = outcome["players"]["0"]
moves = [s for i, s in enumerate(snapshots)
if i == 0 or s["price"] != snapshots[i - 1]["price"]]
live = [s for s in snapshots if s["active"]]
limits = [s["limit"] for s in snapshots if s["limit"]]
print(f'outcome {outcome_id}: {len(snapshots)} snapshots, {len(moves)} price moves')
print(f' open {snapshots[0]["price"]:>6} at {snapshots[0]["createdAt"]}')
print(f' close {live[-1]["price"]:>6} at {live[-1]["createdAt"]}')
print(f' limit {min(limits):.0f} to {max(limits):.0f}')
Pinnacle opened the match at 21:00:41 on 5 September, sixteen hours before first serve, with Poland at 2.77 and Serbia at 1.454. It closed Poland 2.08 and Serbia 1.763. The limit on the Serbia side climbed from 55 to 1,132 across those sixteen hours, which is the shape you want for a closing-line study: the small early number is a placeholder, and the real money arrives late.
Two other books on the same fixture: 1xbet logged 2,160 snapshots and 1,125 price moves, betano 1,430 and 454. Collapse consecutive identical prices before you count repricing. The feed stores a snapshot whether or not the number moved, and the inflation factor is specific to the book, so a raw snapshot count ranks the poller rather than the trading desk. The same lesson shows up on bet365 historical odds, where 230,515 snapshots collapse to 29,569 real moves.
Live Volleyball Odds Over WebSocket
A volleyball set turns over in minutes and the set handicap moves with it. Polling every thirty seconds across a 200-book board burns your request budget and still misses the move. The WebSocket feed pushes each price change as the book publishes it, which is the only sane way to track the p1 to p5 set markets while a match is running.
One caveat that applies in play and not pre-match: bookmakerIsActive is not a liveness flag once the match starts. Read the price-level active flag and changedAt instead.
FAQ
What sport ID is volleyball in OddsPapi?
Volleyball is sportId: 23. Beach volleyball is a separate sport at sportId: 30.
How many bookmakers price a volleyball fixture?
It depends on the tier and how close the fixture is. A CEV EuroVolley national-team fixture measured two hours out on 16 September 2026 carried 214 bookmakers and all 20 volleyball markets. A German 2. Bundesliga fixture three days out carried 14 books and 3 markets. Call /odds on the fixture you care about rather than working from a headline number.
How many markets are available per volleyball match?
Volleyball carries 20 market definitions: match winner (231), the five individual set winners (233, 235, 237, 239, 2311), three total-sets lines (2313, 2315, 2317) and eleven set handicap rungs (2319 to 2339). The EuroVolley fixtures above carried all 20.
Which bookmakers cover volleyball?
Pinnacle anchors the national-team boards and posted 1.168 against 5.21 with limits up to 4,464 on France v Romania. Betfair’s exchange was tightest on that fixture at 3.26%. The rest of the 214 include SBOBet, 1xbet, bwin, unibet, draftkings and the crypto cohort led by bcgame, which posted all 20 markets.
Does OddsPapi cover NCAA volleyball?
NCAA Women’s Regular Season is tournament ID 43847 and held 778 scheduled fixtures when this post was measured. Bookmaker coverage follows the sportsbooks rather than the schedule, so read hasOdds on the fixture and then call /odds to see the board.
Can I get historical volleyball odds?
Yes, on the free tier. Pinnacle’s board on Poland v Serbia carried 798 snapshots across 11 markets, opening sixteen hours before first serve at 2.77 and closing at 2.08. Use /historical-odds on a played fixture, and note that it keys on bookmakers where /odds keys on bookmakerOdds.
Is there a free tier for volleyball data?
Yes. The free tier includes 250 requests a month, and historical odds are included at no extra cost.
Start Pulling Volleyball Odds
313 volleyball tournaments, a 20-market board, Pinnacle on the national-team fixtures, free historical odds and a WebSocket feed for the in-play set markets. The guards in step 4 matter more here than in soccer, because volleyball boards open late and half of what arrives is parked.
Get your free API key and run the snippets above against tonight’s fixtures. No credit card, no sales call.
Related reading: building a consensus line, line shopping across books, and how many bookmakers actually price a fixture.