# OddsRelay > The odds feed, raw or ready-matched. OddsRelay is a B2B odds-data supplier: 140+ bookmakers · 60+ live in the UK & Ireland, delivered as one keyed REST API at https://api.oddsrelay.io (version 2026-07-08). OddsRelay supplies odds data only and takes no bets. Customers license access to the data, never the methods behind it. ## Plans - Free: £0 a month, 2,500 tokens a month, no card, for as long as an account stays on it. A paid plan keeps the same key. - Starter: £19 a month, 250,000 tokens a month. - Pro: £99 a month, 4M tokens a month. - Business: £449 a month, 40M tokens a month. - Enterprise: from £750 a month, talk to us. Every plan includes every product and every bookmaker in its region. Plans differ only in tokens. Details: https://oddsrelay.io/pricing ## Endpoints Base URL https://api.oddsrelay.io. Every route is GET. Full contract: https://api.oddsrelay.io/v2/openapi.json - /v2/odds/{type}: Matched odds for one product - /v2/odds/raw: Every bookmaker's and exchange's prices, a page at a time - /v2/odds/event/{id}: Raw prices for one event - /v2/sports: Sports and competitions, with their markets - /v2/events: Events for a sport - /v2/bookmakers: Bookmakers and exchanges - /v2/regions: Regions your key covers - /v2/coverage: What each bookmaker and exchange covers, by sport - /v2/status: Service status per product (no key needed) - /v2/health: How recently each product was updated - /v2/usage: Your plan, token balance and rate limits - /v2/pricing: The token price table - /v2/openapi.json: The OpenAPI 3.1 contract (no key needed) - /healthz: Returns 200 when the API is up (no key needed) ## Errors Every JSON error is {"error": {"code", "message", "request_id"}} plus the fields listed. Branch on code. - 400 bad_request: Unknown parameter; Gzip refused on standard, dutching or raw; Expired cursor - 400 missing_param: Required parameter missing - 400 region_required: No region and the key lacks uk - 400 unknown_bookmaker: Unknown bookmaker (valid trimmed) (extra fields: valid) - 400 unknown_exchange: A bookmaker named in exchanges (extra fields: valid) - 400 unknown_market: Unknown market (valid trimmed) (extra fields: valid) - 400 unknown_sport: Unknown sport (valid trimmed) (extra fields: valid) - 401 expired_api_key: Expired key - 401 invalid_api_key: Key not recognised - 401 missing_api_key: No key - 401 paused_api_key: Paused key - 401 revoked_api_key: Revoked key - 402 insufficient_tokens: A public plan but business; Plan business; A custom plan (extra fields: cost, plan, quote, rate_card, remaining, resets_at, upgrade_url) - 403 account_suspended: Suspended account (extra fields: dashboard_url, reason) - 403 bookmaker_not_in_plan: Venue outside the plan's bookmakers (extra fields: valid) - 403 bookmaker_not_in_region: Venue outside the region (extra fields: type) - 403 not_in_key_scope: Product outside the key (extra fields: type) - 403 origin_not_allowed: Browser key off its website - 403 product_not_in_region: Product the region does not serve (extra fields: type) - 403 region_not_in_scope: Region outside the key (extra fields: type) - 403 schema_not_in_scope: Key pinned to another API version (extra fields: type) - 404 not_found: No such route - 404 unknown_event: Event not on the raw board - 404 unknown_region: Unknown region - 404 unknown_type: Unknown board - 405 method_not_allowed: A method other than GET - 429 too_many_from_address: The per-address shield - 429 too_many_requests: The account's flood cap - 500 internal_error: Internal error - 503 raw_not_enabled: Raw board not held - 503 temporarily_unavailable: Board or registry not ready ## Guides ## Example client > A complete client in cURL, Python and JavaScript: quote, call, read the token headers and poll with ETags. ### Before you start You need an API key. [Sign up free](https://app.oddsrelay.io/signup): your first key is shown once when you finish. Save it as the environment variable ODDSRELAY_KEY, and keep it out of code, query strings and anything that runs in a browser. ### The whole flow in three languages Each sample makes one small call on the standard product: football match odds at one bookmaker in the UK & Ireland. It prices the call first with a free quote, makes it with gzip, reads the token headers, then polls again with the ETag. It backs off on a 429 and stops on a 402. Each is tested against the live API. #### cURL (oddsrelay.sh) ```curl #!/usr/bin/env bash ## OddsRelay API sample, cURL. Run: ODDSRELAY_KEY=or_live_… bash oddsrelay.sh # ## One small filtered call on the standard board, the way every client should make it: ## 1. price it first with a free quote=true; ## 2. make it with --compressed (gzip; standard refuses a call without it) and read the token headers; ## 3. poll it again with If-None-Match, where an unchanged board answers 304, free; ## 4. back off on 429 (Retry-After) and stop on 402 (out of tokens). set -euo pipefail BASE="https://api.oddsrelay.io" : "${ODDSRELAY_KEY:?set ODDSRELAY_KEY}" ## Football match odds at one bookmaker, UK & Ireland: a small call. URL="$BASE/v2/odds/standard?region=uk&sports=soccer&markets=h2h&bookmakers=ladbrokes" MAX_TOKENS=1000 tmp=$(mktemp -d) trap 'rm -rf "$tmp"' EXIT ## call URL [extra header]: writes headers to $tmp/h, body to $tmp/b, prints the status. Retries 429s. call() { local status extra=() [ -n "${2:-}" ] && extra=(-H "$2") for _ in 1 2 3; do status=$(curl --compressed -sS -o "$tmp/b" -D "$tmp/h" -w '%{http_code}' \ -H "Authorization: Bearer $ODDSRELAY_KEY" ${extra[@]+"${extra[@]}"} "$1") if [ "$status" = 429 ]; then # The front door's own 429 has an empty body; both carry Retry-After. wait=$(awk -F': ' 'tolower($1)=="retry-after"{print $2+0}' "$tmp/h") echo "429: waiting ${wait:-1} s" >&2 sleep "${wait:-1}" continue fi if [ "$status" = 402 ]; then echo "402: out of tokens: $(cat "$tmp/b")" >&2 exit 1 fi echo "$status" return done echo "still rate-limited after 3 tries" >&2 exit 1 } header() { awk -F': ' -v k="$(echo "$1" | tr 'A-Z' 'a-z')" 'tolower($1)==k{sub(/\r$/,"",$2); print $2}' "$tmp/h"; } tokens() { echo "cost=$(header X-Tokens-Cost) remaining=$(header X-Tokens-Remaining) reset=$(header X-Tokens-Reset)"; } ## 1. Quote: free. [ "$(call "$URL"e=true")" = 200 ] || { echo "quote failed: $(cat "$tmp/b")" >&2; exit 1; } cost=$(sed -E 's/.*"cost": *([0-9]+).*/\1/' "$tmp/b") echo "quote: $cost tokens" [ "$cost" -le "$MAX_TOKENS" ] || { echo "quote $cost is above $MAX_TOKENS: narrow the filters" >&2; exit 1; } ## 2. The call. [ "$(call "$URL")" = 200 ] || { echo "call failed: $(head -c 200 "$tmp/b")" >&2; exit 1; } etag=$(header ETag) echo "200: $(tokens)" ## 3. Poll with the ETag: 304 if nothing changed (free), else a fresh 200. status=$(call "$URL" "If-None-Match: $etag") case "$status" in 200|304) ;; *) echo "poll answered $status" >&2; exit 1 ;; esac echo "$status on poll: $(tokens)" echo "ok" ``` #### Python (oddsrelay.py) ```python ## OddsRelay API sample, Python 3 standard library only. Run: ODDSRELAY_KEY=or_live_… python3 oddsrelay.py # ## One small filtered call on the standard board, the way every client should make it: ## 1. price it first with a free quote=true; ## 2. make it with gzip and read the token headers (urllib sends Accept-Encoding: identity unless ## told otherwise, and standard refuses that, so this asks for gzip and decompresses); ## 3. poll it again with If-None-Match, where an unchanged board answers 304, free; ## 4. back off on 429 (Retry-After) and stop on 402 (out of tokens). import gzip import json import os import time import urllib.error import urllib.request BASE = "https://api.oddsrelay.io" KEY = os.environ["ODDSRELAY_KEY"] ## Football match odds at one bookmaker, UK & Ireland: a small call. PATH = "/v2/odds/standard?region=uk&sports=soccer&markets=h2h&bookmakers=ladbrokes" MAX_TOKENS = 1000 # don't make the call if the quote is above this def call(path, headers=None): """(status, response headers, decoded body bytes). Retries 429s; raises on 402.""" for _ in range(3): req = urllib.request.Request( BASE + path, headers={"Authorization": f"Bearer {KEY}", "Accept-Encoding": "gzip", **(headers or {})}, ) try: with urllib.request.urlopen(req) as res: status, hdrs, raw = res.status, res.headers, res.read() except urllib.error.HTTPError as err: # 304 and every 4xx/5xx arrive here status, hdrs, raw = err.code, err.headers, err.read() if hdrs.get("Content-Encoding") == "gzip" and raw: raw = gzip.decompress(raw) if status == 429: # The front door's own 429 has an empty body; both carry Retry-After. wait = int(hdrs.get("Retry-After", "1")) print(f"429: waiting {wait} s") time.sleep(wait) continue if status == 402: err = json.loads(raw)["error"] raise SystemExit(f"402 {err['code']}: {err['cost']} tokens needed, {err['remaining']} left until {err['resets_at']}") return status, hdrs, raw raise SystemExit("still rate-limited after 3 tries") def tokens(hdrs): return f"cost={hdrs.get('X-Tokens-Cost')} remaining={hdrs.get('X-Tokens-Remaining')} reset={hdrs.get('X-Tokens-Reset')}" ## 1. Quote: free. status, _, raw = call(PATH + ""e=true") if status != 200: raise SystemExit(f"quote answered {status}") quote = json.loads(raw) print(f"quote: {quote['cost']} tokens, {quote['remaining']} left") if quote["cost"] > MAX_TOKENS: raise SystemExit(f"quote {quote['cost']} is above {MAX_TOKENS}: narrow the filters") ## 2. The call. status, hdrs, raw = call(PATH) if status != 200: raise SystemExit(f"call answered {status}: {raw[:200]!r}") etag = hdrs.get("ETag") meta = json.loads(raw)["meta"] print(f"200: {meta['count']} events, board time {meta['processed_at']}, {tokens(hdrs)}") ## 3. Poll with the ETag: 304 if nothing changed (free), else a fresh 200. status, hdrs, _ = call(PATH, {"If-None-Match": etag}) if status not in (200, 304): raise SystemExit(f"poll answered {status}") print(f"{status} on poll: {tokens(hdrs)}") print("ok") ``` #### JavaScript (oddsrelay.mjs) ```js // OddsRelay API sample, JavaScript (Node 18+). Run: ODDSRELAY_KEY=or_live_… node oddsrelay.mjs // // One small filtered call on the standard board, the way every client should make it: // 1. price it first with a free quote=true; // 2. make it (fetch sends Accept-Encoding: gzip and decompresses for you) and read the token headers; // 3. poll it again with If-None-Match, where an unchanged board answers 304, free; // 4. back off on 429 (Retry-After) and stop on 402 (out of tokens). const BASE = "https://api.oddsrelay.io"; const KEY = process.env.ODDSRELAY_KEY; if (!KEY) throw new Error("set ODDSRELAY_KEY"); // Football match odds at one bookmaker, UK & Ireland: a small call. const PATH = "/v2/odds/standard?region=uk&sports=soccer&markets=h2h&bookmakers=ladbrokes"; const MAX_TOKENS = 1000; // don't make the call if the quote is above this const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function call(path, headers = {}) { for (let attempt = 1; attempt <= 3; attempt++) { const res = await fetch(BASE + path, { headers: { Authorization: `Bearer ${KEY}`, ...headers } }); if (res.status === 429) { // The front door's own 429 has an empty body; both carry Retry-After. const wait = Number(res.headers.get("retry-after") ?? "1"); console.log(`429: waiting ${wait} s`); await sleep(wait * 1000); continue; } if (res.status === 402) { const { error } = await res.json(); throw new Error(`402 ${error.code}: ${error.cost} tokens needed, ${error.remaining} left until ${error.resets_at}`); } return res; } throw new Error("still rate-limited after 3 tries"); } const tokens = (res) => `cost=${res.headers.get("x-tokens-cost")} remaining=${res.headers.get("x-tokens-remaining")} reset=${res.headers.get("x-tokens-reset")}`; // 1. Quote: free. const quoteRes = await call(`${PATH}"e=true`); if (quoteRes.status !== 200) throw new Error(`quote answered ${quoteRes.status}`); const quote = await quoteRes.json(); console.log(`quote: ${quote.cost} tokens, ${quote.remaining} left`); if (quote.cost > MAX_TOKENS) throw new Error(`quote ${quote.cost} is above ${MAX_TOKENS}: narrow the filters`); // 2. The call. const res = await call(PATH); if (res.status !== 200) throw new Error(`call answered ${res.status}: ${await res.text()}`); const etag = res.headers.get("etag"); const { meta } = await res.json(); console.log(`200: ${meta.count} events, board time ${meta.processed_at}, ${tokens(res)}`); // 3. Poll with the ETag: 304 if nothing changed (free), else a fresh 200. const again = await call(PATH, { "If-None-Match": etag }); if (again.status !== 304 && again.status !== 200) throw new Error(`poll answered ${again.status}`); console.log(`${again.status} on poll: ${tokens(again)}`); console.log("ok"); ``` ### What to change first Swap the product in the path, and the filters in the query, for the data you need. Keep region on every call, keep gzip on, and keep the quote step for any call you haven't priced before. Source: https://oddsrelay.io/docs/guides/quickstart · the API contract: https://api.oddsrelay.io/v2/openapi.json ## Tokens, quotes and rate limits > How a call is priced, how filters lower the price, and how to handle a 402 or a 429. ### A call is priced from the request Each account has one token pool a month. A call's price comes from the product and the filters you send, never from the size of the reply. GET /v2/pricing publishes the table, and /pricing on the site explains it with worked examples. On standard, dutching and raw, each filter narrows the price: every sport, bookmaker, exchange or market you name carries points, and the price is multiplied by the named points over 100, never above 1. On dutching and raw the bookmakers and exchanges you name count together as one filter. The four smaller products cost the same whatever you filter. The result is rounded up, and a call never costs less than 1 token. On raw, one price covers the whole walk: each page pays its share of the query's price. An `updatedSince` no more than 120 s back pays 10% of that price. ### Quote before a big call Add quote=true to any data request. It returns the price instead of the odds and costs nothing. A quote checks your key and parameters but not your balance, so a quote can succeed where the real call returns 402 (or 503 if the product isn't ready). A free quote: ``` curl --compressed -s -H "Authorization: Bearer $ODDSRELAY_KEY" \ "https://api.oddsrelay.io/v2/odds/standard?region=uk&sports=soccer"e=true" ## {"cost": …, "remaining": …, "rate_card": "…"} ``` ### What is free Discovery (/v2/sports, /v2/events, /v2/bookmakers, /v2/regions), /v2/usage, /v2/pricing, quotes, 304 replies, replies with no events, and every refusal. ### Read the token headers Every reply to a valid key carries X-Tokens-Cost. X-Tokens-Used, X-Tokens-Remaining, X-Tokens-Limit and X-Tokens-Reset come with it whenever the balance is known; Remaining and Limit read unlimited on an unlimited account. ### On a 402 402 `insufficient_tokens` means the price is above the balance, and nothing was served or spent. The body carries `cost`, `remaining`, `resets_at` (on a trial, the trial's end when that comes first), `plan`, `rate_card`, `quote` and, except on a custom Enterprise plan, `upgrade_url`. Narrow the filters, wait for `resets_at`, or change plan. Retrying the same call won't help, and a 402 never carries Retry-After. ### On a 429 429 `too_many_requests` means the account's rate limit, per 10 seconds or per minute, or its cap on requests in flight. 429 `too_many_from_address` is the per-address limit before authentication. Wait for the Retry-After seconds, then retry. Our edge also limits each address and the whole service. Its 429s carry Retry-After with an empty body and no code, as does its 502. Handle an empty-bodied 429 the same way, and back off on a 502. Source: https://oddsrelay.io/docs/guides/filters-and-tokens · the API contract: https://api.oddsrelay.io/v2/openapi.json ## Filtering matched odds > The sports, bookmakers, exchanges and markets filters, the kick-off window, and what a filtered reply leaves out. ### The endpoint GET /v2/odds/{type} serves `standard`, `2up`, `dutching`, `each-way`, `extra-place` and `bog`. There is no event filter; for one event use GET /v2/odds/event/{id}. ### The filters and their caps Values are comma-separated and lowercased. /v2/sports and /v2/bookmakers list the values. - sports, at most 20: a sport key or a prefix of one (`soccer`, `soccer_epl`). - bookmakers, at most 40: keeps back offers from those bookmakers. - exchanges, at most 4: keeps lay offers from those exchanges, each-way win and place included. - markets, at most 20: keeps those markets. - commenceTimeFrom and commenceTimeTo: the kick-off window, ISO-8601 UTC. The window is never priced. ### The trimming rule Filtering never changes a price: you get the full product with the rest removed. Events are cut by sport and kick-off, then markets, then back offers by bookmaker and lay offers by exchange. An outcome left without a back offer, or without a lay offer, goes; then empty markets, then empty events. Event order stays kick-off, then id. On dutching a dutch is kept only when every one of its legs is at a bookmaker you named. A call that leaves nothing is an empty reply, and free. ### Unknown and absent values A value the catalogue doesn't know is a 400 (`unknown_sport`, `unknown_market`, `unknown_bookmaker`, `unknown_exchange`) listing the valid ones in `valid`. A known value with nothing on it today is an empty 200, free. A bookmaker outside the call's region is 403 `bookmaker_not_in_region`. ### No per-price time stamp Matched offers carry no time of their own. The reply's time is meta.processed_at, and meta.last_seen gives each bookmaker and sport's last read. The freshness guide covers both. Source: https://oddsrelay.io/docs/guides/matched-board-filters · the API contract: https://api.oddsrelay.io/v2/openapi.json ## How fresh a price is > The timestamps each reply carries and how to work out a price's age. ### Reply time meta.processed_at, and the X-Processed-At header, are the reply's time. X-OddsRelay-Version says which version of the data you were served, the same on every route for one publish. ### When each bookmaker was last read On the matched products, meta.last_seen is {bookmaker key: {sport group: ISO time}}: the last time each bookmaker and sport was read, the oldest reading behind the reply. Now minus `last_seen` is the most that bookmaker's prices can have aged. It is keyed by the sport's key base (`soccer`, `horse_racing`, `american_football`), not an event's full `sport_key`: take the group /v2/sports gives each sport key (American Football) and write it lowercase with underscores. A filtered reply's table is cut to the bookmakers and sports it returns. Keyed /v2/coverage carries the same `last_seen` per bookmaker and sport, with event counts. A price's age, JavaScript: ``` // groupOf: {sport_key: key base}, built once from GET /v2/sports rows: base = group.toLowerCase().replace(/[^a-z0-9]+/g, "_") const seen = meta.last_seen[offer.bookmaker]?.[groupOf[event.sport_key]]; const ageSeconds = seen ? (Date.now() - Date.parse(seen)) / 1000 : null; ``` ### Raw's `last_update` On raw, each market's `last_update` is when that bookmaker's market last changed: a price, a lay price or available moved, or a row appeared. A service restart can move it once with nothing changed, so dedupe on content. It is not a heartbeat: a quiet market's `last_update` ages while the bookmaker is still read. ### What is never cut The API drops no row for age. A bookmaker whose latest read is older than its own window, at most two minutes, is left out until it is read again. Source: https://oddsrelay.io/docs/guides/freshness · the API contract: https://api.oddsrelay.io/v2/openapi.json ## Reading coverage > What /v2/coverage returns with and without a key, and what live and interrupted mean. ### One document per region, two views GET /v2/coverage?region= answers from one document per region, in two views. region missing means uk. ### Without a key Send no Authorization and no x-api-key header. Per product, each bookmaker and sport is live, or `interrupted_24h`: an interruption in the last 24 hours, or no rows now. There are no counts and no times. It is public and cacheable (Cache-Control: public, max-age=60, Access-Control-Allow-Origin: *). This is the route, not the /coverage page on the site, which lists coverage and no statuses. Keyless: ``` curl --compressed -s "https://api.oddsrelay.io/v2/coverage?region=uk" ``` ### With a key Send either key header (an empty or invalid one is a 401, never the public view). You get your key's products that the region serves, bookmakers and exchanges apart, each with the sports it has rows for now, their event counts and `last_seen`. It is free and counts toward your rate limit. Source: https://oddsrelay.io/docs/guides/coverage-method · the API contract: https://api.oddsrelay.io/v2/openapi.json ## Paging through raw odds > Page through raw odds with a cursor, handle expiry, and fetch only what changed. ### Pages and the cursor GET /v2/odds/raw returns one page of up to 250 events (limit sets fewer), ordered by kick-off then event id. Follow meta.next_cursor until it is `null`. meta.total is the events matching the query across all pages. Raw is served gzip only. ### When a cursor expires A cursor expires 300 s after it was issued. An expired cursor is 400 with the message cursor expired: start again from the first page. ### Duplicates and misses A cursor resumes strictly after the last event it saw, so an event that leaves the feed between pages never restarts the walk. An event whose kick-off moves across the cursor during a walk can appear twice or be missed: dedupe by event id, and take a missed event on the next walk. ### Following changes Walk again with updatedSince set to the start of your previous walk, as YYYY-MM-DDTHH:MM:SSZ. It keeps the bookmakers whose `last_update` is at or after it. Each page costs its share of the query's price, so a full walk costs the price once, and a recent updatedSince costs less (the recent rule in /v2/pricing). Source: https://oddsrelay.io/docs/guides/walking-raw · the API contract: https://api.oddsrelay.io/v2/openapi.json ## Polling with ETags > Send If-None-Match to get a free 304 when nothing has changed. ### The label Data replies, /v2/sports, /v2/events and /v2/bookmakers carry an ETag: a label of the decompressed content, with -gz inside the quotes on the gzip copy. It changes when, and only when, those bytes change. /v2/pricing and /v2/openapi.json carry the body's SHA-256 instead. ### The 304 Send the ETag back as If-None-Match. If the body would be the same, the reply is 304 Not Modified. It has an empty body and costs nothing, and it still counts toward your rate limit. Keep the rows you have. A call the balance can't cover is refused 402 even with If-None-Match. ### Pacing Keyed replies carry Cache-Control: private, max-age=2, must-revalidate. Poll as often as your product needs: an unchanged reply costs you only a free 304. Source: https://oddsrelay.io/docs/guides/conditional-requests · the API contract: https://api.oddsrelay.io/v2/openapi.json ## Converting to American odds > Which products return American odds, and a function to convert the rest. ### Decimal only on standard and dutching `standard` and `dutching` serve decimal odds only: oddsFormat=american is a 400 there. `2up`, `each-way`, `extra-place`, `bog`, `raw` and the single-event route take oddsFormat=american and convert for you. On `standard` and `dutching`, convert yourself. This matches the API's own conversion: a price at or below 1.0 has no American form (`null`), and halves round to even. JavaScript: ``` const roundHalfEven = (x) => { const f = Math.floor(x); return x - f === 0.5 ? (f % 2 === 0 ? f : f + 1) : Math.round(x); }; const american = (d) => (d <= 1 ? null : d >= 2 ? roundHalfEven((d - 1) * 100) : roundHalfEven(-100 / (d - 1))); const decimal = (a) => (a > 0 ? 1 + a / 100 : 1 + 100 / -a); ``` Source: https://oddsrelay.io/docs/guides/american-odds · the API contract: https://api.oddsrelay.io/v2/openapi.json