API documentation
Every endpoint is a GET request to https://api.oddsrelay.io with your API key in a header. A call costs tokens, set by the product and the filters you send, never by the size of the reply.
API version 2026-07-08 · Changelog
Quickstart
Get a key. Sign up free. Your first API key is shown once when you finish, so copy it then. Save it as the environment variable ODDSRELAY_KEY.
Make your first call. Football match odds (sports=soccer, markets=h2h) in UK & Ireland, on the standard product:
# --compressed asks for gzip and decompresses it for you. BASE=https://api.oddsrelay.io curl --compressed -sS -D - \ -H "Authorization: Bearer $ODDSRELAY_KEY" \ "$BASE/v2/odds/standard?region=uk&sports=soccer&markets=h2h"This call costs 1,107 tokens (pricing). Try it in the request tool
Check the price first. Add quote=true to any odds request to get its cost without running it. Quotes are free.
BASE=https://api.oddsrelay.io curl --compressed -s -H "Authorization: Bearer $ODDSRELAY_KEY" \ "$BASE/v2/odds/standard?region=uk&sports=soccer"e=true" # {"cost": 5400, "remaining": …, "rate_card": "…"}Poll without paying twice. Send the last ETag back as If-None-Match. If nothing has changed you get a 304 with no body, and it costs no tokens.
# Send the last ETag back exactly as you got it, quotes included: # ETAG='"c1f97f865-4517-gz"' BASE=https://api.oddsrelay.io curl --compressed -s -o /dev/null -w "%{http_code}\n" \ -H "Authorization: Bearer $ODDSRELAY_KEY" \ -H "If-None-Match: $ETAG" \ "$BASE/v2/odds/standard?region=uk&sports=soccer&markets=h2h"
For a complete client with retries and polling, see Example client.
Authentication
Send your key in the Authorization header as Bearer <key>, or in x-api-key. Never put it in the query string.
Server keys start or_live_: keep them on your server and out of code repositories. Browser keys start or_pub_, come with the Enterprise plan, and only work on the websites listed on the key.
No key is needed for /healthz, /v2/status, /v2/openapi.json, or the public view of /v2/coverage.
Regions
Odds endpoints take one region code. Available today: uk (UK & Ireland), which is also the default. Send one lowercase code; a list, a repeat or an uppercase code returns 400. Discovery and account endpoints take no region. GET /v2/regions lists the regions your key covers.
Endpoints
All endpoints are GET requests to https://api.oddsrelay.io. Try opens the call in your dashboard's request tool, which shows its cost before it runs. You need to be signed in.
| Endpoint | Returns | Cost |
|---|---|---|
| /v2/odds/{type} | Matched odds for one product. Try | Tokens |
| /v2/odds/raw | Every bookmaker's and exchange's prices, a page at a time. Try | Tokens |
| /v2/odds/event/{id} | Raw prices for one event | Tokens |
| /v2/sports | Sports and competitions, with their markets | Free |
| /v2/events | Events for a sport | Free |
| /v2/bookmakers | Bookmakers and exchanges | Free |
| /v2/regions | Regions your key covers | Free |
| /v2/coverage | What each bookmaker and exchange covers, by sport | Free |
| /v2/status | Service status per product (no key needed) | Free |
| /v2/health | How recently each product was updated | Free |
| /v2/usage | Your plan, token balance and rate limits | Free |
| /v2/pricing | The token price table | Free |
| /v2/openapi.json | The OpenAPI 3.1 contract (no key needed) | Free |
| /healthz | Returns 200 when the API is up (no key needed) | Free |
Products
GET /v2/odds/{type} returns matched odds for one product. Each event lists its markets, each market its outcomes, and each outcome the best back prices from bookmakers beside the best lay prices from exchanges.
| Product | What it holds | Try |
|---|---|---|
| standard | Every bookmaker's back price beside the best exchange lay price, event by event. | Try |
| 2up | Football prices from bookmakers' 2Up markets, beside the lay for the same team. | Try |
| dutching | Back prices covering every outcome of a market, grouped into ready-made dutches. | Try |
| each-way | Horse racing prices with each bookmaker's each-way terms, and both lays. | Try |
| extra-place | Only the races where a bookmaker pays more places than the exchange. | Try |
| bog | Win prices from bookmakers offering best odds guaranteed on a race. | Try |
| raw | Every bookmaker's and exchange's prices, unmatched, 250 events a page. | Try |
On each-way and extra-place, lay is split into win and place. dutching has no lay prices; its back legs share a dutch_id. Prices don't include exchange commission: apply your own rate when you work out ratings and lay stakes.
Event ids are the same on every endpoint, but an id can change once, when a higher-ranked exchange lists the fixture. Don't use it as a permanent key.
Raw odds
GET /v2/odds/rawreturns every bookmaker's and exchange's prices per event, up to 250 events a page, ordered by kick-off. Follow meta.next_cursor for the next page. A cursor expires after 300 seconds; if it does, start again from the first page. Deduplicate by event id. Add updatedSince to get only what changed. See Paging through raw odds.
Discovery
/v2/sports, /v2/events, /v2/bookmakers and /v2/regions are free and list the values the filters accept.
What a reply carries
| Field | Description |
|---|---|
| meta.processed_at | When the reply was built, also sent as X-Processed-At. |
| meta.last_seen | When each bookmaker and sport was last read. The time since then is the most a price can have aged. |
| meta.count | Events in data. |
| X-OddsRelay-Version | A response header, not a field: which version of the data you were served, the same on every endpoint for one publish. |
Prices carry no timestamp of their own; use meta.last_seen. A bookmaker not read within its own window, at most two minutes, is left out until it is read again. See How fresh a price is.
Filters
| Parameter | Max values | Returns only |
|---|---|---|
| sports | 20 | Events in these sports or competitions (a sport key or a prefix, such as soccer) |
| bookmakers | 40 | Back prices from these bookmakers |
| exchanges | 4 | Lay prices from these exchanges |
| markets | 20 | These markets, such as h2h |
| commenceTimeFrom, commenceTimeTo | – | Events kicking off in this window (ISO 8601, UTC) |
Filtering never changes a price: you get the full product with the rest removed. An outcome left with no back or no lay price is dropped, then any empty market or event. On dutching, a dutch is kept only when every leg is at a bookmaker you named. An unknown value returns 400 with the valid values listed. A valid value with nothing on today returns an empty reply, which is free. For one event, use /v2/odds/event/{id}.
Tokens
Each account has one pool of tokens that resets every month. A call's price depends on the product and the filters, never on the size of the reply. GET /v2/pricing returns the price table, Pricing explains it with examples, and quote=true prices any request for free.
Free: discovery endpoints, /v2/usage, /v2/pricing, quotes, 304 replies, empty replies and every error. A call that costs more than your balance returns 402 insufficient_tokens and nothing is charged. The body includes cost, plan, quote, rate_card, remaining, resets_at and upgrade_url, so you can narrow the filters or wait for the reset. On a custom Enterprise plan there is no upgrade_url.
| Header | Description |
|---|---|
| X-Tokens-Cost | What this reply cost; 0 when it was free. Sent on every reply to a valid key. |
| X-Tokens-Used | Tokens used this period. |
| X-Tokens-Remaining | Tokens left this period, or unlimited. |
| X-Tokens-Limit | Your allowance for the period, or unlimited. |
| X-Tokens-Reset | When the allowance resets (ISO 8601, UTC). On a trial, the trial's end if that is sooner. |
The last four are sent whenever the balance is known, so treat them as optional. See Tokens, quotes and rate limits.
Rate limits
Each account is limited per 10 seconds, per minute, and in concurrent requests; GET /v2/usage shows your limits. Going over returns 429 too_many_requests with a Retry-After header. Every authenticated request counts, including 304s. Before a key is checked, each IP address has its own limit (429 too_many_from_address).
| Header | Description |
|---|---|
| X-RateLimit-Limit | Your limit for the window closest to running out: 10 seconds or 1 minute. |
| X-RateLimit-Remaining | Requests left in that window. |
| X-RateLimit-Reset | Seconds until that window resets. |
| Retry-After | On every 429 and 503: seconds to wait. Never sent on a 402. |
Our edge also limits each IP address and the service as a whole. Its 429 and 502 replies have an empty body and no error code; honour Retry-After and back off.
Compression and caching
Send Accept-Encoding: gzip (the quickstart's --compressed does this) to get bodies over 1 kB compressed. Without it you get plain bodies. standard, dutching and raw refuse a request that rules gzip out (Accept-Encoding: identity) with a 400. Python's urllib sends that by default, so set Accept-Encoding: gzip yourself. /healthz, /v2/status, /v2/health, /v2/usage, quotes and errors are always plain.
Odds replies, /v2/sports, /v2/events and /v2/bookmakers carry an ETag. Send it back exactly as you received it, quotes included, in If-None-Match. If nothing has changed you get 304with no body: it costs no tokens but still counts towards your rate limit. A call your balance can't cover returns 402 even with If-None-Match.
Errors
Errors return JSON: {"error": {"code", "message", "request_id"}}, plus any extra fields listed below. Branch on code, not message, which may change.
| Status | Code | When |
|---|---|---|
| 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)Also returns: valid |
| 400 | unknown_exchange | A bookmaker named in exchangesAlso returns: valid |
| 400 | unknown_market | Unknown market (valid trimmed)Also returns: valid |
| 400 | unknown_sport | Unknown sport (valid trimmed)Also returns: 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 planAlso returns: cost, plan, quote, rate_card, remaining, resets_at, upgrade_url |
| 403 | account_suspended | Suspended accountAlso returns: dashboard_url, reason |
| 403 | bookmaker_not_in_plan | Venue outside the plan's bookmakersAlso returns: valid |
| 403 | bookmaker_not_in_region | Venue outside the regionAlso returns: type |
| 403 | not_in_key_scope | Product outside the keyAlso returns: type |
| 403 | origin_not_allowed | Browser key off its website |
| 403 | product_not_in_region | Product the region does not serveAlso returns: type |
| 403 | region_not_in_scope | Region outside the keyAlso returns: type |
| 403 | schema_not_in_scope | Key pinned to another API versionAlso returns: 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 |
For help with an error, contact us and include the request_id from the reply.
Versioning
This is API version 2026-07-08, returned in the X-OddsRelay-Api-Version header. Within /v2we only add things: endpoints, optional fields, enum values and error codes, so ignore fields you don't recognise. A breaking change ships as /v3, announced by email and in the changelog at least 6 months ahead, and /v2 keeps running for at least 12 months after that.
More
Guides
- Example client: A complete client in cURL, Python and JavaScript: quote, call, read the token headers and poll with ETags.
- Tokens, quotes and rate limits: How a call is priced, how filters lower the price, and how to handle a 402 or a 429.
- Filtering matched odds: The sports, bookmakers, exchanges and markets filters, the kick-off window, and what a filtered reply leaves out.
- How fresh a price is: The timestamps each reply carries and how to work out a price's age.
- Reading coverage: What /v2/coverage returns with and without a key, and what live and interrupted mean.
- Paging through raw odds: Page through raw odds with a cursor, handle expiry, and fetch only what changed.
- Polling with ETags: Send If-None-Match to get a free 304 when nothing has changed.
- Converting to American odds: Which products return American odds, and a function to convert the rest.
Use with an AI assistant
Copy this prompt into your coding assistant and replace the last line with what you want to build. It covers keys, requests, tokens, caching and every error code. Tools that read docs directly can use llms.txt and llms-full.txt.
Show the prompt
You are integrating the OddsRelay odds API (version 2026-07-08). Follow this contract exactly.
BASE URL
https://api.oddsrelay.io (every route is GET; the full contract is https://api.oddsrelay.io/v2/openapi.json)
KEYS
Send the key as Authorization: Bearer <key> or x-api-key: <key>, never in a query string.
Server keys start or_live_. Read the key from an environment variable; never ship it to a browser.
THE CALL
GET /v2/odds/{type}?region=uk products: standard, 2up, dutching, each-way, extra-place, bog
GET /v2/odds/raw?region=uk every bookmaker's prices, paged (meta.next_cursor; a cursor expires after 300 s)
On data routes region is one lowercase code; missing means uk (400 region_required if your key lacks uk).
Discovery routes like /v2/bookmakers take no region. Any parameter a route doesn't read is 400.
Filters on the matched products: sports (<= 20), bookmakers (<= 40), exchanges (<= 4), markets (<= 20),
commenceTimeFrom / commenceTimeTo. Discovery lists the values: GET /v2/sports, /v2/bookmakers.
TOKENS
Every data call costs tokens, priced from the request (product and filters), never the reply's size.
Add quote=true to any data request to get {cost, remaining, rate_card} for free, before a big call.
Read X-Tokens-Cost on replies to a valid key; X-Tokens-Remaining and X-Tokens-Reset come when the balance is
known, so treat them as optional.
402 insufficient_tokens means the price is above the balance: narrow the filters or wait for resets_at (a trial's
end, on a trial). upgrade_url is absent on a custom plan.
DELIVERY
Accept gzip and check Content-Encoding: standard, dutching and raw answer a request that refuses gzip
(Accept-Encoding: identity) with 400. Status, health, usage, quotes and errors are always plain.
Store each ETag exactly as sent (quotes included) and send it back as If-None-Match: 304 means unchanged,
no body, no tokens. Quotes carry no ETag.
Rate limits are per account (X-RateLimit-*). On 429 honour Retry-After; our edge's own 429 and 502
have an empty body.
RESPONSE SHAPE (matched products)
{ "meta": { "feed_type", "region", "odds_format", "processed_at", "last_seen", "count",
"version", "next_cursor" },
"data": [ { "event_id", "sport_key", "sport_title", "commence_time", "home_team", "away_team",
"markets": [ { "key", "outcomes": [ { "name", "back": [...], "lay": [...] } ] } ] } ] }
lay is an array on standard, 2up and bog; an object {win: [...], place: [...]} on each-way and
extra-place; absent on dutching, where back legs carry dutch_id (group by it; a dutch rates 100 / sum(1/price)).
Event ids are stable across routes but re-key once when a higher-ranked exchange lists the fixture: don't
store them as permanent keys. Every level can be empty; walk it defensively.
ERRORS
Every JSON error is { "error": { "code", "message", "request_id" } }, plus the extra fields listed.
400 bad_request, missing_param, region_required, unknown_bookmaker (valid), unknown_exchange (valid), unknown_market (valid), unknown_sport (valid)
-> fix the request; do not retry.
401 expired_api_key, invalid_api_key, missing_api_key, paused_api_key, revoked_api_key
-> stop and surface it; do not retry.
402 insufficient_tokens (cost, plan, quote, rate_card, remaining, resets_at, upgrade_url)
-> out of tokens: add filters or wait for X-Tokens-Reset; price a call first with quote=true.
403 account_suspended (dashboard_url, reason), bookmaker_not_in_plan (valid), bookmaker_not_in_region (type), not_in_key_scope (type), origin_not_allowed, product_not_in_region (type), region_not_in_scope (type), schema_not_in_scope (type)
-> refused for this account, key, region or plan; do not retry.
404 not_found, unknown_event, unknown_region, unknown_type
-> a bad path or value.
405 method_not_allowed
-> use GET.
429 too_many_from_address, too_many_requests
-> back off and honour Retry-After.
500 internal_error
-> transient; retry with backoff.
503 temporarily_unavailable
-> transient; retry with backoff, honouring Retry-After.
503 raw_not_enabled
-> raw odds are not served here: stop and report it; do not retry.
Branch on code, never on message. Log request_id.
WHAT TO BUILD
[Describe what you want here.]
Widgets
The oddsmatcher widget shows live odds on your own site with one script tag. Live odds need a browser key (or_pub_), available on the Enterprise plan; the calculators need no key. See Widgets.
Reference and contract
- API reference: every endpoint, parameter and response
- OpenAPI 3.1 contract: for Postman, Insomnia or a code generator
- Postman collection