API reference
Integrate in an afternoon.
The OddsRelay feed is one keyed, versioned REST API: get a key, send one authenticated GET, receive matched opportunities. Deep UK bookmaker coverage with bet365 included, exchange/lay-ready — served from api.oddsrelay.io/v1.
Quickstart
1. Get a key. Start a free trial — your or_test_* key serves the full UK product (all 7 feed types, bet365 included) for 14 days. Live keys are or_live_*.
2. Make one call. Ask for matched standard opportunities in the UK:
curl -s --compressed "https://api.oddsrelay.io/v1/odds/standard?region=uk" \
-H "Authorization: Bearer <YOUR_API_KEY>"3. Poll efficiently. The board rolls roughly every 15s. Always send Accept-Encoding: gzip (the payload is large) and If-None-Match with the last ETag — together they collapse most polls to a tiny 304 Not Modified.
# 1. First poll — capture the ETag
ETAG=$(curl -sD - -o /dev/null --compressed \
"https://api.oddsrelay.io/v1/odds/standard?region=uk" \
-H "Authorization: Bearer <YOUR_API_KEY>" \
| awk -F': ' 'tolower($1)=="etag"{print $2}' | tr -d '\r')
# 2. Subsequent polls — send it back. Until the board rolls you get a
# bodyless 304 Not Modified (fast + free), otherwise a fresh 200 + new ETag.
curl -s -o /dev/null -w "%{http_code}\n" --compressed \
"https://api.oddsrelay.io/v1/odds/standard?region=uk" \
-H "Authorization: Bearer <YOUR_API_KEY>" \
-H "If-None-Match: $ETAG"Authentication
Every request needs a key, sent as a Bearer token or an x-api-key header. Keys are 256-bit, shown once at issue, and stored hashed. A key is never accepted in a query parameter — it would leak into logs.
# Preferred — Bearer
Authorization: Bearer <YOUR_API_KEY>
# Equivalent — x-api-key header
x-api-key: <YOUR_API_KEY>
# NEVER in a query string (keys leak into logs) — this is rejected:
# https://api.oddsrelay.io/v1/odds/standard?api_key=... ✗A key carries a scope across four axes — feed types, regions, form (processed/raw) and add-ons (e.g. bet365). Requests outside scope return a 403 (see Errors).
The region filter
One endpoint set, one filter: ?region=uk (the default) — or a comma-separated list like ?region=uk,ie. There is no endpoint-per-region. Your key's scope gates which regions it may request; call GET /v1/regions to discover them.
- uk is live today; other regions are reserved and not yet requestable.
- A known region your key isn't scoped for → 403 region_not_in_scope.
- A region not in the enum → 404 unknown_region.
Endpoints
Base URL https://api.oddsrelay.io/v1. All endpoints require auth.
| Method | Path | Purpose |
|---|---|---|
| GET | /odds/{type} | Processed (matched) opportunities for a feed type + region. |
| GET | /regions | Regions this key may request + their coverage. |
| GET | /coverage | Bookmakers covered + per-board freshness (no odds). |
| GET | /health | Liveness + per-board processed_at / age for the key's regions. |
Roadmap — not yet live
- GET /v1/raw/odds — Normalised, unmatched odds per selection (raw tier). Returns 404 today.
- GET /v1/stream (SSE) — Live push — snapshot + delta + seq. Returns 404 today.
- WSS /v1/ws — Sub-second bidirectional channel (arb tier). Returns 404 today.
- OddsRelay-Version date-pinning — Optional Stripe-style per-key version header for fine-grained backward-compatible evolution.
The envelope & types
GET /v1/odds/{type} returns a JSON envelope. Odds are decimal strings (e.g. "1.6"); money is a string, usually £-prefixed and occasionally negative; rating, ROI and liquidity are numbers. Parse defensively and ignore unknown fields.
| Field | Type | Notes |
|---|---|---|
| opportunities | array | The matched opportunities. Shape depends on {type} (see below). |
| opportunity_count | integer | How many opportunities were served. |
| processed_at | string (ISO) | Matcher cycle stamp, millisecond precision. |
| stale_hidden | boolean | true if rows were hidden past the freshness cutoff. |
| served_total | integer | Total served (equals opportunity_count today). |
| unreliable_links | string[] | Selections whose deeplink is flagged unreliable. |
| region | string | The region served (echoes the request). |
Per-type opportunity fields. The concrete shape is chosen by {type}:
| type | Type-specific fields |
|---|---|
| standard | Back-vs-lay legs: outcome, bookmaker (+_link), exchange (+_link), back_odds, lay_odds, lay_liquidity, rating. |
| 2up | Same back-vs-lay shape as standard (2Up offer market). |
| bog | Back-vs-lay + ROI (best-odds-guaranteed, racing). |
| each-way | Win + place exchange legs: horse, bookmaker_each_way_odds, bookmaker_fraction, bookmaker_places, win_exchange (+_link), exchange_win_odds/_liquidity, place_exchange (+_link), exchange_place_odds/_liquidity, exchange_places, rating, ROI. |
| extra-place | extra_places, implied_odds (no ROI). |
| dutching | outcomes (leg count) + per-leg first_/second_/… : *_bookmaker, *_outcome, *_odds, *_link; ROI. |
| price-boost-matcher | Back-vs-lay + original_odds (the pre-boost price). |
Every opportunity also carries: _id, sport, market_type, fixture, league, commence_time, qualifying_loss, potential_profit, region, plus denormalised time fields (date_and_time, game_day, game_time, datetime_string, odds_read_at) and horse_race_distance / horse_race_runners on racing rows.
A trimmed sample response:
{
"opportunities": [
{
"_id": "mu5y0PCqeQufUv3K0soTnCvxhiYk5nJx",
"sport": "Football",
"market_type": "BTTS",
"fixture": "Spain U19 (W) v Iceland U19 (W)",
"league": "UEFA Women's U19 Euro Championship",
"outcome": "No",
"bookmaker": "Paddy Power",
"bookmaker_link": "https://www.paddypower.com/...",
"exchange": "Betfair Exchange",
"exchange_link": "https://www.betfair.com/exchange/...",
"back_odds": "1.6",
"lay_odds": "1.51",
"lay_liquidity": 21.39,
"rating": 105.96,
"qualifying_loss": "£0.60",
"potential_profit": "£11.92",
"commence_time": "2026-07-01T15:00:00Z",
"date_and_time": "01/07/26 16:00",
"game_day": "01/07/26",
"game_time": "16:00",
"datetime_string": "20260630201025",
"odds_read_at": "2026-06-30T20:10:21.746441+00:00",
"horse_race_distance": null,
"horse_race_runners": null,
"region": "uk"
}
],
"opportunity_count": 21114,
"processed_at": "2026-06-30T20:10:29.000Z",
"stale_hidden": false,
"served_total": 21114,
"unreliable_links": [],
"region": "uk"
}Prefer ?format=bare to receive the opportunities array alone; the metadata moves to X-Processed-At / X-Opportunity-Count / X-Stale-Hidden headers.
Errors
Errors use one stable JSON envelope — branch on error.code (the message may change). Every response carries an X-OddsRelay-Request-Id (echoed in the body) — quote it in support.
{
"error": {
"code": "region_not_in_scope",
"message": "Key not scoped for region 'sa'.",
"type": "region",
"request_id": "or_req_…"
}
}| Status | code | When |
|---|---|---|
| 400 | bad_request | Malformed query (e.g. bad region syntax). |
| 401 | missing_api_key | No key presented. |
| 401 | invalid_api_key | Key not recognised. |
| 401 | revoked_api_key | Key has been revoked. |
| 401 | expired_api_key | Key is past its expiry. |
| 403 | type_not_in_scope | Valid key, not scoped for this feed type. |
| 403 | region_not_in_scope | Valid key, not scoped for this (known) region. |
| 403 | raw_not_in_scope | Valid key, not scoped for the raw form. |
| 403 | bet365_not_in_scope | Reserved — bet365 is enforced by FILTERING (a key without the add-on receives 0 bet365 rows), never a 403. |
| 404 | unknown_type | Feed type not in the enum. |
| 404 | unknown_region | Region not in the enum. |
| 404 | not_found | No such endpoint/path. |
| 429 | rate_limited | Per-key fair-use or per-IP flood cap (see Retry-After). |
| 500 | internal_error | Server fault (no upstream leakage). |
Versioning & deprecation
- Path-versioned — /v1. The effective dated version is echoed in X-OddsRelay-Api-Version.
- Additive-only within v1. New endpoints, new optional fields, new enum values and new error codes can land any time — always ignore unknown fields.
- Breaking changes never happen in place. A removal/rename/semantic change only ships in a future /v2 with a sunset window.
- Anything being retired is signalled with Deprecation + Sunset + Link headers, with ≥ 12 months notice — and a live client is never sunset without consent.
Rate limits
Two layers: a generous per-IP flood throttle, and a per-key fair-use cap (tier-scaled). Every response carries your budget:
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 118
X-RateLimit-Limit-Hour: 2000
X-RateLimit-Remaining-Hour: 1998
# on 429:
Retry-After: <seconds>Conditional polling (gzip + If-None-Match → 304) keeps you well inside any cap — a 304 is cheap and still returns fresh rate-limit headers.
OpenAPI spec
The full machine-readable contract is published as OpenAPI 3.1 — import it into Postman, Insomnia, Scalar, Redoc or any code generator.
Ready to build?
Get a key and pull live matched opportunities in minutes.