Guide

Fetch betting odds with Python

A complete Python script that lists upcoming football events, pages through them, reads the odds of each event and retries correctly on 429 and 503. It uses only requests.

Before you start

You need an API key from the dashboard; see Authentication. The script sends it as Authorization: Bearer; the X-Api-Key header works as well.

Which bookmakers your key can read depends on your plan and bookmaker entitlements; see Bookmakers for the list your key returns. No bookmaker is in verified customer coverage right now. The examples use bookmaker=tipico and football. Examples use Tipico for illustration only. Tipico ingestion is offline for a source review and is not part of current customer coverage.

Install and set the key

Install requests and keep the key in an environment variable instead of the source file:

Shell
python -m pip install requests
export BETSPREAD_API_KEY="YOUR_API_KEY"

A client that handles errors and rate limits

Every error response has the shape {"error":{"code","message"}}. Branch on error.code, not on the message text. The get helper below retries only what can succeed later:

  • 429 rate_limit_exceeded: wait for Retry-After seconds, or until X-RateLimit-Reset (Unix seconds) when Retry-After is missing.
  • 503 service_unavailable: the API fails closed while an auth, quota or rate-limit backend is down. Retry with exponential backoff.
  • 400 validation_failed, 401 missing_api_key / invalid_api_key, 403 forbidden / live_not_enabled / api_key_scope_denied and 404 not_found: raise, because the same request fails again.

When the monthly REST quota is used up, X-RateLimit-Reset points at the start of the next month. The helper stops instead of sleeping when the wait exceeds MAX_WAIT_SECONDS. See Errors and Rate limits for the full contract.

betspread_odds.py — client
import os
import time
from datetime import datetime, timezone

import requests

API_BASE = "https://api.betspread.io/api/v1"
MAX_RETRIES = 5
MAX_WAIT_SECONDS = 120

session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['BETSPREAD_API_KEY']}"


class BetspreadError(Exception):
    def __init__(self, status, code, message):
        super().__init__(f"{status} {code}: {message}")
        self.status = status
        self.code = code


def error_of(response):
    try:
        error = response.json()["error"]
        return error["code"], error["message"]
    except (ValueError, KeyError, TypeError):
        return "unknown", response.text


def wait_seconds(response, attempt):
    retry_after = response.headers.get("Retry-After")
    if retry_after is not None:
        return int(retry_after)
    reset = response.headers.get("X-RateLimit-Reset")
    if reset is not None:
        return max(0, int(reset) - int(time.time()))
    return 2 ** attempt


def get(path, params=None):
    for attempt in range(MAX_RETRIES):
        response = session.get(f"{API_BASE}{path}", params=params, timeout=30)
        if response.ok:
            return response.json()

        code, message = error_of(response)
        if response.status_code == 429:
            delay = wait_seconds(response, attempt)
        elif response.status_code == 503:
            delay = 2 ** attempt
        else:
            # 400, 401, 403 and 404 do not get better by retrying.
            raise BetspreadError(response.status_code, code, message)

        if delay > MAX_WAIT_SECONDS:
            # A monthly quota resets at the next month start: stop here.
            raise BetspreadError(response.status_code, code, message)
        time.sleep(delay)

    raise BetspreadError(response.status_code, code, "retries exhausted")

Paginate events

GET /api/v1/events is paginated with limit (1–500, default 50) and offset. The response carries meta.total, meta.limit and meta.offset. Default ordering does not guarantee the next upcoming event first, so pass dateFrom (ISO-8601) when you want future games only.

betspread_odds.py — pagination
def list_events(sport="football", date_from=None, page_size=100):
    params = {"sport": sport, "limit": page_size, "offset": 0}
    if date_from is not None:
        params["dateFrom"] = date_from

    while True:
        page = get("/events", params)
        yield from page["data"]
        params["offset"] += page["meta"]["limit"]
        if not page["data"] or params["offset"] >= page["meta"]["total"]:
            return

Read the odds of an event

GET /api/v1/events/{eventId}/odds returns the event and its markets. Each market has a type, an optional line (null for 1X2) and one entry per bookmaker with its outcomes. Odds are decimal; every outcome carries status and lastUpdatedAt. REST returns only open quotes, so status is always open here.

