# Efficient odds API polling with ETag and 304s

> Poll an odds feed without paying twice for the same board: send If-None-Match for a free 304, accept gzip, narrow the request, and back off on a 429.

Published 1 July 2026 · updated 27 October 2026

**Efficient odds API polling comes down to two headers: store the `ETag` from every reply and send it back as `If-None-Match` on the next call, so an unchanged board comes back as an empty `304 Not Modified` that costs no tokens.** A changed one arrives in full with a new label to keep.

A self-serve key is pull only, with no webhook or stream, so a well-built polling loop is the whole integration. This post builds one against the `/v2` API, header by header.

## Webhooks vs polling for odds data

Polling fits odds data better than webhooks, because prices change continuously and a webhook needs a discrete event to fire on. With polling, your client asks for the current state at a pace it chooses. With a webhook, the provider calls a URL you run when a defined event happens, so you also secure that endpoint and handle the deliveries that fail or arrive out of order.

Webhooks suit sparse events, such as a payment settling. A bookmaker's price can move many times before kick-off, and the exchange lay it is paired with moves on its own, so no single change means "notify now" unless you invent a threshold. Send every change and you have rebuilt a stream. Send only the large moves and you miss the small ones that still matter to an oddsmatcher.

A poll of a matched board returns the whole current board under one `ETag`, so you never stitch a picture back together from partial updates. If you want alerts, poll the board and raise your own notification when a condition you set is met. The threshold then lives in your product, where you can change it without asking a supplier.

## How often should you poll an odds feed?

At the pace your product needs, and no faster. An oddsmatcher that users refresh while they place bets wants a short interval. A content page that rebuilds a few times an hour does not. Pick the interval from what your screen promises its reader, then let conditional requests make the idle polls cheap.

You can measure whether your interval is right from the data in hand. Every data reply, a `304` included, carries `X-Processed-At`, the time of the board you are looking at. A matched board in the default envelope also carries `meta.last_seen`, the last read per venue and sport, so now minus that time is an upper bound on a price's age. A `format=bare` reply leaves it out, and the keyed `/v2/coverage` call carries the same times. If nearly every poll is a `304`, you are asking more often than your filtered board changes. That costs no tokens, but it still counts toward your request cap.

## What an ETag is

An `ETag` is a response header that labels one exact version of a reply. On OddsRelay it is a label of the decompressed body, so it changes when, and only when, those bytes change. The gzip copy carries `-gz` inside the quotes, which is why you store the header exactly as it arrived.

In the default envelope the label moves with every new board version, because `meta.processed_at` is in the body. Ask for `format=bare` and the reply is the events alone, so the label moves only when your events do. The board time stays in the `X-Processed-At` header. Even then, a wide or busy call is rarely unchanged between polls, so the `304`s come mostly from narrow ones.

You never parse it. Keep the last one per request and hand it back. Data replies carry one, and so do the discovery routes `/v2/sports`, `/v2/events` and `/v2/bookmakers`, so the same pattern covers your reference data.

> **Key the ETag by the full URL** Each board and query string has its own body and so its own label. Store the `ETag` against the exact URL you called, filters included. One shared value across different requests makes every call look changed and throws the saving away.

## How If-None-Match and 304 work

You send the stored label in an `If-None-Match` header. If the body would be the same, the reply is `304 Not Modified` with an empty body, and you keep the rows you already hold. If anything changed, the reply is `200 OK` with the full board and a new `ETag`.

A conditional request and its 304 · example:

```http
GET /v2/odds/standard?region=uk&sports=soccer&format=bare HTTP/1.1
Host: api.oddsrelay.io
Authorization: Bearer $ODDSRELAY_KEY
Accept-Encoding: gzip
If-None-Match: "c7e19a4b2-3f1c6-gz"

HTTP/1.1 304 Not Modified
ETag: "c7e19a4b2-3f1c6-gz"
X-Processed-At: 2026-09-17T02:20:57.271Z
Cache-Control: private, max-age=2, must-revalidate
```

When the board has moved, the same request returns `200 OK` and the body. Here is one outcome from a matched board, trimmed to a single row:

