MLS Odds API: Live Major League Soccer Odds & Goalscorer Props (Python)
MLS has no public odds API. Major League Soccer sells its data rights, the league app shows you one price, and the generic sports APIs that do carry soccer bury MLS somewhere under 1,300 other competitions with a thin book list. If you want every price on a Sunday night Inter Miami match in one JSON call, you have to aggregate it yourself or find someone who already did.
This guide pulls live MLS odds in Python: match result, totals, the full Asian handicap ladder, and anytime goalscorer props with real player names. Every number below came off the live API on a real MLS fixture before this post went up, including one finding that changes how you should count your bookmakers.
Why MLS is a good league to build on
MLS sits in an unusual spot. It is a soccer league priced mostly by US sportsbooks, which means the books quoting it are DraftKings, FanDuel, BetMGM, BetRivers and friends rather than the European soft books that dominate Premier League markets. Pinnacle prices it too, so you get a sharp anchor to measure everyone else against.
On the fixture used throughout this guide, 13 bookmakers were on the board and Pinnacle alone priced 37 separate markets. Across the league, 30 MLS fixtures carried odds in a single ten-day window.
| The old way | OddsPapi |
|---|---|
| Scrape each sportsbook’s MLS page separately | One call returns every book on the fixture |
| No sharp reference price | Pinnacle priced on every MLS fixture checked |
| Goalscorer props locked behind the app | Anytime scorer with player names in the JSON |
| Pay for historical odds | Free price history on the free tier |
| Count 13 books, assume 13 opinions | Detect duplicate quotes and count real ones |
Step 1: Authenticate and find MLS
Grab a free API key. The key goes in the query string, not a header. MLS lives under soccer, which is sportId 10, and the tournament name in the feed is exactly MLS.
import requests
API_KEY = "YOUR_API_KEY"
BASE_URL = "https://api.oddspapi.io/v4"
def api_get(path, **params):
params["apiKey"] = API_KEY # query param, never a header
r = requests.get(f"{BASE_URL}{path}", params=params, timeout=30)
r.raise_for_status()
return r.json()
fixtures = api_get("/fixtures", sportId=10,
**{"from": "2026-07-18", "to": "2026-07-27"})
mls = [f for f in fixtures
if f.get("tournamentName") == "MLS" and f.get("hasOdds")]
print(len(mls), "MLS fixtures with odds")
for f in mls[:5]:
print(f["fixtureId"], f["participant1Name"], "v", f["participant2Name"])
That returned 30 MLS fixtures with odds. The hasOdds filter matters: fixtures without it return metadata only and no bookmakerOdds key. Note that team names live on participant1Name and participant2Name in the /fixtures response, not in the odds payload, which carries participant IDs only.
Step 2: Pull the match result market
The worked example is Inter Miami CF against Chicago Fire, fixture id1000024266299252. Full Time Result is market 101, with outcomes 101 home, 102 draw, 103 away.
FID = "id1000024266299252"
odds = api_get("/odds", fixtureId=FID)
books = odds["bookmakerOdds"]
def price(slug, market_id, outcome_id):
node = (books.get(slug, {}).get("markets", {})
.get(market_id, {}).get("outcomes", {}).get(outcome_id, {}))
player = node.get("players", {}).get("0")
if not player or node.get("active") is False:
return None
return player.get("price")
for slug in sorted(books):
h, d, a = (price(slug, "101", o) for o in ("101", "102", "103"))
if h:
print(f"{slug:20} {h:6} {d:6} {a:6}")
Filter on active is False rather than testing whether active is true. The live feed sometimes ships active: null next to a perfectly good price, and a truthy check silently throws those away.
| Bookmaker | Inter Miami | Draw | Chicago Fire |
|---|---|---|---|
| pinnacle | 1.99 | 4.29 | 3.15 |
| kalshi | 2.041 | 4.545 | 3.226 |
| polymarket | 2.00 | 4.545 | 3.333 |
| draftkings | 1.95 | 4.10 | 3.00 |
| fanduel | 1.95 | 4.00 | 3.30 |
| bet365 | 1.90 | 4.00 | 3.30 |
| betrivers | 1.89 | 4.20 | 3.30 |
| ballybet / betparx / fourwinds | 1.90 | 4.20 | 3.35 |
| betmgm / borgata | 1.52 | 4.75 | 4.80 |
Step 3: Count your real bookmakers, not your slugs
Look at the last two rows again. Three slugs quoted 1.90 / 4.20 / 3.35 and two quoted 1.52 / 4.75 / 4.80. Those are not near-misses, they match to the decimal. Checking six MLS fixtures in a row, ballybet, betparx and fourwinds were identical on all six, and betmgm and borgata were identical on all six.
The bookmaker catalog does not tell you this. All five carry cloneOf: null in /v4/bookmakers, so the only way to find it is to compare prices yourself. Thirteen slugs on this fixture collapse to ten independent quotes.
from collections import defaultdict
groups = defaultdict(list)
for slug in books:
quote = tuple(price(slug, "101", o) for o in ("101", "102", "103"))
if None not in quote:
groups[quote].append(slug)
print(len(books), "slugs ->", len(groups), "distinct quotes")
for quote, slugs in groups.items():
if len(slugs) > 1:
print(" duplicate:", quote, slugs)
# 13 slugs -> 10 distinct quotes
# duplicate: (1.9, 4.2, 3.35) ['betparx', 'ballybet', 'fourwinds']
# duplicate: (1.52, 4.75, 4.8) ['betmgm', 'borgata']
This matters if you build a consensus price. Averaging 13 quotes when three of them are the same feed weights that opinion triple. Deduplicate first, then average. The same trap shows up whenever you compute consensus odds across many books.
Step 4: De-vig Pinnacle for a fair price
Pinnacle is the sharp reference. Strip its margin to get a fair probability for each outcome.
quote = {o: price("pinnacle", "101", o) for o in ("101", "102", "103")}
implied = {k: 1 / v for k, v in quote.items()}
total = sum(implied.values())
print(f"overround {(total - 1) * 100:.2f}%")
for k, label in [("101", "Inter Miami"), ("102", "Draw"), ("103", "Chicago")]:
print(f"{label:12} fair prob {implied[k]/total:.4f} fair odds {total/implied[k]:.4f}")
# overround 5.31%
# Inter Miami fair prob 0.4772 fair odds 2.0956
# Draw fair prob 0.2214 fair odds 4.5177
# Chicago fair prob 0.3015 fair odds 3.3172
Pinnacle carried a 5.31% margin on this three-way market. Fair prices land at 2.0956 on Inter Miami, 4.5177 on the draw and 3.3172 on Chicago. If you want the proportional, power and Shin methods compared, the no-vig odds guide walks through all three.
Step 5: Sanity-check the outliers before you trust them
Run a naive best-price scan and Chicago Fire comes back at 4.80 from BetMGM against a Pinnacle fair price of 3.3172. That looks like a 45% edge. It is not.
The free historical endpoint explains it. Pinnacle opened Inter Miami at 1.684 and drifted to 1.99 across 17 recorded price points, so the market moved substantially away from Miami. BetMGM and its twin still sat at 1.52, shorter than Pinnacle’s opening number. That is the shape of a price that has not caught up, and the 4.80 on the other side is the mirror of the same lag.
hist = api_get("/historical-odds", fixtureId=FID,
bookmakers="pinnacle,draftkings,fanduel") # max 3 per call
snaps = (hist["bookmakers"]["pinnacle"]["markets"]["101"]
["outcomes"]["101"]["players"]["0"]) # a LIST, not a dict
print(len(snaps), "snapshots",
snaps[0]["price"], "->", snaps[-1]["price"])
# 17 snapshots 1.684 -> 1.99
Two rules follow. Compare any outlier against the sharp book’s price history before calling it value, and remember that a stale line is usually unavailable by the time you click it. Note also that the historical endpoint nests under bookmakers rather than bookmakerOdds, and players["0"] is a list of snapshots instead of a single price.
Step 6: Totals and the Asian handicap ladder
MLS carries the full native market tree. Pinnacle priced 37 markets on this one fixture, including Over/Under from 2.5 through 5.5 and Asian handicaps from -1.75 to +0.25 in quarter-goal steps.
| Market | ID | Handicap | Pinnacle price |
|---|---|---|---|
| Over/Under Full Time | 1010 | 2.5 | Over 1.328 / Under 3.31 |
| Over/Under Full Time | 1012 | 3.5 | Over 1.869 / Under 1.961 |
| Asian Handicap | 1064 | -1.0 | 2.77 / 1.458 |
| Asian Handicap | 1068 | -0.5 | 2.00 / 1.854 |
| Asian Handicap | 1072 | 0 | 1.558 / 2.48 |
The Over/Under 2.5 line came in at 1.328 and 3.31, a 5.51% margin. Do not hardcode these IDs across sports. Every handicap step is its own market ID, so resolve them from the catalog instead.
catalog = api_get("/markets", sportId=10)
lookup = {m["marketId"]: (m["marketName"], m.get("handicap")) for m in catalog}
for mid in books["pinnacle"]["markets"]:
name, handicap = lookup.get(int(mid), ("?", None))
if name == "Asian Handicap":
print(mid, name, handicap)
If Asian handicaps are the reason you are here, the cross-book Asian handicap guide goes deeper on settlement and quarter lines.
Step 7: Anytime goalscorer props
Anytime Goal Scorer is market 10730. Five books priced it on this fixture: BallyBet, BetParX, BetRivers, FourWinds and PointsBet. There is a parsing catch that will make the market look empty if you miss it.
On game lines the players dict has a single "0" key. On player props it is keyed by player id, and each entry carries a playerName in "Last, First" format. One outcome holds the entire squad.
market = books["ballybet"]["markets"]["10730"]
for outcome in market["outcomes"].values():
rows = []
for player_id, node in outcome["players"].items():
if player_id == "0": # skip the game-line key
continue
rows.append((node["playerName"], node["price"]))
for name, odds in sorted(rows, key=lambda r: r[1])[:6]:
print(f"{name:28} {odds}")
# Messi, Lionel 1.6
# Cuypers, Hugo 2.05
# Berterame, German 2.6
# Suarez, Luis 2.8
# Silvetti, Mateo 3.1
# Zinckernagel, Philip 3.2
That outcome held 26 players. Messi came back shortest at 1.60 to score at any time, Luis Suarez at 2.80. Loop the same parse across the five books that price the market and you have a goalscorer comparison nobody publishes in one place. The player props API guide covers the same keying pattern for NFL, NBA and MLB.
Step 8: Shop the line properly
Put the pieces together: dedupe the duplicate feeds, skip inactive outcomes, then take the best remaining price per outcome.
def best_price(books, market_id, outcome_id):
seen, offers = set(), []
for slug in books:
p = price(slug, market_id, outcome_id)
if p is None:
continue
quote = tuple(price(slug, market_id, o) for o in ("101", "102", "103"))
if quote in seen: # duplicate feed, count it once
continue
seen.add(quote)
offers.append((p, slug))
return sorted(offers, reverse=True)
for oid, label in [("101", "Inter Miami"), ("102", "Draw"), ("103", "Chicago")]:
top = best_price(books, "101", oid)[:2]
print(label, top)
Best available on Inter Miami was 2.041 at Kalshi, ahead of Polymarket at 2.00 and Pinnacle at 1.99. Both prediction markets beat every US retail book on that side. Those are the best prices on the board, which is a shopping result and not a claim that either is a profitable bet. For the general version across every sport, see line shopping in Python.
Frequently asked questions
Is there an official MLS odds API?
No. Major League Soccer does not publish a public odds API, and individual sportsbooks do not offer open odds endpoints either. Aggregating the books is the practical route, which is what the code above does through a single fixture call.
Which bookmakers price MLS?
On the fixture checked for this guide, 13 books were on the board: Pinnacle, Kalshi, Polymarket, DraftKings, FanDuel, BetMGM, Borgata, Bet365, BetRivers, BallyBet, BetParX, FourWinds and PointsBet. Pinnacle appeared on every MLS fixture sampled, which gives you a sharp reference price.
Does the API have MLS goalscorer props?
Yes. Anytime Goal Scorer is market 10730 and was priced by five books on the sample fixture, with 26 players and readable names in the payload. First and last goalscorer markets also appear, though on fewer books.
Why do some bookmakers show identical MLS odds?
Several slugs quote the same numbers to the decimal. Across six MLS fixtures, BallyBet, BetParX and FourWinds matched every time, as did BetMGM and Borgata. The catalog does not flag them as related, so compare quotes yourself and deduplicate before averaging books into a consensus.
Can I get historical MLS odds for backtesting?
Yes, on the free tier. The /historical-odds endpoint returns the recorded price history for a fixture, capped at three bookmakers per call. Pinnacle showed 17 price points on the sample fixture, tracing the line from 1.684 to 1.99.
Start pulling MLS prices
One call gives you 13 books, 37 Pinnacle markets, a full Asian handicap ladder and a goalscorer board with real names. The catalog runs to 350+ bookmakers across 69 sports, and the price history is free. Get your free API key and point it at this weekend’s fixtures. If you want the broader soccer picture beyond MLS, start with the football odds API guide.