betspread_odds.py — odds
def event_odds(event_id, bookmaker="tipico"):
    params = {"bookmaker": bookmaker}
    return get(f"/events/{event_id}/odds", params)["data"]


def odds_rows(odds):
    event = odds["event"]
    for market in odds["markets"]:
        for bookmaker in market["bookmakers"]:
            for outcome in bookmaker["outcomes"]:
                yield (
                    event["kickoffAt"],
                    f"{event['homeTeam']} vs {event['awayTeam']}",
                    market["type"],
                    market.get("line"),
                    bookmaker["slug"],
                    outcome["outcome"],
                    outcome["odds"],
                    outcome["lastUpdatedAt"],
                )

For decisions on current prices, drop quotes whose lastUpdatedAt is older than your own cutoff; see Quote freshness.

Run it

Every request, including the odds request per event, counts against your monthly REST quota. The example therefore reads odds for the first five events only.

betspread_odds.py — main
if __name__ == "__main__":
    now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
    events = list(list_events(date_from=now))
    print(f"{len(events)} upcoming events")

    # One odds request per event: start small, every call counts.
    for event in events[:5]:
        for row in odds_rows(event_odds(event["id"])):
            print(*row)
Shell
python betspread_odds.py

Complete script

betspread_odds.py
import os
import time
from datetime import datetime, timezone

import requests

API_BASE = "https://api.betspread.io/api/v1"
MAX_RETRIES = 5
MAX_WAIT_SECONDS = 120

session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['BETSPREAD_API_KEY']}"


class BetspreadError(Exception):
    def __init__(self, status, code, message):
        super().__init__(f"{status} {code}: {message}")
        self.status = status
        self.code = code


def error_of(response):
    try:
        error = response.json()["error"]
        return error["code"], error["message"]
    except (ValueError, KeyError, TypeError):
        return "unknown", response.text


def wait_seconds(response, attempt):
    retry_after = response.headers.get("Retry-After")
    if retry_after is not None:
        return int(retry_after)
    reset = response.headers.get("X-RateLimit-Reset")
    if reset is not None:
        return max(0, int(reset) - int(time.time()))
    return 2 ** attempt


def get(path, params=None):
    for attempt in range(MAX_RETRIES):
        response = session.get(f"{API_BASE}{path}", params=params, timeout=30)
        if response.ok:
            return response.json()

        code, message = error_of(response)
        if response.status_code == 429:
            delay = wait_seconds(response, attempt)
        elif response.status_code == 503:
            delay = 2 ** attempt
        else:
            # 400, 401, 403 and 404 do not get better by retrying.
            raise BetspreadError(response.status_code, code, message)

        if delay > MAX_WAIT_SECONDS:
            # A monthly quota resets at the next month start: stop here.
            raise BetspreadError(response.status_code, code, message)
        time.sleep(delay)

    raise BetspreadError(response.status_code, code, "retries exhausted")


def list_events(sport="football", date_from=None, page_size=100):
    params = {"sport": sport, "limit": page_size, "offset": 0}
    if date_from is not None:
        params["dateFrom"] = date_from

    while True:
        page = get("/events", params)
        yield from page["data"]
        params["offset"] += page["meta"]["limit"]
        if not page["data"] or params["offset"] >= page["meta"]["total"]:
            return


def event_odds(event_id, bookmaker="tipico"):
    params = {"bookmaker": bookmaker}
    return get(f"/events/{event_id}/odds", params)["data"]


def odds_rows(odds):
    event = odds["event"]
    for market in odds["markets"]:
        for bookmaker in market["bookmakers"]:
            for outcome in bookmaker["outcomes"]:
                yield (
                    event["kickoffAt"],
                    f"{event['homeTeam']} vs {event['awayTeam']}",
                    market["type"],
                    market.get("line"),
                    bookmaker["slug"],
                    outcome["outcome"],
                    outcome["odds"],
                    outcome["lastUpdatedAt"],
                )


if __name__ == "__main__":
    now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
    events = list(list_events(date_from=now))
    print(f"{len(events)} upcoming events")

    # One odds request per event: start small, every call counts.
    for event in events[:5]:
        for row in odds_rows(event_odds(event["id"])):
            print(*row)

Next steps