LoL Odds API: 119 Books on the LEC, LCK and LPL Board
Riot does not publish a betting odds API. The esports data vendors that do will quote you an annual contract before they show you a payload. So developers building a League of Legends model end up scraping two or three sportsbooks and calling it a market.
That was a defensible shortcut a year ago. It is not one now. We pulled every LoL fixture on the OddsPapi feed on 27 August 2026 and counted 119 distinct bookmakers across 36 pre-match series, with a median of 63.5 books per fixture and 101 on the deepest LEC game.
The board is bigger than the football board most people assume is the ceiling. It is also shaped nothing like it, and the book you would reach for first is the wrong one.
The finding: Pinnacle is not the LoL benchmark
On soccer, on baseball, on the NFL, you anchor on Pinnacle and measure everything else against it. That habit fails on League of Legends in two ways at once.
First, coverage. Pinnacle priced 17 of the 36 pre-match series. Polymarket priced 35. Kalshi 30. 1xBet and 22Bet were on all 36. If your scanner filters to Pinnacle you throw away half the slate before you start.
Second, price. Across every book quoting at least 8 of the 36 series, here is the median two-way margin on the series Winner market.
| Rank | Bookmaker | Median Winner margin | Series priced (of 36) |
|---|---|---|---|
| 1 | polymarket.us |
1.51% | 22 |
| 2 | kalshi |
1.99% | 30 |
| 3 | polymarket |
2.00% | 35 |
| 4 | duel |
3.83% | 30 |
| 5 | roobet |
5.38% | 26 |
| 6 | betfair-ex |
6.35% | 13 |
| 7 | 1xbet |
6.35% | 36 |
| 8 | 22bet |
6.35% | 36 |
| 14 | pinnacle |
6.46% | 17 |
| 20 | betano |
7.05% | 31 |
| 21 | bet365 |
7.08% | 30 |
| 68 | stake |
8.07% | 32 |
| 76 | betway |
8.33% | 31 |
| 100 | netbet.co.uk |
17.50% | 32 |
Pinnacle charges 6.46% on a LoL series and 3 to 4% on a football match. Kalshi charges 1.99% on the same LoL series. Thirteen books beat Pinnacle, and eight of the top eight are exchanges, prediction markets or crypto books. Read our vig calculator guide if you want the margin arithmetic in full.
Kalshi being tighter does not make Kalshi sharper. The two venues price a LoL series for different reasons. What it does mean is that if you benchmark against Pinnacle alone on esports, you are benchmarking against a book that shows up half the time and charges twice its usual rate.
Old way vs OddsPapi
| Scraping 3 books | Esports data vendor | OddsPapi | |
|---|---|---|---|
| Books per series | 3 | 20-40 | Median 63.5, max 101 |
| Prediction markets | No | Rare | Kalshi, Polymarket, Betfair, SX Bet |
| Map-level markets | Build your own parser | Yes | Native, 8 families |
| Historical prices | Whatever you stored | Paid add-on | Free tier |
| Access | Cloudflare roulette | Sales call | Free API key |
| Format | HTML | JSON + contract | JSON |
Step 1: authenticate and find the sport
The key is a query parameter, never a header.
import requests, time, collections
API_KEY = "YOUR_API_KEY"
BASE_URL = "https://api.oddspapi.io/v4"
LOL = 18 # sportId for ESport League of Legends
def call(path, **params):
"""One request, honouring the documented 429 retry."""
for _ in range(6):
r = requests.get(f"{BASE_URL}{path}",
params={"apiKey": API_KEY, **params}, timeout=120)
if r.status_code == 429:
time.sleep(r.json()["error"]["retryMs"] / 1000 + 0.3)
continue
return r
return r
sports = call("/sports").json()
print([s for s in sports if s["sportId"] == LOL])
# [{'sportId': 18, 'slug': 'esport-league-of-legends', 'sportName': 'ESport League of Legends'}]
The free tier rate-limits per endpoint and returns a real 429 with a retryMs figure in the body. Sleep about a second between calls on the same endpoint and never run /odds concurrently. Four parallel workers get eleven of twelve requests rejected.
Step 2: discover circuits from /fixtures, not /tournaments
This is the trap that costs you 40% of the slate. The LoL catalogue holds 90 tournament rows, and each carries a futureFixtures count. On most sports that count is the right filter. On LoL it lies.
fixtures = call("/fixtures", sportId=LOL,
**{"from": "2026-08-27", "to": "2026-09-06"}).json()
priced = [f for f in fixtures if f.get("hasOdds")]
circuits = collections.Counter(
(f["tournamentId"], f["tournamentName"]) for f in priced)
for (tid, name), n in circuits.most_common():
print(f"{n:3d} {tid:6d} {name}")
# The /tournaments row disagrees:
rows = {t["tournamentId"]: t.get("futureFixtures", 0)
for t in call("/tournaments", sportId=LOL).json()}
blind = sum(n for (tid, _), n in circuits.items() if not rows.get(tid))
print(f"\n{blind} of {len(priced)} priced fixtures sit under a "
f"tournament row reporting futureFixtures = 0")
7 2452 LEC
4 39985 LPL
4 26698 CBLOL
4 2450 LCS
3 39009 NACL
2 26706 TCL
2 45855 Rift Legends
2 45985 Road of Legends
2 46121 Circuito Desafiante
1 2454 LCK
1 33814 Prime League
1 33678 LFL
1 45589 LCP
1 36997 LCK CL
1 46117 LRN
1 50586 LES
15 of 37 priced fixtures sit under a tournament row reporting futureFixtures = 0
LPL, LCK, LCS and CBLOL all report zero future fixtures. All four had priced games inside 48 hours. Go through /fixtures and group by tournamentName.
Two more things in that output. LPL exists twice in the catalogue (rows 15490 and 39985) and only 39985 carries fixtures, so match on the ID you saw in a real fixture rather than the name. And the tail of that list is the interesting part: Rift Legends, Road of Legends, LES and LRN are tier-2 and tier-3 circuits that a scraper aimed at LEC and LCK never sees.
Step 3: pull one board and read the market families
Market IDs and outcome IDs are integers. Names come from /markets. Do not hardcode the catalogue: it holds 32,815 rows and it is global rather than per-sport, so use it as a lookup.
catalogue = call("/markets", sportId=LOL).json()
MARKET_NAME = {m["marketId"]: m["marketName"] for m in catalogue}
HANDICAP = {m["marketId"]: m.get("handicap") for m in catalogue}
fx = next(f for f in priced if f["fixtureId"] == "id1803998573998788")
board = call("/odds", fixtureId=fx["fixtureId"]).json()["bookmakerOdds"]
print(f'{fx["participant1Name"]} v {fx["participant2Name"]} '
f'({fx["tournamentName"]}) - {len(board)} bookmakers')
families = collections.defaultdict(set)
for slug, book in board.items():
for market_id in book["markets"]:
families[MARKET_NAME.get(int(market_id), "?")].add(slug)
for name, slugs in sorted(families.items(), key=lambda x: -len(x[1])):
print(f" {name:45s} {len(slugs):3d} books")
Edward Gaming v Ninjas In Pyjamas (LPL) - 98 bookmakers
Winner 97 books
Maps Handicap 86 books
First Map Winner (incl. overtime) 83 books
Second Map Winner (incl. overtime) 82 books
Total Maps Over Under 81 books
Third Map Winner (incl. overtime) 78 books
Fourth Map Winner (incl. overtime) 33 books
Fifth Map Winner (incl. overtime) 29 books
Eight families and that is the whole menu. Across all 36 series we counted zero player props: every price on the LoL board is keyed players["0"], so the kill/assist markets you see on a book’s own site do not reach the aggregated feed.
What you get instead is the native series structure. A LoL match is a best-of-three or best-of-five, and the feed models it that way: a series Winner, a Maps Handicap ladder (-1.5 through +1.5, one market ID per rung), a Total Maps line at 2.5, 3.5 or 4.5, and a separate Winner market for each individual map with period set to p1 through p5. The Fifth Map Winner only carries 29 books because most books post it once a best-of-five is 2-2.
Winner is a single market ID on LoL. Market 181, outcome 181 for participant 1 and 182 for participant 2. Boxing splits the same bet across three IDs and rugby across two, so this is worth stating: on LoL you can key on 181 and lose nothing. The outcome names in the catalogue are literally "1" and "2", so join fixtureId back to /fixtures for team names.
Step 4: filter to usable quotes, then dedupe
98 books is the headline number and it is not the number you can bet into. Two filters cut it down.
WINNER = 181 # outcome 181 = participant 1, 182 = participant 2
def winner_quote(book):
"""Return (p1, p2) only when both legs are present AND active."""
market = book["markets"].get(str(WINNER))
if not market:
return None
legs = []
for outcome_id in ("181", "182"):
outcome = market["outcomes"].get(outcome_id)
if not outcome:
return None
price = outcome["players"]["0"] # game lines are always keyed "0"
if not price.get("active") or not price.get("price"):
return None
legs.append(float(price["price"]))
return tuple(legs)
quotes = {}
for slug, book in board.items():
if slug.startswith("pinnacle+") or slug == "demo": # internal test feeds
continue
q = winner_quote(book)
if q:
quotes[slug] = q
groups = collections.defaultdict(list)
for slug, q in quotes.items():
groups[q].append(slug)
print(f"{len(board)} books on the board")
print(f"{len(quotes)} with a complete, active Winner quote")
print(f"{len(groups)} independent prices "
f"({100 * (1 - len(groups) / len(quotes)):.1f}% collapse)")
biggest = max(groups.items(), key=lambda g: len(g[1]))
print(f"largest duplicate group: {len(biggest[1])} slugs at {biggest[0]}")
98 books on the board
93 with a complete, active Winner quote
31 independent prices (66.7% collapse)
largest duplicate group: 24 slugs at (2.95, 1.36)
Two of every three bookmaker rows on a LoL series repeat a price you already have. Across all 36 pre-match series the collapse runs 60.7%: 2,285 complete quotes become 899 independent ones. The 24-slug group above includes atg.se, betmgm.co.uk, betplay, betrivers, betuk, bingoal.be, casumo, expekt.se, fourwinds and grosvenor, and every one of them reports cloneOf: null. The clone flag does not track feed reality, so dedupe on the price tuple, per fixture.
The pinnacle+ skip in that code matters too. pinnacle, pinnacle+30 and pinnacle+5 are internal test feeds that ship in live payloads, and on 13 of the 18 series where Pinnacle appeared at least one of them quoted the identical price. Count them and you count Pinnacle three times.
Step 5: rank the board and take the best price
ladder = sorted(((1 / a + 1 / b - 1) * 100, slug, (a, b))
for slug, (a, b) in quotes.items())
for rank, (margin, slug, price) in enumerate(ladder, 1):
if rank <= 8 or slug in ("pinnacle", "bet365", "stake"):
print(f"{rank:4d}. {slug:16s} {margin:6.2f}% {price}")
best = (max(p[0] for p in quotes.values()), max(p[1] for p in quotes.values()))
sharp = quotes["pinnacle"]
print(f"\npinnacle {sharp} best on board {best}")
print("gain: " + " ".join(f"{100 * (best[i] / sharp[i] - 1):+.2f}%" for i in (0, 1)))
1. kalshi 1.00% (3.448, 1.389)
2. polymarket 1.00% (3.448, 1.389)
3. polymarket.us 1.00% (3.448, 1.389)
4. duel 3.38% (3.35, 1.36)
5. 3et 5.40% (3.25, 1.34)
6. roobet 5.40% (3.25, 1.34)
7. pinnacle 5.88% (3.46, 1.299)
8. betfury 6.44% (3.2, 1.33)
49. bet365 7.79% (2.75, 1.4)
74. stake 8.17% (3.2, 1.3)
pinnacle (3.46, 1.299) best on board (3.61, 1.455)
gain: +4.34% +12.01%
Taking the best of 93 books instead of Pinnacle is worth 4.34% on the underdog and 12.01% on the favourite, on one LPL series. Our line shopping walkthrough generalises the same loop across sports.
Check the ladder before you trust a prediction-market price
Kalshi and Polymarket lead that table. Depth decides whether either price is worth anything. Exchange-type books ship an exchangeMeta object with a three-level back ladder; sportsbooks ship null. Summing the stake capacity across those three levels on every LoL price we pulled:
| Venue | Median back-ladder stake | Max | Priced outcomes |
|---|---|---|---|
polymarket |
$978.42 | $46,510.28 | 474 |
kalshi |
$798.49 | $45,464.91 | 281 |
polymarket.us |
$0.00 | $0.00 | 176 |
polymarket.us posts the tightest median margin on the whole sport at 1.51% and carries nothing behind it on any of the 176 outcomes we sampled. Polymarket and Kalshi are the opposite: roughly a thousand dollars of depth on a median LoL outcome, which is real money for an esports market. Screen on margin and ladder depth together, never on margin alone. The prediction market guide covers the ladder shape in more detail.
The Maps Handicap ladder, and the one sport where mainLine works
Every handicap rung is its own market ID. -1.5 is 1825, +1.5 is 1837, and the worked fixture carried 15 rung IDs from 86 books. So you need to resolve which rung is the real line.
Each price object carries a mainLine boolean that claims to answer this. On tennis it matches the consensus line 8.6% of the time. On the NFL 46%. On rugby FanDuel flags every single rung true. We ran the same test on LoL, resolving the consensus rung as each book’s own most balanced quote and then taking the mode across books:
mainLine flags on Maps Handicap: 2217
matching the resolved consensus rung: 1856 (83.7%)
worst books best books
polymarket 5.6% bwin 100.0%
gamdom 32.4% roobet 100.0%
1xbet 47.5% sportingbet 100.0%
22bet 47.5% bcgame 100.0%
83.7% is the highest figure we have measured on any sport. LoL handicaps sit on a short ladder with an obvious centre, so the flag mostly agrees with the market. It still misses one rung in six, and Polymarket gets it wrong 19 times in 20, so keep the balanced-rung resolver as your source of truth and treat mainLine as a hint.
When the LoL board opens
/historical-odds is on the free tier, so the opening time of every book is free to measure. Take the first snapshot per bookmaker on one series.
import datetime
FIXTURE = "id1803998573998788"
KICKOFF = datetime.datetime.fromisoformat("2026-08-28T06:00:00+00:00")
def opened(slug):
"""First recorded snapshot for one book, in days before kick-off."""
r = call("/historical-odds", fixtureId=FIXTURE, bookmakers=slug)
if r.status_code != 200:
return None
book = r.json()["bookmakers"].get(slug)
if not book:
return None
stamps, changes = [], 0
for market in book["markets"].values():
for outcome in market["outcomes"].values():
history = outcome["players"]["0"] # a LIST here, not a dict
previous = None
for snap in history:
stamps.append(snap["createdAt"])
if previous is not None and snap["price"] != previous:
changes += 1
previous = snap["price"]
first = datetime.datetime.fromisoformat(min(stamps).replace("Z", "+00:00"))
return (KICKOFF - first).total_seconds() / 86400, len(stamps), changes
for slug in ["stake", "1xbet", "bet365", "kalshi", "betano", "pinnacle"]:
result = opened(slug)
if result:
days, snaps, changes = result
print(f"{slug:10s} opened T-{days:4.2f}d {snaps:6d} snapshots {changes:4d} price changes")
time.sleep(4.6)
stake opened T-4.21d 312 snapshots 212 price changes
1xbet opened T-4.17d 1428 snapshots 72 price changes
bet365 opened T-4.17d 1050 snapshots 42 price changes
kalshi opened T-3.21d 14556 snapshots 2884 price changes
betano opened T-2.72d 2010 snapshots 179 price changes
pinnacle opened T-1.25d 450 snapshots 298 price changes
The whole LoL board opens inside about four days. A football league opener posts two months out; a Grand Slam first round posts six hours out; LoL sits between them and compresses everything into a long weekend. Pinnacle is the last book in, at T-1.25 days here and T-1.68 on an LEC game we ran the same test on.
Late does not mean idle. Pinnacle logged 298 price changes across 450 snapshots while Bet365 logged 42 across 1,050. Count changes rather than snapshots: the feed records on a cadence and most snapshots repeat the previous price.
Two mechanical notes on that endpoint. It caps at three bookmakers per call and a batch containing one slug your key cannot read returns nothing at all, so retry failed batches one slug at a time. And betfair-ex and polymarket both reject a multi-book call outright: pass one bookmaker and one outcomeId, or leave them out of the batch.
Caveats worth coding around
| Behaviour | What it does to your parser |
|---|---|
76.9% of prices are active |
Roughly a quarter of the board is posted and suspended. Filter on the price-level active flag, not marketActive. |
| In-play boards look deep and are not | A live LCK game returned 103 books and 21.6% active prices. Only 25 carried a complete two-sided Winner quote. |
127 book records ship suspended: true |
They still return prices. Check book["suspended"] before adding a book to consensus. |
| Tier-3 circuits bottom out at 13 books | Depth ranges 13 to 101. Count the board per fixture rather than per sport. |
| No FanDuel, no SBOBet, DraftKings on 3 of 36 | US retail barely prices LoL. Do not build a US-book-only esports scanner. |
Where to go next
The same eight-family structure and the same dedupe problem show up across the rest of the esports board. Our esports odds API hub covers CS2 and Dota alongside LoL, the Valorant guide runs the same measurement on VCT, and if you arrived here comparing vendors, we wrote up PandaScore and Abios against the free tier.
Get the data
Every number on this page came from the free tier: 37 fixtures, 119 bookmakers, 37,653 prices and a full historical pull, with no card and no sales call. Grab a key, run the discovery block against today’s slate and see what your three scraped books have been missing.
Get your free OddsPapi API key and pull the LoL board in the next five minutes.
FAQ
Is there an official League of Legends betting odds API?
No. Riot publishes match and esports data APIs but no odds feed. Betting prices come from the bookmakers themselves or from an aggregator. OddsPapi covers LoL under sportId 18 and returned 119 distinct bookmakers across 36 pre-match series on 27 August 2026.
Which bookmakers cover League of Legends?
1xBet and 22Bet priced all 36 series we sampled. Polymarket covered 35, Stake 33, Bet365 32, Betway 32, Betano 32, Kalshi 30, Pinnacle 17 and Betfair Exchange 13. DraftKings appeared on 3 and FanDuel on none.
Does Pinnacle cover LoL?
Partially. Pinnacle priced 17 of 36 series and posted a 6.46% median margin on the series Winner, which ranks it 14th of 100 bookmakers. It also opens last, about a day before the match. Use it as one input rather than as the benchmark.
What LoL markets does the API return?
Eight families: series Winner, Maps Handicap, Total Maps Over Under, and a separate Winner market for maps one through five. There are no player props on the LoL feed. Every price is keyed players["0"].
How do I find which LoL circuits have odds?
Pull /fixtures with sportId=18 and group the results by tournamentName. The futureFixtures count on /tournaments reported zero for LPL, LCK, LCS and CBLOL while all four had priced games inside 48 hours.
Is historical LoL odds data free?
Yes. /historical-odds is on the free tier and returns the full snapshot history for a fixture, capped at three bookmakers per call. That is how the opening-time table above was built.