Your first integration
A complete, runnable script that lists matches, expands one, and reads its odds.
The quickstart gets you a response. This gets you something you'd actually ship: a script that handles errors, paginates, expands a match in one request, and reads its odds — with the mistakes already removed.
Set your key
export ESPORTSODDS_API_KEY="eo_live_…"Never put a literal key in source. Every sample here reads the environment.
The script
"""A minimal but correct EsportsOdds integration."""
import os
import time
import random
import requests
BASE = "https://api.esportsodds.gg"
HEADERS = {"Authorization": f"Bearer {os.environ['ESPORTSODDS_API_KEY']}"}
RETRYABLE = {"rate_limited", "internal_error", "unavailable"}
class ApiError(RuntimeError):
def __init__(self, code, message, request_id):
super().__init__(f"{code}: {message} (request {request_id})")
self.code = code
def get(path, params=None, attempts=4):
"""GET with the retry policy the API's error codes imply."""
for attempt in range(attempts):
r = requests.get(BASE + path, params=params, headers=HEADERS, timeout=30)
if r.ok:
return r.json()
err = r.json().get("error", {})
code = err.get("code", "")
# quota_exceeded is NOT retryable — no amount of waiting inside this process helps.
if code not in RETRYABLE or attempt == attempts - 1:
raise ApiError(code, err.get("message", ""), err.get("request_id"))
wait = float(r.headers.get("Retry-After") or 2**attempt)
time.sleep(wait + random.uniform(0, 0.5))
def paginate(path, **params):
"""Walk every page. meta.next_cursor is null on the last one — the only stop signal."""
cursor = None
while True:
if cursor:
params["cursor"] = cursor
body = get(path, params=params)
yield from body["data"]
cursor = body["meta"]["next_cursor"]
if not cursor:
return
def main():
# 1. Upcoming fixtures. The list row carries team and tournament NAMES, so rendering a
# schedule needs no further calls.
upcoming = list(paginate("/v1/cs2/matches", status="scheduled", limit=50))
print(f"{len(upcoming)} scheduled matches\n")
for m in upcoming[:5]:
print(f" {m['scheduled_at']} {m['team_a_name']} vs {m['team_b_name']}"
f" ({m['tournament_name']})")
if not upcoming:
return
# 2. One match, fully expanded — one request instead of four.
match_id = upcoming[0]["id"]
match = get(f"/v1/cs2/matches/{match_id}",
params={"include": "teams,tournament,odds"})["data"]
print(f"\n{match['team_a']['name']} vs {match['team_b']['name']}")
print(f" {match['tournament']['name']} · {match['format']} · {match['status']}")
# 3. Odds. Group by market first — a match can have more than one.
from collections import defaultdict
markets = defaultdict(list)
for line in match.get("odds", []):
markets[line["market_id"]].append(line)
for market_id, lines in markets.items():
print(f"\n market {market_id[:8]}…")
for l in lines:
implied = 1 / l["price"]
print(f" {l['label']:24} {l['price']:6.2f} p={implied:5.1%}"
f" ({l['book_count']} books)")
# De-vigged, so implied probabilities sum to ~1.0. A good parsing sanity check.
total = sum(1 / l["price"] for l in lines)
print(f" {'sum':24} {'':6} p={total:5.1%}")
if __name__ == "__main__":
main()What it demonstrates
Errors are classified, not blanket-retried. quota_exceeded and rate_limited are both 429
but mean opposite things — one is transient, the other cannot succeed until the month resets.
Pagination stops on next_cursor, not on a short page. A short page doesn't mean the end.
One request per match, not four. ?include=teams,tournament,odds embeds what would otherwise be
three follow-up calls.
Odds are grouped by market before being read. Mixing a moneyline with a handicap produces numbers that look like movement but aren't.
The de-vig sum is asserted. Implied probabilities summing to ~100% confirms you've grouped correctly; ~105% means two markets got mixed.
Where to go next
- Budget your requests — before you put anything on a timer.
- Connect a WebSocket — for live data, cheaper than polling.
- Troubleshooting — the behaviours that surprise people.