BlogBuilding betting tools

Sports Betting API Python Tutorial, From Request to EV

To call a sports betting API from Python you need three things: the requests library, your key in an environment variable sent as an Authorization: Bearer header, and a helper that handles errors, rate limits and paging. This walkthrough builds that helper against ZippyOddsAPI, pulls NBA moneylines, finds the best price on each side, and adds the odds maths to judge a price yourself.

Everything that touches the API below uses only endpoints, parameters and fields from the ZippyOddsAPI docs. The maths at the end is plain Python and works with any source.

Set up

Install requests with pip install requests, then put your key in the environment rather than in the code:

export ZIPPYODDS_KEY="ua_live_..."

Keys are issued on request, and the docs say how to ask for one. Never put the key in a URL: ZippyOddsAPI refuses that with 400 api_key_in_url, because URLs end up in logs.

A request helper

Every success comes back as { data, meta } and every failure as { error, meta }, where error.code is stable enough to branch on. Over the rate limit you get a 429 with a Retry-After header in seconds. This helper handles both:

import os
import time
import requests

BASE = "https://zippyoddsapi.com/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['ZIPPYODDS_KEY']}"


class ApiError(Exception):
    pass


def get(path, **params):
    for _ in range(3):
        r = session.get(f"{BASE}/{path}", params=params, timeout=30)
        if r.status_code == 429:
            time.sleep(int(r.headers.get("Retry-After", "5")))
            continue
        body = r.json()
        if "error" in body:
            err = body["error"]
            raise ApiError(f"{r.status_code} {err['code']}: {err['message']}")
        return body
    raise ApiError("still rate limited after 3 tries")

Refused requests cost no credits, so a retry after a 429 doesn't double-charge you. Don't retry other errors blindly, though. insufficient_credits or plan_feature_unavailable won't fix themselves.

A quick first call is get("status"), which costs 0 credits and tells you whether the key works.

Paging with cursors

Paged endpoints return meta.has_more and, when it's true, meta.next_cursor. Pass the cursor back unchanged, with the same filters:

def get_all(path, **params):
    cursor = None
    while True:
        page = get(path, cursor=cursor, **params)
        yield from page["data"]
        if not page["meta"].get("has_more"):
            return
        cursor = page["meta"]["next_cursor"]

requests leaves out any parameter whose value is None, so the first call goes without a cursor. If a cursor expires mid-walk, the API answers 409 cursor_expired, and the fix is to start again from the first page.

Fetching odds and finding the best price

/v1/odds needs league or event_ids. It costs 2 credits a page and returns each event with its bookmakers, each holding markets, each holding outcomes with a side and a price in American odds by default.

def american_to_decimal(a):
    return 1 + (a / 100 if a > 0 else 100 / -a)


def best_prices(event):
    best = {}
    for book in event["bookmakers"]:
        for market in book["markets"]:
            for o in market["outcomes"]:
                if o["price"] is None:
                    continue
                d = american_to_decimal(o["price"])
                if o["side"] not in best or d > american_to_decimal(best[o["side"]][0]):
                    best[o["side"]] = (o["price"], book["name"], book["updated_at"])
    return best


for event in get_all("odds", league="nba", markets="moneyline"):
    print(event["away"]["name"], "at", event["home"]["name"], event["start_time"])
    for side, (price, book, updated) in best_prices(event).items():
        print(f"  {side}: {price:+d} at {book}, read {updated}")

Comparing in decimal avoids the trap of American odds, where -105 is better than -120 but +100 is better than -105. Keep updated_at with each price: a best line that's ten minutes old may not be there anymore.

If you'd rather not do this yourself, /v1/best-lines returns the best fresh price on each side and the books that have it, for 3 credits a page.

Odds maths helpers

These four functions cover most of what you'll need to judge a price:

def implied(a):
    return 100 / (a + 100) if a > 0 else -a / (-a + 100)


def no_vig(a, b):
    pa, pb = implied(a), implied(b)
    return pa / (pa + pb), pb / (pa + pb)


def ev_percent(fair_p, a):
    return (fair_p * american_to_decimal(a) - 1) * 100

Worked through: a sharp book has a total at -112 over, -104 under. implied(-112) is 0.5283 and implied(-104) is 0.5098, which add to 1.0381. no_vig(-112, -104) returns 0.5089 and 0.4911. Another book has the under at +115, which is 2.15 in decimal. ev_percent(0.4911, 115) is 0.4911 × 2.15 - 1 = +5.58%.

That's the simplest way to remove a margin (multiplicative). ZippyOddsAPI's own fair prices use Shin's method across several sharp references, so your figure and its figure won't always match.

Letting the API do the EV

On plans with the ev feature, /v1/ev returns prices already scored against a no-vig consensus, best first. min_ev is in percent:

for row in get_all("ev", league="nba", min_ev=2, limit=100):
    print(row["book_name"], row["market"], row["side"], row["line"],
          row["price"], f"{row['ev_percent']:.2f}%", row["observed_at"])

Each row also carries fair_probability, fair_price, kelly_fraction (full Kelly, no multiplier) and reference_age_seconds. Each page is 3 credits. Plan features and credit allowances are on the pricing page.

A positive EV figure is an estimate, and it plays out only as an average over many bets. A real edge still loses often. Stake only money you can afford to lose.

Where this goes next

This helper is the core of most sports betting API Python projects, whatever they turn into. Run the /v1/ev loop on a timer and de-duplicate its rows, and you have the start of a sports betting bot that alerts you. Put the odds loop behind a web server with a cache, and you have the data layer from our guide to sports betting app development. For a wider look at what a python sports betting api client can reach, see what a sports betting API gives you.

One note if you searched for a general python sports api for scores and stats: ZippyOddsAPI is built around prices. Its /v1/scores lists games in progress, and a game leaves the board when it's reported final, so finished results need another source.