BlogBuilding with odds data

Efficient odds API polling with ETag and 304s

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 304s 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

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

[{
  "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 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. 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 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 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

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 before you choose your filters. The quickstart has the same flow in three languages.

Related posts

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