Tennis Moneyline Odds API: Pull 174 Books per ITF Match
Looking for a tennis moneyline odds API that covers every level, from the ATP and WTA tours down to ITF and UTR, with pregame prices? OddsPapi returns the match winner for all of them from one endpoint. Call /v4/odds with a fixture ID and read market 121, the Winner. Every quote carries the decimal price and the American moneyline side by side, so a US-facing app needs no conversion step.
We measured the depth on 24 September 2026 across 76 pregame singles boards in eight tiers. The median ITF match drew 174 books on the moneyline. Challenger matches drew 237 and WTA Tour matches 239.5. The price gets wider as the tier drops: the median margin runs from 6.09% on the WTA Tour to 8.97% at ITF, and Pinnacle’s closing limit drops from 875 to 225. This guide shows how to find each tier, pull the moneyline, and pick a fair-value anchor that holds at every level.
Why an all-levels tennis feed is harder than it looks
Most tennis matches happen below the main tour. On the morning we measured, the API listed 203 pregame tennis matches with prices for the next three days. The ATP and WTA Tours held 38 of them. Challenger, WTA 125, ITF and UTR held 151. If your feed or your filter stops at the tour, you see about one match in five.
The lower tiers also break code that works fine on the ATP. Three things catch developers:
- ITF fixtures use a second ID scheme. Their IDs start with
pninstead ofid, and they ship withstatusNameandstatusIdset to null. A pregame filter onstatusName == "Pre-Game"drops every ITF match without an error. - The participant name order changes. ITF fixtures write “Astra Sharma”. The
idscheme writes “Yamamoto, Natsuki”. Join on participant IDs, never on names. - The sharp anchor loosens. Pinnacle’s margin and stake limit change by tier, so a fair-value model tuned on the tour misprices an ITF match.
| What an all-levels moneyline needs | What the v4 feed ships (measured 24 Sep 2026) |
|---|---|
| Every tier in one call pattern | 12 tennis categories from /v4/fixtures?sportId=12, ATP to UTR |
| Depth below the tour | Median 174 books on an ITF singles moneyline, 237 on Challenger |
| American odds | priceAmerican on every quote; it matched the decimal price on 22,783 of 22,783 legs |
| A sharp reference | Pinnacle, with a stake limit on each price |
| Prediction-market prices | kalshi and polymarket on 9 or 10 of 10 Challenger and ITF boards |
| Closing lines for backtests | /v4/historical-odds on played matches, ITF included, on the free tier |
Step 1: list the tennis tiers
Tennis is sport ID 12. /v4/fixtures returns every match in the window, and categoryName carries the tier. The key goes in the query string, never in a header. Define “pregame” as a start time that is still ahead, because the ITF records carry no status.
import os, time, requests
from collections import Counter
from datetime import datetime, timedelta, timezone
API_KEY = os.environ["ODDSPAPI_API_KEY"]
BASE = "https://api.oddspapi.io/v4"
def get(path, **params):
params["apiKey"] = API_KEY
for _ in range(4):
r = requests.get(f"{BASE}/{path}", params=params, timeout=120)
if r.status_code == 429:
time.sleep(r.json().get("error", {}).get("retryMs", 2000) / 1000 + 0.5)
continue
r.raise_for_status()
return r.json()
raise RuntimeError("rate limited")
now = datetime.now(timezone.utc)
fixtures = get("fixtures", sportId=12,
**{"from": now.strftime("%Y-%m-%d"),
"to": (now + timedelta(days=3)).strftime("%Y-%m-%d")})
def starts(f):
return datetime.fromisoformat(f["startTime"].replace("Z", "+00:00"))
# Pregame = the start time is still ahead. Do not filter on statusName:
# ITF fixtures ship with statusName and statusId set to null.
pregame = [f for f in fixtures if f["hasOdds"] and starts(f) > now]
tiers = Counter(f["categoryName"] for f in pregame)
for tier, n in tiers.most_common():
scheme = Counter(f["fixtureId"][:2] for f in pregame if f["categoryName"] == tier)
print(f"{tier:<26}{n:>4} id prefix: {dict(scheme)}")
Output on 24 September 2026:
UTR Men 39 id prefix: {'id': 39}
Challenger 33 id prefix: {'id': 33}
ITF Women 27 id prefix: {'pn': 27}
ATP 27 id prefix: {'id': 27}
UTR Women 19 id prefix: {'id': 19}
WTA 125K 17 id prefix: {'id': 17}
ITF Men 16 id prefix: {'pn': 16}
WTA 11 id prefix: {'id': 11}
Simulated Reality Women 4 id prefix: {'id': 4}
Simulated Reality 4 id prefix: {'id': 4}
Exhibition 4 id prefix: {'id': 4}
Billie Jean King Cup 2 id prefix: {'id': 2}
Two rows need a decision. Simulated Reality and Simulated Reality Women are virtual matches, so drop them from any model of real players. Exhibition and Billie Jean King Cup are real, but they run on their own calendar. Every other row is a tier you can price.
Step 2: pull the moneyline for one match
The snippet below takes the next six ITF matches, keeps the deepest board, and reads market 121. Outcome 121 is participant 1 and outcome 122 is participant 2. The catalogue names them “1” and “2”, so join back to the fixture for the player names.
Each quote passes four checks before it counts. The book is not suspended. Both legs carry active: true. Both prices sit above 1.0. The quote changed within six hours of the newest price that book posted on the same fixture, which catches a withdrawn market that still reads as active.
import os, time, statistics, requests
from datetime import datetime, timedelta, timezone
API_KEY = os.environ["ODDSPAPI_API_KEY"]
BASE = "https://api.oddspapi.io/v4"
def get(path, **params):
params["apiKey"] = API_KEY
for _ in range(4):
r = requests.get(f"{BASE}/{path}", params=params, timeout=120)
if r.status_code == 429:
time.sleep(r.json().get("error", {}).get("retryMs", 2000) / 1000 + 0.5)
continue
r.raise_for_status()
return r.json()
raise RuntimeError("rate limited")
def ts(s):
return datetime.fromisoformat(s.replace("Z", "+00:00"))
now = datetime.now(timezone.utc)
fixtures = get("fixtures", sportId=12,
**{"from": now.strftime("%Y-%m-%d"),
"to": (now + timedelta(days=2)).strftime("%Y-%m-%d")})
itf = sorted((f for f in fixtures
if f["categoryName"].startswith("ITF") and f["hasOdds"]
and ts(f["startTime"]) > now + timedelta(hours=1)),
key=lambda f: ts(f["startTime"]))
WINNER, P1, P2 = "121", "121", "122" # market 121 = Winner (moneyline)
def moneylines(board):
quotes = {}
for slug, book in board.items():
if "+" in slug or slug == "demo": # internal test feeds
continue
mkt = book.get("markets", {}).get(WINNER)
if not mkt:
continue
try:
a = mkt["outcomes"][P1]["players"]["0"]
b = mkt["outcomes"][P2]["players"]["0"]
except KeyError:
continue
newest = max(p["changedAt"] for m in book["markets"].values()
for o in m["outcomes"].values() for p in o["players"].values())
fresh = ts(newest) - ts(max(a["changedAt"], b["changedAt"])) < timedelta(hours=6)
live = (not book.get("suspended", False) and book.get("bookmakerIsActive", True)
and a["active"] and b["active"] and a["price"] > 1 and b["price"] > 1)
if live and fresh and 1 / a["price"] + 1 / b["price"] > 1:
quotes[slug] = (a["price"], b["price"], a["priceAmerican"], b["priceAmerican"],
a["limit"], b["limit"])
return quotes
best = None
for fx in itf[:6]: # keep the deepest of the next six ITF boards
time.sleep(1.0)
board = get("odds", fixtureId=fx["fixtureId"])["bookmakerOdds"]
quotes = moneylines(board)
if best is None or len(quotes) > len(best[2]):
best = (fx, board, quotes)
fx, board, quotes = best
print(fx["tournamentName"], "|", fx["participant1Name"], "v", fx["participant2Name"],
"|", fx["startTime"])
margin = lambda q: (1 / q[0] + 1 / q[1] - 1) * 100
distinct = {(q[0], q[1]) for q in quotes.values()}
print(f"books on the fixture: {len(board)}, tradeable moneylines: {len(quotes)}, "
f"distinct price pairs: {len(distinct)}")
print(f"median margin: {statistics.median(margin(q) for q in quotes.values()):.2f}%")
for slug in ("pinnacle", "bet365", "draftkings", "kalshi", "polymarket"):
if slug in quotes:
q = quotes[slug]
lim = f" limit {min(q[4], q[5]):,.2f}" if q[4] and q[5] else ""
print(f"{slug:<11}{q[0]:>7}{q[1]:>7} {q[2]:>5} / {q[3]:<5} {margin(q):5.2f}%{lim}")
Columbia | John Hallquist Lithen v Charlie Swaine | 2026-09-24T15:00:00.000Z
books on the fixture: 188, tradeable moneylines: 166, distinct price pairs: 33
median margin: 8.84%
bet365 1.068 7.0 -1471 / 600 7.92%
draftkings 1.071 7.0 -1408 / 600 7.66%
kalshi 1.099 8.333 -1010 / 733 2.99% limit 124.45
polymarket 1.124 7.143 -806 / 614 2.97% limit 2.80
The priceAmerican field is a string. It was blank on 47 of 22,830 legs in our census, and each of those 47 had a decimal price of exactly 1.0, a placeholder. The price > 1 check removes them. If you need fractional odds, priceFractional ships next to it; the odds converter guide covers the arithmetic if you want to derive them yourself.
Look at the gap between 166 tradeable quotes and 33 distinct price pairs. Many slugs are regional skins of one trading desk and post byte-identical prices. Dedupe on the price pair before you average anything, or one desk votes thirty times.
Step 3: measure every tier
This loop reuses moneylines() from Step 2 and samples the next three singles matches in each tier. Doubles boards are thinner and follow a different pattern, so the filter drops them.
# Reuses get(), ts(), fixtures and moneylines() from the step above.
from collections import defaultdict
TIERS = ["ATP", "WTA", "Challenger", "WTA 125K", "ITF Men", "ITF Women",
"UTR Men", "UTR Women"]
def singles(f):
return "Doubles" not in f["tournamentName"] and "/" not in f.get("participant1Name", "/")
rows = defaultdict(list)
for tier in TIERS:
picks = sorted((f for f in fixtures if f["categoryName"] == tier and f["hasOdds"]
and singles(f) and ts(f["startTime"]) > now + timedelta(minutes=45)),
key=lambda f: ts(f["startTime"]))[:3]
for fx in picks:
time.sleep(1.0)
quotes = moneylines(get("odds", fixtureId=fx["fixtureId"])["bookmakerOdds"])
if not quotes:
continue
pin = quotes.get("pinnacle")
rows[tier].append((len(quotes),
len({(q[0], q[1]) for q in quotes.values()}),
statistics.median(margin(q) for q in quotes.values()),
margin(pin) if pin else None,
min(pin[4], pin[5]) if pin and pin[4] and pin[5] else None))
med = lambda xs: statistics.median(xs) if xs else None
print(f"{'tier':<11}{'boards':>7}{'books':>7}{'pairs':>7}{'margin':>8}{'pinnacle':>10}{'pin limit':>11}")
for tier, r in rows.items():
pm = med([x[3] for x in r if x[3] is not None])
pl = med([x[4] for x in r if x[4] is not None])
print(f"{tier:<11}{len(r):>7}{med([x[0] for x in r]):>7}{med([x[1] for x in r]):>7}"
f"{med([x[2] for x in r]):>7.2f}%"
+ (f"{pm:>9.2f}%" if pm is not None else f"{'-':>10}")
+ (f"{pl:>11,.0f}" if pl is not None else f"{'-':>11}"))
tier boards books pairs margin pinnacle pin limit
ATP 3 196 49 6.36% 3.29% 162
WTA 3 212 53 5.81% 2.53% 3,000
Challenger 3 231 54 7.91% 4.66% 572
WTA 125K 3 178 37 7.60% 4.18% 875
ITF Men 3 128 26 9.01% 2.67% 225
ITF Women 3 141 33 9.04% 4.86% 200
UTR Men 3 106 25 8.90% - -
UTR Women 3 79 19 8.29% - -
A three-board sample moves around from run to run. For the numbers in this post we ran the same logic on 76 singles boards between 13:11 and 13:14 UTC on 24 September, ten per tier where ten existed:
| Tier | Boards | Hours to start | Books on the moneyline (median) | Tradeable quotes | Duplicate quotes | Median margin | Pinnacle margin |
|---|---|---|---|---|---|---|---|
| WTA | 6 | 13.8 to 17.3 | 239.5 | 235 | 76.5% | 6.09% | 3.12% |
| ATP | 10 | 15.8 to 37.8 | 163 | 163 | 74.8% | 6.61% | 3.40% |
| Challenger | 10 | 0.8 to 7.0 | 237 | 231 | 77.8% | 7.93% | 4.50% |
| WTA 125 | 10 | 0.8 to 20.8 | 216 | 210 | 77.0% | 7.76% | 4.31% |
| ITF Men | 10 | 0.8 to 12.8 | 174 | 163 | 78.3% | 8.97% | 4.72% |
| ITF Women | 10 | 1.8 to 12.8 | 170 | 151.5 | 77.8% | 8.93% | 5.55% |
| UTR Men | 10 | 0.8 to 17.8 | 93.5 | 92.5 | 77.1% | 8.90% | n/a |
| UTR Women | 10 | 0.8 to 24.3 | 27.5 | 24 | 70.8% | 8.65% | n/a |
“Duplicate quotes” is the share of tradeable quotes that repeat another book’s exact price pair. “Pinnacle margin” is the median over the boards where Pinnacle posted a tradeable price: 5 of 10 ATP, 6 of 6 WTA, 10 of 10 Challenger, 2 of 10 WTA 125, 6 of 10 ITF Men and 7 of 10 ITF Women.
The board fills in the last day. The ATP median of 163 is low because every ATP match we sampled was 16 to 38 hours away. One Chengdu match 37.8 hours out carried 4 books; one Hangzhou match 16.3 hours out carried 250. The UTR boards more than 17 hours out held a single polymarket.us quote, while the UTR boards inside 12 hours held a median of 82 books. Poll a match again in its final 12 hours before you judge its depth.
ITF depth is real depth. A median of 174 books on an ITF singles match beats the 163 we measured on ATP boards 16 or more hours out. bet365 quoted all 10 ITF Men boards, polymarket quoted all 20 ITF boards, and kalshi quoted 18.
The margin climbs about three points from the WTA Tour to ITF. The median book charges 6.09% on a WTA match and 8.97% on an ITF Men match. Line shopping matters more at the bottom of the sport. The line shopping guide has the best-price code.
About three quotes in four repeat another book’s price. The collapse holds at 71% to 78% on every tier, so a raw book count overstates the number of independent opinions by about four times.
Step 4: pick the fair-value anchor by tier
Pinnacle is the usual reference price. In tennis, its margin widens and its stake limit shrinks as the tier drops. The live boards showed that, and the closing prices on played matches confirm it. This snippet reads the closing moneyline on three ITF matches played the day before, from /v4/historical-odds, for three books at once:
# Reuses get() and ts() from above. Closing moneyline on ITF matches played yesterday.
yday = now - timedelta(days=1)
played = get("fixtures", sportId=12, **{"from": yday.strftime("%Y-%m-%d"),
"to": now.strftime("%Y-%m-%d")})
itf_played = [f for f in played if f["categoryName"].startswith("ITF")
and "/" not in f.get("participant1Name", "/")
and ts(f["startTime"]) < now - timedelta(hours=6)][:3]
def close(snaps, start):
pre = [s for s in snaps if ts(s["createdAt"]) < start and s["active"] and s["price"] > 1]
return max(pre, key=lambda s: s["createdAt"]) if pre else None
for fx in itf_played:
time.sleep(4.6) # /historical-odds needs a longer cooldown
hist = get("historical-odds", fixtureId=fx["fixtureId"],
bookmakers="pinnacle,bet365,draftkings")["bookmakers"]
start = ts(fx["startTime"])
print(fx["tournamentName"], "|", fx["participant1Name"], "v", fx["participant2Name"])
for slug, book in hist.items():
outs = book.get("markets", {}).get("121", {}).get("outcomes", {})
s1 = outs.get("121", {}).get("players", {}).get("0", [])
s2 = outs.get("122", {}).get("players", {}).get("0", [])
c1, c2 = close(s1, start), close(s2, start)
if not (c1 and c2):
continue
first = min(ts(s["createdAt"]) for s in s1 + s2)
lim = f" limit {min(c1['limit'], c2['limit']):,.0f}" if c1["limit"] and c2["limit"] else ""
print(f" {slug:<11} opened T-{(start - first).total_seconds() / 3600:4.1f}h "
f"closed {c1['price']:.3f} / {c2['price']:.3f} "
f"{(1 / c1['price'] + 1 / c2['price'] - 1) * 100:4.2f}%{lim}")
Nanao | Haruna Arakawa v Hiromi Abe
bet365 opened T-28.6h closed 7.000 / 1.068 7.92%
draftkings opened T-11.6h closed 7.000 / 1.071 7.66%
pinnacle opened T-27.9h closed 6.390 / 1.125 4.54% limit 225
Nanao | Kisa Yoshioka v Hikaru Sato
bet365 opened T-28.6h closed 2.370 / 1.500 8.86%
draftkings opened T-11.6h closed 2.250 / 1.541 9.34%
pinnacle opened T-27.9h closed 2.330 / 1.617 4.76% limit 225
Nanao | Jiangxue Han v Kurumi Tamura
bet365 opened T- 2.8h closed 1.270 / 3.400 8.15%
draftkings opened T-10.8h closed 1.250 / 3.650 7.40%
pinnacle opened T-12.4h closed 1.289 / 3.670 4.83% limit 225
The closing price is the last active snapshot before the start time. Snapshots keep recording in-play, so the createdAt < start filter matters. We ran the same close on five played singles matches per tier from 22 and 23 September:
| Tier (5 matches each) | Pinnacle close margin | Pinnacle close limit, smaller side | bet365 close margin | DraftKings close margin |
|---|---|---|---|---|
| ATP and WTA | 3.74% | 875 | 6.79% | 4.76% |
| Challenger | 4.31% | 875 | 8.75% | 6.48% |
| WTA 125 | 4.43% | 875 | 7.14% | 6.53% |
| ITF | 4.84% | 225 | 8.57% | 7.61% |
All values are medians. Pinnacle keeps the tightest close of the three books on every tier, but at ITF it takes about a quarter of the stake it takes on a Challenger match. A smaller limit means Pinnacle absorbs less money at its own number. Use two rules:
- ATP, WTA, Challenger and WTA 125: Pinnacle’s de-vigged price is a sound anchor. The no-vig guide shows three ways to strip the margin.
- ITF and UTR: de-vig the deduplicated consensus instead. With 30 to 35 distinct price pairs on a typical ITF board, the consensus carries more information than one book at a 225 limit. Pinnacle did not quote a UTR match in our sample, so the consensus is the only anchor there. The consensus odds calculator builds that number.
What to build with it
- A lower-tier value scanner. Run Step 3 every 15 minutes on matches inside 12 hours and flag any book priced above the deduplicated consensus. The three-point margin gap means soft prices show up more often at ITF than on the tour.
- A tennis rating model. The tennis Elo guide trains against closing odds. Step 4 gives you closes for ITF and Challenger players, who have far more matches than tour players.
- A Grand Slam pipeline. The US Open study covers the top of the sport, where the board reached 155 books a match in the first round.
For the full tennis market menu (game handicaps, set totals, first-set winner), start from the tennis odds API guide.
Tennis moneyline odds API FAQ
Is there an odds API for tennis at all levels with pregame moneylines?
Yes. OddsPapi’s /v4/odds returns the pregame match winner (market 121) for ATP, WTA, Challenger, WTA 125, ITF and UTR matches. On 24 September 2026 the median ITF singles match drew 174 books on that market.
Which market ID is the tennis moneyline?
Market 121, named “Winner” in /v4/markets, with outcome 121 for participant 1 and outcome 122 for participant 2. The set winners use separate IDs: 123 for the first set and 125 for the second.
Does the API return American odds for tennis?
Yes. Every price carries priceAmerican as a string next to the decimal price, plus priceFractional. In our census the American value matched the decimal price on 22,783 of 22,783 legs.
How do I tell an ITF match from an ATP match in the API?
Read categoryName on /v4/fixtures: “ITF Men”, “ITF Women”, “Challenger”, “ATP”, “WTA”, “WTA 125K”, “UTR Men” and “UTR Women”. ITF fixture IDs also start with pn, and they ship without a status, so filter pregame matches on startTime.
Can I get historical tennis moneyline odds for ITF matches?
Yes. /v4/historical-odds returns the full price history for played ITF matches on the free tier, up to three bookmakers a call. Pinnacle’s median closing margin across five ITF matches from 22 and 23 September 2026 was 4.84%.
Pull your first ITF moneyline
Every call in this guide runs on the free key: 350+ bookmakers in the catalogue, free historical odds for your closing-line backtests, and the same fixture IDs on the WebSocket feed when polling gets too slow for in-play. Get a free API key and run Step 2 on the next ITF match on the schedule.