# How to monitor an odds feed in production

> Monitor an odds feed on three checks: is it answering, how old are the prices, and is every bookmaker present. The fields and routes to build each one on.

Published 1 July 2026 · updated 5 November 2026

**To monitor an odds feed in production, watch for three separate failures: 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 to monitor

- **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/status` as a coarse per-product signal.
- **Coverage**: a count of the bookmakers and markets in your own replies, checked against keyed `/v2/coverage`.

## 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](https://oddsrelay.io/blog/anatomy-of-an-odds-api-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](https://oddsrelay.io/blog/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.

## Catch stale prices

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:

GET /v2/odds/standard · trimmed · example:

```json
{
  "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",
    "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": "smarkets", "price": 3.0, "available": 175, "link": null }]
      }]
    }]
  }]
}
```

Your freshness check · example:

```js
// 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 for match odds, and for every market within six hours of kick-off. Further from kick-off, prices in the other markets can be older. `last_seen` is the oldest read behind the board, those markets included, so a venue can sit past the update time with nothing wrong. Set the budget a little above the ages a day of your own readings shows, so that only a real change trips the alert.

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](https://oddsrelay.io/docs/guides/freshness) covers each one in full.

## Check every bookmaker is present

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](https://oddsrelay.io/coverage) reads. The [coverage method guide](https://oddsrelay.io/docs/guides/coverage-method) 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.

## Set alert thresholds

A monitor that pages on every blip trains people to ignore it, which is worse than having none.

1. **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.
2. **Tier by severity.** A brief `slow` is 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.
3. **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](https://oddsrelay.io/docs/reference), and `/healthz` and `/v2/status` work today without a key. [See coverage](https://oddsrelay.io/coverage) for the bookmakers and sports the keyed canary will find.

18+ · OddsRelay supplies odds data only and takes no bets. Please gamble responsibly.

Source: https://oddsrelay.io/blog/how-to-monitor-an-odds-feed-in-production
