How to monitor an odds feed in production
OddsRelay · · Updated · 5 min read
An odds feed in production fails three separate ways: it stops answering, its prices stop getting younger, or a bookmaker goes missing. Give each its own check and alert on sustained drift, because a feed can fail any one of them while passing the other two.
A stale price looks exactly like a live one. Your users cannot tell that the back price in front of them was last read twenty minutes ago. A feed that is up but frozen costs trust, and it passes every check that only asks whether the endpoint responds.
What should I monitor on an odds feed?
- Availability: keyless
/healthz, plus a keyed canary request that must come back well-formed. - Freshness: the age of each venue's last read, from
meta.last_seen, with keyless/v2/statusas a coarse per-product signal. - Coverage: a count of the bookmakers and markets in your own replies, checked against keyed
/v2/coverage.
How do I check availability?
Start with liveness. OddsRelay's GET /healthz needs no key and answers plain text whose first line is ok with a 200, or warming with a 503 while the service starts. It says the service is up and nothing about how old the data is, which is exactly why it cannot be your only check.
Then run a canary: a small scheduled request against the endpoint your product uses, narrowed with filters (one sport, one market) so the reply stays small. On standard, dutching and raw, a narrower request also costs fewer tokens. Assert a 200, a body that parses, and the documented envelope: meta.processed_at present and a data array. The field names are laid out in the anatomy of a response. Send the last ETag back as If-None-Match, and an unchanged board comes back as a 304 that costs no tokens. For the rest of that trade-off, see polling an odds feed efficiently.
Keep your own limits apart from feed faults. Every JSON error carries a code, so branch on it. A 429 with too_many_requests means your account is polling too hard, and a 402 with insufficient_tokens is a billing alert. Neither is an outage, so neither should page anyone. A 429 or 502 with an empty body and no code comes from the front door. Wait the Retry-After seconds, then retry.
How do I detect stale odds?
Measure age from the timestamps the feed sends. A price that did not move and a price nobody read look identical in the body, so watching for changed values cannot tell them apart. Every data reply carries a board time, meta.processed_at, also sent as the X-Processed-At header (so it survives ?format=bare and a 304).
A price's age needs a finer field. A matched board carries meta.last_seen: per bookmaker and sport group, the last instant that venue was read. Now minus last_seen is an upper bound on how old that venue's prices can be, which makes it the field to alert on. Here is a trimmed example reply, then the check:
{
"meta": {
"region": "uk",
"odds_format": "decimal",
"processed_at": "2026-09-20T12:18:10.412Z",
"last_seen": { "william_hill": { "soccer": "2026-09-20T12:18:07Z" } },
"count": 1,
"version": "v2",
"next_cursor": null
},
"data": [{
"event_id": "or_evt_917dd44bce05",
"sport_key": "soccer_epl",
"commence_time": "2026-09-20T14:00:00Z",
"home_team": "Arsenal",
"away_team": "Chelsea",
"markets": [{
"key": "h2h",
"outcomes": [{
"name": "Arsenal",
"back": [{ "bookmaker": "william_hill", "price": 2.9, "link": null }],
"lay": [{ "exchange": "betfair_exchange", "price": 3.0, "available": 175, "link": null }]
}]
}]
}]
}// alert on any venue read longer ago than your budget
for (const [venue, sports] of Object.entries(reply.meta.last_seen)) {
for (const [group, seen] of Object.entries(sports)) {
const ageSeconds = (Date.now() - Date.parse(seen)) / 1000;
if (ageSeconds > STALE_BUDGET_SECONDS) alertStale(venue, group, ageSeconds);
}
}Every product on sale meets a maximum update time of 10–20 s, so an age well past it is a real signal. Set the budget at a few multiples of it, or tighter if your product cannot tolerate that.
Raw needs one caution. Its per-market last_update is when that venue's market last changed, not a heartbeat. A quiet market's last_update ages while the venue is still read normally, so alerting on it produces false alarms on exactly the markets nobody is moving. For a raw venue's age, read its last_seen in keyed /v2/coverage, below.
For a cheap product-level signal, poll GET /v2/status?region=uk. It needs no key, spends no tokens and gives each product one word: ok, slow or stale, judged on that board's odds age. While boards warm up, every product reads stale.
Whether a price is too old to show depends on what your product does with it, so make that call in your own code, on these fields. The freshness guide covers each one in full.
How do I check coverage is complete?
Count what arrives and compare it with what you expect. For your filters, how many distinct bookmakers came back, and is bet365 among them? How many markets does an event carry compared with an hour ago? Alert on a meaningful drop.
The feed reports its own view too. GET /v2/coverage with your key lists, for each product your key holds, the venues with rows now, their event counts and their last_seen, and it costs no tokens. Without a key it shows each venue and sport as live or interrupted_24h, which is what the public coverage page reads. The coverage method guide explains both views.
Coverage failures hide behind green checks
Liveness, the canary and the freshness check can all stay green while a bookmaker is missing, because a missing venue has no old timestamp to trip on. Only a count notices the gap before your users do.
How do I alert without drowning in noise?
A monitor that pages on every blip trains people to ignore it, which is worse than having none.
- Require persistence. Fire only after a check fails for several consecutive polls or across a rolling window, so one slow reply does not wake anyone.
- Tier by severity. A brief
slowis a warning. A venue past budget for several polls in a row, a bookmaker missing from the count, or a dead endpoint, is a page. Route them to different channels so the urgent ones stay urgent. - Baseline, then compare. Coverage counts drift as events open and settle, so alert on deviation from a rolling baseline instead of a fixed threshold you will spend a week tuning.
Point the checks at the real feed
Every route and field above is in the API reference, and /healthz and /v2/status work today without a key. To run the keyed canary and the coverage count against real boards, request access, and see live coverage for what is live right now.