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

On this page