# Odds API in Python: an oddsmatcher in 50 lines

> Call an odds API from Python with only the standard library: ask for gzip, quote before you spend, set each back price against the lowest lay, and sort.

Published 11 October 2026 · updated 12 October 2026

**An odds API Python script needs nothing beyond the standard library: a small helper handles the HTTP, and `main.py`, under 50 lines of code, turns a matched board into a sorted oddsmatcher.** We wrote both files, ran them on the response examples in OddsRelay's OpenAPI file, and every line is below.

## How do you call an odds API from Python?

You call it with `urllib.request`, `gzip` and `json` from the standard library, plus four habits. Send the key in an `Authorization: Bearer` header rather than the URL, and send `Accept-Encoding: gzip`, then decompress the reply. Price each OddsRelay request with `quote=true` before you spend on it. After a `429`, wait as long as `Retry-After` says and try again.

The gzip header is the one Python gets wrong by default. Unless you set it yourself, `urllib` sends `Accept-Encoding: identity`, because CPython's `http.client` adds that header to every request. The standard board, like dutching and raw, answers a request that refuses gzip with a `400`. Asking for gzip also shrinks the reply, since a board repeats the same field names and venue keys in every outcome.

Everything below runs on Python 3.9 or later and installs nothing. Both files sit in the [oddsmatcher-50 folder](https://github.com/oddsrelay/examples/tree/main/oddsmatcher-50) of our public examples repo, with tests that run on the contract's example replies. Clone it, or copy the two files into one folder, and set `ODDSRELAY_KEY` in your shell.

Run it · example:

```sh
git clone https://github.com/oddsrelay/examples.git
cd examples/oddsmatcher-50
export ODDSRELAY_KEY="your API key"
python3 main.py --sports tennis
python3 main.py --board bog --commission 0.02 --top 10
```

## One helper holds the plumbing

Here is `api.py` in full. It reads the key from the environment and trims stray whitespace first, because a key pasted with a trailing newline makes `http.client` raise an error that prints the header's value, key and all.

api.py · example:

```python
"""The small HTTP helper every example here shares. Each folder keeps an identical copy, so any
folder runs on its own with nothing but the Python standard library."""
from __future__ import annotations

import gzip
import json
import os
import sys
import time
from http.client import HTTPMessage
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen

API = "https://api.oddsrelay.io"


def get(path: str, params: dict, headers: dict | None = None) -> tuple[int, HTTPMessage, Any]:
    """GET one route with gzip: (status, headers, decoded JSON or None). Waits out 429s, exits on errors."""
    key = os.environ.get("ODDSRELAY_KEY", "").strip()  # a stray newline would put the key in a ValueError
    if not key:
        sys.exit("set ODDSRELAY_KEY to your API key")
    query = urlencode({k: v for k, v in params.items() if v is not None})
    for attempt in range(3):
        req = Request(f"{API}{path}" + (f"?{query}" if query else ""), headers={
            "Authorization": f"Bearer {key}", "Accept-Encoding": "gzip", **(headers or {})})
        try:
            with urlopen(req, timeout=30) as res:
                status, hdrs, body = res.status, res.headers, res.read()
        except HTTPError as err:  # urllib raises for 304 and for every 4xx and 5xx
            status, hdrs, body = err.code, err.headers, err.read()
        except URLError as err:
            sys.exit(f"could not reach {API}: {err.reason}")
        if body and hdrs.get("Content-Encoding") == "gzip":
            body = gzip.decompress(body)
        if status == 429:  # rate-limited: wait as long as the reply says, unless that was the last try
            if attempt < 2:
                time.sleep(int(hdrs.get("Retry-After", "1")))
            continue
        if status >= 400:
            error = json.loads(body).get("error", {}) if body[:1] == b"{" else {}
            sys.exit(f"HTTP {status} {error.get('code', '')}: {error.get('message', '')} "
                     f"(request id {error.get('request_id', '-')})")
        return status, hdrs, json.loads(body) if body else None
    sys.exit("still rate-limited after 3 tries")


def afford(quote: dict, max_tokens: int | None) -> bool:
    """True when a quoted price is fine: free, within --max-tokens, or confirmed at the prompt."""
    left = "unknown" if quote["remaining"] is None else quote["remaining"]
    print(f"quote: {quote['cost']} tokens ({left} left)", file=sys.stderr)
    if quote["cost"] == 0 or (max_tokens is not None and quote["cost"] <= max_tokens):
        return True
    if max_tokens is None and sys.stdin.isatty():
        print("fetch it? [y/N] ", end="", file=sys.stderr, flush=True)  # stderr: stdout may be redirected
        return input().strip().lower() == "y"
    return False
```

`get()` returns the status, the headers and the decoded JSON. `urllib` raises an exception for a `304` and for every `4xx` and `5xx`, so the helper catches `HTTPError` and reads the status and body from it. A `429` waits for `Retry-After` and tries again, three tries in all.

Any other error stops the script with the API's own `error.code`, its message and the `request_id`, the id to give when you ask about a failed call. None of the script's own messages print the key.

`afford()` is the spending guard. The first call carries `quote=true`, so the reply is that request's price instead of the board, and a quote costs nothing. If the price is zero, or within the ceiling you pass as `--max-tokens`, the script carries on. If it is above your ceiling, the script stops. With no ceiling it asks at the terminal, and an unattended run stops before anything is spent.

## Every back price meets the lowest lay

`main.py` is the oddsmatcher itself. Beyond its code, the file holds only docstrings, one comment and blank lines.

main.py · example:

```python
"""An oddsmatcher in about 50 lines: each bookmaker back price on a matched board beside the
lowest exchange lay price, sorted by a rating this script calculates (the feed sends no rating).

Run: ODDSRELAY_KEY=<your key> python3 main.py --sports tennis
"""
from __future__ import annotations

import argparse
import sys
from typing import Iterator

from api import afford, get

FILTERS = ("region", "sports", "bookmakers", "exchanges", "markets")


def rating(back: float, lay: float, commission: float) -> float:
    """100 x back x (1 - commission) / (lay - commission): 100 means the two prices balance exactly."""
    return 100 * back * (1 - commission) / (lay - commission)


def label(event: dict) -> str:
    if event.get("home_team"):
        return f"{event['home_team']} v {event.get('away_team')}"
    return event.get("event_name") or event.get("venue") or event["event_id"]


def pairs(events: list[dict], commission: float) -> Iterator[tuple]:
    """(rating, event, market, outcome, back offer, lay offer) for every priced back offer."""
    for event in events:
        for market in event["markets"]:
            for outcome in market["outcomes"]:
                # Lays come in price order, so lays[0] is the lowest on offer.
                lays = [lay for lay in outcome.get("lay") or [] if lay.get("price")]
                if not lays or lays[0]["price"] <= commission:
                    continue
                lay = lays[0]
                for back in outcome["back"]:
                    if back.get("price"):
                        score = rating(back["price"], lay["price"], commission)
                        yield score, event, market, outcome, back, lay


def main() -> None:
    ap = argparse.ArgumentParser(description="Rate every back and lay pair on a matched board.")
    ap.add_argument("--board", choices=["standard", "2up", "bog"], default="standard")
    for name in FILTERS:
        ap.add_argument(f"--{name}", help="one region code" if name == "region" else "comma-separated keys")
    ap.add_argument("--commission", type=float, default=0.0, help="your exchange commission, as a fraction")
    ap.add_argument("--top", type=int, default=20, help="how many rows to print")
    ap.add_argument("--max-tokens", type=int, help="spend up to this many tokens without asking")
    args = ap.parse_args()
    if not 0 <= args.commission < 1:
        ap.error("--commission is a fraction from 0 to 1, such as 0.02")
    path, params = f"/v2/odds/{args.board}", {name: getattr(args, name) for name in FILTERS}
    if not afford(get(path, {**params, "quote": "true"})[2], args.max_tokens):
        sys.exit("not fetched: narrow the filters, or pass --max-tokens")
    rows = sorted(pairs(get(path, params)[2]["data"], args.commission), key=lambda row: row[0], reverse=True)
    for score, event, market, outcome, back, lay in rows[: args.top]:
        selection = outcome["name"] + (f" {outcome['point']}" if outcome.get("point") is not None else "")
        print(f"{score:6.1f}  {event['commence_time']}  {label(event)}  {market['key']}  {selection}"
              f"  back {back['bookmaker']} {back['price']}  lay {lay['exchange']} {lay['price']}"
              f" ({lay.get('available')} available)")


if __name__ == "__main__":
    main()
```

`pairs()` walks the board the way the response nests it. Events hold markets, markets hold outcomes, and each outcome carries a `back` list of bookmaker offers and a `lay` list of exchange offers. Lays arrive in price order, so the first priced lay is the lowest price on offer, but only for as much as its `available` covers. Every priced back offer on the outcome is set against it, and an outcome with no priced lay is skipped.

`--board` takes the standard, 2Up and Best odds guaranteed (BOG) boards (`standard`, `2up` and `bog`), because their lay side is a plain list. On the each-way and extra-place boards `lay` splits into `win` and `place`, which needs different sums, and dutching carries back offers only. The filter options go to the API exactly as you type them: one region code, and comma-separated keys for the rest.

## Two lines for one race

We fed the script an example based on the OpenAPI file's Best odds guaranteed example in place of a network call. Here it is, wrapped in the `data` envelope the script reads by default, with the venue links left out:

GET /v2/odds/bog (venue links left out) · example:

```json
{
  "data": [{
    "event_id": "or_evt_58ab94d71497",
    "sport_key": "horse_racing",
    "sport_title": "Horse Racing",
    "commence_time": "2026-09-17T12:30:00Z",
    "venue": "Ayr",
    "markets": [{
      "key": "win",
      "outcomes": [{
        "name": "Bassrah",
        "back": [
          { "bookmaker": "ladbrokes", "price": 4.0 },
          { "bookmaker": "betfred", "price": 4.0 }
        ],
        "lay": [
          { "exchange": "matchbook", "price": 4.3, "available": 17.26 },
          { "exchange": "smarkets", "price": 4.4, "available": 75.93 }
        ]
      }]
    }]
  }]
}
```

With `--board bog --commission 0.02`, the script printed these two lines:

Output · example:

```text
  91.6  2026-09-17T12:30:00Z  Ayr  win  Bassrah  back ladbrokes 4.0  lay matchbook 4.3 (17.26 available)
  91.6  2026-09-17T12:30:00Z  Ayr  win  Bassrah  back betfred 4.0  lay matchbook 4.3 (17.26 available)
```

Each line starts with the rating, then the start time, the event, the market key and the selection, the back offer and the lay offer, with the lay's `available` liquidity in brackets. A race has no teams, so the script names the event by its `venue`. Both bookmakers price the horse at 4.0, and both meet the same lay, 4.3 at the first exchange in the list. The 4.4 behind it never reaches a line.

## The rating is your arithmetic

The reply stops at prices and the `available` liquidity at each lay. The number the script sorts on comes from one line of its own:

Your code: the rating for one back and lay pair · example:

```python
rating = 100 * back * (1 - commission) / (lay - commission)
```

It equals 100 plus what a back stake of 100 nets once it is laid off at that price, the same whichever side wins, with the exchange's commission deducted when the lay side wins. At 100 the two prices balance exactly. Below 100, laying off costs money. Above it, the back price is longer than the lay even after commission.

On the board above, 4.0 against 4.3 gives 93.0 with no commission and 91.6 at `0.02`. With commission at zero the formula reduces to back ÷ lay × 100, the plain version in the [response anatomy post](https://oddsrelay.io/blog/anatomy-of-an-odds-api-response).

Commission goes in as a fraction from 0 to 1, such as `0.02`, and the script refuses anything outside that range before it makes a request. Only your user knows their commission rate, which is why this number belongs in your code. The [qualifying loss and ratings post](https://oddsrelay.io/blog/qualifying-loss-and-ratings-explained) adds stakes and the free-bet versions.

## Polling and stakes come next

- **Narrow the request.** Pass `--sports`, `--bookmakers`, `--exchanges` or `--markets` and the script forwards them as filters. On the standard board each one you name narrows the price of the call, and the quote shows the new price before you spend it. [Filters and tokens](https://oddsrelay.io/docs/guides/filters-and-tokens) sets out the rules.
- **Poll without paying twice.** Send each reply's `ETag` back as `If-None-Match` through `get()`'s `headers` argument, and add `format=bare` to the params, because the `meta` times in the envelope change the `ETag` with every new board. A bare reply is the event list itself, so drop the `["data"]` index. An unchanged list then comes back as an empty `304` that costs no tokens, with `None` for a body, which your loop reads as no change. A narrower call gets more of them, as [Polling cheaply](https://oddsrelay.io/docs/guides/polling-cheaply) explains.
- **Add stakes.** A lay stake is back price × stake ÷ (lay − commission). Show it beside each line and flag any that would not fit inside `available`.
- **Swap the helper for a package.** [The oddsrelay package on PyPI](https://pypi.org/project/oddsrelay/) is our Python client, standard library only like `api.py`. After `pip install oddsrelay`, `client.odds("bog")` returns the board in `result.data`. A `max_tokens` argument quotes the call before it spends, the client sends each `ETag` back for you, and it retries a `429` on its own.
- **Post it to a channel.** The same `api.py` drives the [racing board tutorial](https://oddsrelay.io/blog/horse-racing-api-in-python), which posts the next races' prices to Discord or Telegram and sends nothing when the board is unchanged. Posting where other people read it is Enterprise use.
- **Put it on a page.** [Displaying odds data](https://oddsrelay.io/blog/how-to-display-odds-data-in-your-app) covers formats and layout, and the [oddsmatcher widget](https://oddsrelay.io/widgets/oddsmatcher) is a ready-made board over the same matched data.
- **Build the product around it.** Filters by the accounts a user holds, alerts and tracking are where oddsmatchers differ, as the [guide to building an oddsmatcher](https://oddsrelay.io/blog/how-to-build-an-oddsmatcher) sets out.

## Point it at your own board

Try `--board 2up` or `--board bog` with the same filters. The [API reference](https://oddsrelay.io/docs/reference) lists every filter each board takes, and `/v2/sports` and `/v2/bookmakers` list the keys to put in them.

Test all of it free on your own key. A page or oddsmatcher that other people use runs on [Enterprise](https://oddsrelay.io/enterprise).

18+ · OddsRelay supplies odds data only and takes no bets. Please gamble responsibly.

Source: https://oddsrelay.io/blog/odds-api-in-python