The 200 when the board has changed (format=bare), trimmed · example:

```json
[{
  "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 }]
    }]
  }]
}]
```

The [response anatomy post](https://oddsrelay.io/blog/anatomy-of-an-odds-api-response) walks every field of that body.

A conditional request is still checked against your balance, so a call the balance cannot cover is refused with a `402` even with `If-None-Match`. The [conditional requests guide](https://oddsrelay.io/docs/guides/conditional-requests) has the exact rules.

## Ask for gzip, and check your client does

Send `Accept-Encoding: gzip` on every call. With curl, `--compressed` sends the header and decompresses the reply for you. Odds boards are repetitive JSON, the same field names and bookmaker keys over and over, which is the shape gzip shrinks best.

Check what your HTTP client sends by default, because it matters here. The standard and dutching boards and raw answer `400` to a request that explicitly refuses gzip, and Python's `urllib` and `http.client` send `identity` unless told otherwise, which counts as a refusal. A request with no `Accept-Encoding` header at all still gets plain bodies. Setting the header yourself removes the guesswork. [Odds API in Python](https://oddsrelay.io/blog/odds-api-in-python) shows a standard-library helper that sets it.

## Narrow the request before you shorten the interval

On the live feed, a call's token price comes from the board and the filters you send, never from the size of the reply. So gzip saves transfer and filters save tokens. On the standard and dutching boards and raw, every sport, bookmaker, exchange or market you name narrows the price, so a loop that asks only for what your product shows pays less on every `200`. The smaller boards cost the same whatever you filter.

Before you put a new call in a loop, add `quote=true` to it once. The reply is the price instead of the board, and a quote costs nothing. Every reply to a valid key also carries `X-Tokens-Cost`, and `X-Tokens-Remaining` whenever the balance is known, so the loop can watch its own spend. [Filters and tokens](https://oddsrelay.io/docs/guides/filters-and-tokens) explains the pricing in full.

## Back off on a 429, stop on a 402

Two refusals need different handling. A `429` means you hit a request cap: wait the seconds in its `Retry-After` header, then try again. Some `429`s arrive with an empty body and no error code, so branch on the status code alone. A `402 insufficient_tokens` means the call costs more than your balance. Retrying will not help. Narrow the filters or choose a plan. On a paid plan, waiting for the reset time in the body also works. On Free it does not, because its pool never refills.

## A polling loop that behaves

Put together, one tracked request looks like this. The interval is a placeholder for your product's own pace.

A conditional polling loop in Python · example:

```python
import os, time, requests

URL = "https://api.oddsrelay.io/v2/odds/standard"
PARAMS = {"region": "uk", "sports": "soccer", "format": "bare"}
HEADERS = {
    "Authorization": f"Bearer {os.environ['ODDSRELAY_KEY']}",
    "Accept-Encoding": "gzip",
}
INTERVAL = 30  # seconds: your product's pace

etag, board = None, None
while True:
    headers = dict(HEADERS, **({"If-None-Match": etag} if etag else {}))
    r = requests.get(URL, params=PARAMS, headers=headers, timeout=10)
    if r.status_code == 200:
        board, etag = r.json(), r.headers.get("ETag")
    elif r.status_code in (429, 502, 503):
        time.sleep(int(r.headers.get("Retry-After", "5")))
        continue
    elif r.status_code == 402:
        break  # balance too low: narrow the filters, choose a plan, or on a paid plan wait for resets_at
    elif r.status_code != 304:
        r.raise_for_status()
    time.sleep(INTERVAL)
```

A `304` needs no branch of its own. The board you hold is still the current one. A board that is not ready yet answers `503` with a `Retry-After`, so it shares the `429` branch. A `502` comes from the front door when the API behind it does not answer. It is empty and may carry no `Retry-After`, which is why the loop falls back to a default wait.

## Run it against the real feed

The loop above needs only a key. Check the coverage on [the coverage page](https://oddsrelay.io/coverage) before you choose your filters. The [quickstart](https://oddsrelay.io/docs/guides/quickstart) has the same flow in three languages.

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

Source: https://oddsrelay.io/blog/polling-an-odds-feed-efficiently
