Call a JSON API from Python without requests, and urllib asks for an uncompressed reply. You never wrote that header. CPython's http.client adds Accept-Encoding: identity to every request unless you set the header yourself, and the standard board of the odds API used below answers such a request with a 400.
This tutorial fixes that in one small helper, then puts a short script on top of it. The script lines up each bookmaker's back price with the lowest exchange lay price for the same outcome and sorts the pairs. It needs Python 3.9 or later and installs nothing.
Two files in one folder
Save api.py and main.py side by side, put your key in the environment and run:
export ODDSRELAY_KEY="your API key"
python3 main.py --sports tennis
python3 main.py --board bog --commission 0.02 --top 10
A helper for gzip, errors, retries and spending
api.py keeps four habits in one place:
- The key travels in an
Authorization: Bearerheader, never in the query string. - Every request sends
Accept-Encoding: gzip, and a gzipped reply is decompressed beforejson.loadssees it. - A
429waits as many seconds asRetry-Aftersays, three tries in all. - A metered call is priced before it is paid for. This API returns a request's cost in place of its data when you add
quote=true, and the quote itself is free.
"""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
Three details are easy to miss.
urlopen raises HTTPError for a 304 and for every 4xx and 5xx. The exception still carries the status, the headers and the body, so the helper reads all three from it and every status takes the same path afterwards.
The key is read with .strip(). Paste a key with a trailing newline and http.client raises a ValueError whose message quotes the header value, key included, straight into your terminal or your logs.
Any other error exits with the API's own error code, its message and the request id, the thing to quote when you ask why a call failed. The key never appears in anything the script prints.
afford() is the spending guard. It goes ahead when the quote is zero or within --max-tokens, stops when the quote is above that ceiling, asks at the terminal when you set none, and stops an unattended run before anything is spent. If the API you call has no way to price a request, delete afford() and the quote call with it.
Each back price meets the lowest lay
The reply nests events, then markets, then outcomes. Each outcome holds a back list of bookmaker offers and a lay list of exchange offers, and the lays arrive sorted by price. The first priced lay is therefore the lowest one on offer, good for as much as its available field says.
main.py sets every priced back offer against that lay, scores the pair and prints the best first. Leave out the docstrings, one comment and the blank lines, and it is under 50 lines.
"""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()
--board takes standard, 2up or bog (Best odds guaranteed), the boards where lay is a plain list. On the each-way and extra-place boards lay splits into win and place, which needs a different sum. Filter flags pass through to the API untouched.
One race, two lines
You can test the pairing without a network call by feeding the script a saved reply. This one is the Best odds guaranteed example from the API's OpenAPI file, wrapped in the data envelope, with the venue links left out:
{
"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": "betfair_exchange", "price": 4.3, "available": 17.26 },
{ "exchange": "smarkets", "price": 4.4, "available": 75.93 }
]
}]
}]
}]
}
With --board bog --commission 0.02, the script printed:
91.6 2026-09-17T12:30:00Z Ayr win Bassrah back ladbrokes 4.0 lay betfair_exchange 4.3 (17.26 available)
91.6 2026-09-17T12:30:00Z Ayr win Bassrah back betfred 4.0 lay betfair_exchange 4.3 (17.26 available)
Each line holds the score, the start time, the event, the market key and the selection, then the back offer and the lay offer, with the lay's available in brackets. A horse race has no home and away team, so label() falls back to the venue. Both bookmakers offer 4.0, both meet the same 4.3 lay, and the 4.4 lay behind it never reaches the output.
The score is one line of your own
rating = 100 * back * (1 - commission) / (lay - commission)
The API sends prices and the liquidity at each lay, not a score. The score is 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. A score of 100 means the back and lay prices cancel out. Under 100 the lay-off costs something, and over 100 the back price beats the lay with commission already paid.
For the race above, 4.0 against 4.3 gives 93.0 with no commission and 91.6 at 0.02. With commission at zero the formula is back divided by lay, times 100. Only your user knows their commission, so it is a flag rather than a constant, and the script refuses a value outside 0 to 1 before it makes a request.
Where to take it next
-
Poll without paying twice. Keep each reply's
ETagand send it back asIf-None-Matchthrough theheadersargument ofget(). An unchanged reply then comes back as an empty304, which the helper already returns withNonefor a body. On this API also addformat=bare, because themetatimes in the envelope change theETagevery time a new board is built. A bare reply is just the event list, with no["data"]key to index. -
Add stakes. The lay stake for a back stake is back price ร stake รท (lay โ commission). Print it beside each line and flag any that would not fit inside
available. -
Reuse the helper. Apart from the base URL, the key's variable name and the shape of the error body,
api.pyknows nothing about odds. PointAPIat another JSON service that compresses its replies and answers429withRetry-After, deleteafford()if there is nothing to quote, and keep the rest.
Written by the OddsRelay team with AI assistance, then edited and checked by a person. The API in the examples is ours. The helper works on any HTTP JSON API.
Top comments (0)