Skip to content

BlogFundamentals

Polling an odds feed efficiently with ETag and 304s

OddsRelay · · Updated · 5 min read

Store the ETag from every odds reply and send it back as If-None-Match on the next call. An unchanged board then comes back as an empty 304 Not Modified that costs no tokens, and a changed one arrives in full with a new label to keep.

The feed is pull only. There is 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.

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 tips 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. Matched boards also carry meta.last_seen, the last read per venue and sport, so now minus that time is an upper bound on a price's age. 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 is an ETag?

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.

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 do If-None-Match and 304 Not Modified 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

GET /v2/odds/standard?region=uk&sports=soccer 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, trimmed · example

{
  "meta": {
    "region": "uk",
    "odds_format": "decimal",
    "processed_at": "2026-09-17T02:21:04.118Z",
    "last_seen": { "william_hill": { "soccer": "2026-09-17T02:21:01Z" } },
    "count": 1,
    "version": "v2",
    "next_cursor": null
  },
  "data": [{
    "event_id": "or_evt_917dd44bce05",
    "sport_key": "soccer_epl",
    "sport_title": "Premier League",
    "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 }]
      }]
    }]
  }]
}

The response anatomy post 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 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.

Narrow the request before you shorten the interval

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 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 429s 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 wait for the reset time in the body.

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

import os, time, requests

URL = "https://api.oddsrelay.io/v2/odds/standard"
PARAMS = {"region": "uk", "sports": "soccer"}
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 or 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. Request access to get one, and check what is live today on the coverage page before you choose your filters. The quickstart has the same flow in three languages.