Skip to main content

oddsrelay

The OddsRelay Python client. One method for each GET route of the OddsRelay API, which serves bookmaker and exchange odds for the UK & Ireland as JSON. Standard library only, Python 3.9 and up.

Install

pip install oddsrelay

First call

Sign up at app.oddsrelay.io and copy your key from the dashboard. Put it in your environment, never in your code:

export ODDSRELAY_KEY="your key"
from oddsrelay import OddsRelay

client = OddsRelay()  # reads ODDSRELAY_KEY

result = client.odds("standard", region="uk", sports="soccer", markets="h2h")  # soccer is the API's key for football
for event in result.data["data"]:
    teams = f"{event['home_team']} v {event['away_team']}" if event.get("home_team") else event.get("event_name")
    print(event["commence_time"], teams)

print(result.tokens)  # what the call cost and what is left

No key yet? These routes answer without one:

client = OddsRelay()
print(client.status().data)
print(client.coverage(region="uk").data["products"].keys())

Methods

Method Route What it returns
odds(type, **filters) GET /v2/odds/{type} Matched odds for one board type: standard, 2up, dutching, each-way, extra-place or bog
raw(**filters) GET /v2/odds/raw Bookmaker and exchange prices by event, unpaired, a page at a time
event(id, **filters) GET /v2/odds/event/{id} One event's raw prices
sports(**filters) GET /v2/sports Sport and competition keys
events(sport, **filters) GET /v2/events Upcoming events for one sport key
bookmakers(**filters) GET /v2/bookmakers The venues you can filter on, with their keys
regions() GET /v2/regions The region codes open to your key
coverage(**filters) GET /v2/coverage Which venues and sports each product carries (keyless; more detail with a key)
status(**filters) GET /v2/status Whether each product is serving (keyless)
health() GET /v2/health When each board was last processed, and whether any is behind
usage() GET /v2/usage Tokens used and left, and what your key may read
pricing() GET /v2/pricing The token rate card
openapi() GET /v2/openapi.json The API description (keyless)
healthz() GET /healthz A plain-text liveness check (keyless)

Each method returns a Result. result.data is the decoded body, with every field kept, including fields the API adds after this release.

Filters

Every query parameter in the API reference is a keyword argument. Python's snake_case and the API's camelCase both work, so odds_format="decimal" and oddsFormat="decimal" send the same thing. Lists become comma-separated values, True and False become true and false, a datetime becomes a UTC timestamp, and None is left out:

from datetime import datetime, timedelta, timezone

now = datetime.now(timezone.utc)
result = client.odds(
    "each-way",
    region="uk",
    sports=["horse_racing"],
    commence_time_from=now,
    commence_time_to=now + timedelta(hours=6),
)

The API checks every filter itself: an unknown name or value raises BadRequestError, and where the API lists the accepted values, error.get("valid") holds them.

Price a call before you make it

quote=True asks what a call would cost instead of making it. A quote spends no tokens:

quote = client.odds("standard", region="uk", quote=True)
print(quote.data["cost"], quote.data["remaining"])

Or let the client do it. With max_tokens, odds(), raw() and event() quote first and raise QuoteTooHigh, without making the call, when the cost is above your cap:

from oddsrelay import QuoteTooHigh

try:
    result = client.raw(region="uk", sports="tennis", max_tokens=cap)  # cap: the most you will spend on this call
except QuoteTooHigh as exc:
    print(f"this call costs {exc.cost} tokens: add filters to narrow it")

Polling: spend tokens only on changes

The client keeps the ETag of each URL it has fetched and sends it back as If-None-Match. When nothing has changed the API answers 304, which spends no tokens, and you get a NotModified result whose data is the body you already have:

import time

while True:
    result = client.odds("standard", region="uk", sports="soccer")
    if not result.not_modified:
        update_my_screen(result.data)
    time.sleep(poll_seconds)

NotModified is a subclass of Result, so code that reads result.data works either way.

Paging

raw(), sports() and events() return a page at a time. Pass the next page's cursor back as cursor:

cursor = None
while True:
    page = client.raw(region="uk", sports="tennis", cursor=cursor)
    handle(page.data["data"])
    cursor = page.data["meta"].get("next_cursor")
    if not cursor:
        break

Errors

A reply the API refuses raises an APIError subclass with status, code, message, request_id and error (the whole error object). APIError and QuoteTooHigh are both OddsRelayErrors. Network failures are not wrapped: a refused connection or a timeout raises the standard library's OSError (such as urllib.error.URLError), so a long-running loop catches (OddsRelayError, OSError).

Status Exception
400 BadRequestError
401 AuthenticationError
402 InsufficientTokensError, with cost, remaining and resets_at
403 PermissionDeniedError
404 NotFoundError
429 RateLimitError, with retry_after, once the client's own retries are used up
5xx ServerError

Branch on exc.code, never on the message, which may change. Quote request_id when you contact us.

from oddsrelay import InsufficientTokensError

try:
    result = client.odds("dutching", region="uk")
except InsufficientTokensError as exc:
    print(f"needs {exc.cost} tokens, {exc.remaining} left until {exc.resets_at}")

A 429 is retried for you: the client waits the Retry-After seconds and tries again, three requests in all. Pass your own sleep= to OddsRelay() to change how it waits.

Tokens and rate limits

Every Result, and every APIError, carries the reply's headers:

  • tokens: cost, used, remaining, limit and reset, from the X-Tokens-* headers
  • rate_limit: limit, remaining and reset, from the X-RateLimit-* headers
  • headers, etag and request_id

A header the reply did not carry reads as None.

Your key

The key travels only in the Authorization header. The client never puts it in a URL, a log line, a repr or an exception message, and it is not forwarded if a reply ever redirects. Spaces and newlines around the key, common when it is read from a file, are dropped.

Versions

oddsrelay.CONTRACT_VERSION names the version of the API description these methods follow. The API only adds to /v2, so new fields and new error codes reach you without an upgrade.

Licence

MIT. See LICENSE.

Metadata

Release files for oddsrelay 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for oddsrelay 0.1.0
File Size Uploaded
oddsrelay-0.1.0.tar.gz 19.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for oddsrelay 0.1.0
File Interpreter ABI Platform
oddsrelay-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 29.1 kB

Release files / oddsrelay-0.1.0.tar.gz

Download URL oddsrelay-0.1.0.tar.gz
Size 19.2 kB
Tags Source
SHA-256 checksum
How to use checksums
83563827479cd8f08aae22a6100b42e0d1eceacd81dff9baf6a128c57671bc58
BLAKE2b-256 checksum
How to use checksums
10a435e6826c4be03b79957b8e83893e5354f73d2089e094f7afed47921cb508
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 9, 2026.

Transparency log

Release files / oddsrelay-0.1.0-py3-none-any.whl

Download URL oddsrelay-0.1.0-py3-none-any.whl
Size 10.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
8d95990cdf50bdbcd8bc6b2cf22e49abda5fb21d537e96d8e90d91a3eb0a42e5
BLAKE2b-256 checksum
How to use checksums
f691278a0780616b57813f50ef6708023f6e4f5bd6769c4939f85d95497007fc
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 9, 2026.

Transparency log

Release history Release notifications | RSS feed

0.1.1

2 release files

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page