Skip to content
OddsRelay

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.

Conditional polling (304)
# 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.

Headers
# 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.

MethodPathPurpose
GET/odds/{type}Processed (matched) opportunities for a feed type + region.
GET/regionsRegions this key may request + their coverage.
GET/coverageBookmakers covered + per-board freshness (no odds).
GET/healthLiveness + per-board processed_at / age for the key's regions.

Roadmap — not yet live

  • GET /v1/raw/oddsNormalised, unmatched odds per selection (raw tier). Returns 404 today.
  • GET /v1/stream (SSE)Live push — snapshot + delta + seq. Returns 404 today.
  • WSS /v1/wsSub-second bidirectional channel (arb tier). Returns 404 today.
  • OddsRelay-Version date-pinningOptional 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.

FieldTypeNotes
opportunitiesarrayThe matched opportunities. Shape depends on {type} (see below).
opportunity_countintegerHow many opportunities were served.
processed_atstring (ISO)Matcher cycle stamp, millisecond precision.
stale_hiddenbooleantrue if rows were hidden past the freshness cutoff.
served_totalintegerTotal served (equals opportunity_count today).
unreliable_linksstring[]Selections whose deeplink is flagged unreliable.
regionstringThe region served (echoes the request).

Per-type opportunity fields. The concrete shape is chosen by {type}:

typeType-specific fields
standardBack-vs-lay legs: outcome, bookmaker (+_link), exchange (+_link), back_odds, lay_odds, lay_liquidity, rating.
2upSame back-vs-lay shape as standard (2Up offer market).
bogBack-vs-lay + ROI (best-odds-guaranteed, racing).
each-wayWin + 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-placeextra_places, implied_odds (no ROI).
dutchingoutcomes (leg count) + per-leg first_/second_/… : *_bookmaker, *_outcome, *_odds, *_link; ROI.
price-boost-matcherBack-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:

200 — /v1/odds/standard?region=uk
{
  "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 envelope
{
  "error": {
    "code": "region_not_in_scope",
    "message": "Key not scoped for region 'sa'.",
    "type": "region",
    "request_id": "or_req_…"
  }
}
StatuscodeWhen
400bad_requestMalformed query (e.g. bad region syntax).
401missing_api_keyNo key presented.
401invalid_api_keyKey not recognised.
401revoked_api_keyKey has been revoked.
401expired_api_keyKey is past its expiry.
403type_not_in_scopeValid key, not scoped for this feed type.
403region_not_in_scopeValid key, not scoped for this (known) region.
403raw_not_in_scopeValid key, not scoped for the raw form.
403bet365_not_in_scopeReserved — bet365 is enforced by FILTERING (a key without the add-on receives 0 bet365 rows), never a 403.
404unknown_typeFeed type not in the enum.
404unknown_regionRegion not in the enum.
404not_foundNo such endpoint/path.
429rate_limitedPer-key fair-use or per-IP flood cap (see Retry-After).
500internal_errorServer 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:

Response headers
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.