How to test an odds feed before you buy
Test an odds feed by running the calls your product will make: coverage against your own list, a priced first call, a freshness check, a 304 and a deliberate error.
Treat everything in a vendor's deck as a claim until you have.
What you can check before paying
The contract, live coverage and a status per product answer without a key, and for the rest you sign up free. Plans differ only in their tokens, and pricing lists them.
Read the contract before the key arrives
The OpenAPI 3.1 contract is public at /. It carries every route, parameter and error code, with example responses. Read it for the field names you will depend on and for how refusals are shaped. An AI coding assistant can draft a client from the same file, as integrating an odds feed with AI tools shows.
# The contract curl --compressed -s https://api.oddsrelay.io/v2/openapi.json -o openapi.json # Live status per product, venue and sport in the UK & Ireland curl --compressed -s "https://api.oddsrelay.io/v2/coverage?region=uk" # One word per product: ok, slow or stale curl --compressed -s "https://api.oddsrelay.io/v2/status?region=uk"
How to evaluate odds-data coverage
Judge coverage per product, bookmaker, sport and market, because a headline book count answers none of those. Write down the bookmakers, exchanges and sports your product needs before you look at anyone's coverage page. When bet365 is the book your product cannot do without, choosing a bet365 odds API narrows these checks to that one book.
OddsRelay covers 140+ bookmakers, 60+ of them live in the UK & Ireland, with bet365 for featured football and UK and Irish racing, and lay prices from Betfair, Smarkets and Matchbook. With a key, GET / returns the named list: every bookmaker and exchange key the API serves, with its display name, its regions and whether it is an exchange. It costs no tokens, so you can diff it against your own list in a script.
A big bookmaker count misleads
A count says how many books are on a list and nothing about what each one carries. A vendor can print a large number and still fail you in three ordinary ways:
- Thin coverage: a book is on the list for your sport but carries a handful of its fixtures, so it lifts the total while missing most of the card.
- Headline markets only: football is covered, but only the 1X2 price. Over/under, both teams to score and handicaps are missing, and those are often the markets your users want.
- Listed but not current: the book is named, but nothing has been read from it for your sport today. A price nobody has read for hours looks usable and is not.
Coverage is per product, not per feed
A book that is live for football on the standard board may carry no each-way racing at all, so one yes or no for a whole feed is the wrong unit. The keyless coverage call answers per product: each venue and sport is live, or interrupted_ (not serving now, though it served within the last 24 hours). It carries no counts and no per-venue times. The coverage page shows only what is covered, never these statuses, so run the call itself.
{
"region": "uk",
"generated_at": "2026-09-17T02:20:29Z",
"products": {
"each-way": [
{ "bookmaker": "7bet", "sport": "horse_racing", "status": "live" },
{ "bookmaker": "bet600", "sport": "horse_racing", "status": "live" }
],
"standard": [
{ "bookmaker": "7bet", "sport": "american_football", "status": "live" }
]
}
}Filter it to your list and you have a first answer in one call. A book that is live on the product you need, for the sports you need, passes this stage. Anything else is a question to ask before you sign.
Count the markets per bookmaker
Depth is the market count per book on a real fixture, not the markets a vendor claims across its whole dataset. GET / needs a key but costs no tokens, and lists every competition on the matched boards with the market keys seen under it, so you can see whether handicaps or totals exist for your competitions at all.
With a key, the coverage route adds, for each venue and sport, the number of events it has rows for now and its last_ time. Compare a bookmaker's event count with the exchanges' on the same product and sport: a wide gap on a product you need is worth raising before you sign. Then, priced first with quote= (shown below), pull the raw board for a few of your fixtures and count the markets each bookmaker returns. Walking raw shows how to page through it.
{
"region": "uk",
"generated_at": "2026-09-17T02:27:31Z",
"products": {
"raw": {
"bookmakers": [
{ "key": "7bet", "name": "7bet",
"sports": { "american_football": { "events": 71, "last_seen": "2026-09-17T02:27:19Z" } } }
],
"exchanges": [
{ "key": "matchbook", "name": "Matchbook",
"sports": { "american_football": { "events": 72, "last_seen": "2026-09-17T02:27:17Z" } } }
]
}
}
}Check coverage holds over time
Coverage that is whole one week can thin out by the next, because bookmakers change what they offer, and a single demo cannot show that. Read the keyless coverage document daily for a week or two and note every venue and sport on your list that goes to interrupted_ or drops off the list. A venue and sport stay on the list only while they have published within the last 24 hours. What you are buying is the upkeep, the case buying vs building an odds data layer makes.
Completeness inside a market needs its own check. A correct-score market missing two selections still renders and still looks fine, so compare a few deep markets on your fixtures with the bookmaker's own page, selection by selection.
Keep the first call small
Price it before it runs. Add quote= to any data request and it returns the call's token price instead of the board, free. On the live feed, a price comes from the board and the filters you send, never from the size of the reply, so on the standard board a narrow call with named sports and bookmakers is the cheap way to look. The quickstart runs the same quote, call and ETag steps in cURL, Python and JavaScript.
# 1. Price it (free) curl --compressed -s -H "Authorization: Bearer $ODDSRELAY_KEY" \ "https://api.oddsrelay.io/v2/odds/standard?region=uk&sports=soccer&bookmakers=bet365"e=true" # 2. Make it curl --compressed -s -H "Authorization: Bearer $ODDSRELAY_KEY" \ "https://api.oddsrelay.io/v2/odds/standard?region=uk&sports=soccer&bookmakers=bet365"
Read the first reply by hand before you write integration code. Check that your books appear under back, that your markets appear under markets, and that the event names and kick-off times match what the bookmaker shows.
{
"meta": {
"feed_type": "standard", "region": "uk", "odds_format": "decimal",
"processed_at": "2026-09-17T14:20:57.271Z",
"last_seen": { "bet365": { "soccer": "2026-09-17T14:20:55Z" } },
"count": 1,
"version": "v2",
"next_cursor": null
},
"data": [{
"event_id": "or_evt_917dd44bce05",
"commence_time": "2026-09-20T14:00:00Z",
"home_team": "Brentford",
"away_team": "Everton",
"markets": [{
"key": "h2h",
"outcomes": [{
"name": "Brentford",
"back": [{ "bookmaker": "bet365", "price": 3.4, "link": null }],
"lay": [{ "exchange": "smarkets", "price": 3.5, "available": 1620, "link": null }]
}]
}]
}]
}Read the pair, then do the arithmetic
A matched board earns its name in the lay array. Each back offer sits beside an exchange lay offer, and available says how much liquidity waits at that lay price, so coverage for a matched-betting tool means the lay side too. Lay and exchange coverage goes further. Nothing on the board is derived. Work the rating and qualifying loss out yourself on a handful of rows, at your own commission rate, and check them against the oddsmatcher widget.
const back = 3.4, lay = 3.5, commission = 0.02; const rating = (back / lay) * 100; // 97.14 const layStake = (back * 10) / (lay - commission); // for a 10-unit back stake
Check freshness
You need the timestamps in the reply and a browser. Every response carries the board's time in meta.processed_ and the X-Processed-At header. On a matched board, meta.last_ gives each venue and sport's last read, so now minus that time is an upper bound on a price's age. On raw, each market's last_ is when that market last changed, which is not a heartbeat: a quiet market's time ages while the venue is still read. Take a few rows and compare them with the bookmaker and exchange sites at the same moment. They will not match to the penny on a moving market, but they should be close.
Run the comparison on fixtures that have not kicked off. inPlay= on the standard board, served since 2 November 2026, returns only the events in play, and bet365, the book in the calls above, has no in-play prices. The freshness guide covers both fields in detail.
Prove the 304 before you set a polling rate
A self-serve key is pull only, so your running cost depends on how it answers a repeat call. Every data reply carries an ETag. Send it back as If-None-Match and an unchanged board comes back as 304 Not Modified with an empty body, which costs no tokens. It still counts towards the rate limit, so poll at the pace your product needs rather than as fast as you can. Conditional requests has the details.
Ask for format= for this test. The default envelope carries meta.processed_ in its body, so its ETag changes with every new board version, and an envelope can come back 200 even when none of your prices moved. Send the second call straight after the first. A busy board can change even in that gap, and a 200 then means something in your events changed, so run the pair again.
# 1. The same call as bare, keeping the headers curl --compressed -s -o /dev/null -D headers.txt -H "Authorization: Bearer $ODDSRELAY_KEY" \ "https://api.oddsrelay.io/v2/odds/standard?region=uk&sports=soccer&bookmakers=bet365&format=bare" # 2. Straight after, send its ETag back ETAG=$(grep -i '^etag:' headers.txt | cut -d' ' -f2- | tr -d '\r') curl --compressed -s -i -H "Authorization: Bearer $ODDSRELAY_KEY" \ -H "If-None-Match: $ETAG" \ "https://api.oddsrelay.io/v2/odds/standard?region=uk&sports=soccer&bookmakers=bet365&format=bare" | head -n 1 # HTTP/2 304 (nothing changed: keep the rows you have)
Break it on purpose
How a feed fails tells you more than the happy path. Send a bookmaker key that does not exist: the reply should be a structured error that lists the valid values. Here it is a 400 with the code unknown_. The API's errors are JSON with a code and a request_ you can quote to support. A 429 carries Retry-After, sometimes with an empty body, so handle both. A call your balance cannot cover is a 402 that serves and spends nothing.
Then run the exact call your product will make, at roughly the rate it will make it, for a few minutes. You are checking that your client handles the retry signals and the 304s, not trying to knock the service over. If you are replacing a provider, switching odds data providers runs this test beside your current feed.
What good looks like, and the red flags
| Signal | Good | Red flag |
|---|---|---|
| Schema | Public contract with example responses | Field list only after a sales call |
| Coverage | Live status per venue and sport | A book count in a PDF |
| Matched data | Back paired with exchange lay and liquidity | Back prices only, matching "coming soon" |
| Freshness | Timestamps in every reply | Speed promises with nothing to check them against |
| Cost | Free quotes and free 304s | Every repeat call billed in full |
Run the first test
Open the coverage list and hold it against your list of bookmakers and sports. If it covers them, run your first quoted call with the example client.
Related posts
- Buying vs building an odds data layerThe five jobs inside an odds data layer, and which ones a licensed feed takes off your team.
- The anatomy of an odds API responseEvery field in a /v2 odds response, from the meta envelope down to each back and lay offer.
- Efficient odds API polling with ETag and 304sSend the ETag back as If-None-Match and an unchanged reply is an empty 304 that costs no tokens.
18+ · OddsRelay supplies odds data only and takes no bets. Please gamble responsibly.