Skip to content

BlogFundamentals

How odds comparison sites use odds data

OddsRelay · · Updated · 5 min read

Comparison sites read many bookmakers' prices for the same fixture, line up each selection across them, and highlight the highest number. Reading the prices is the easy half. Proving that every column in a row is the same bet is the work.

What is an odds comparison site?

Underneath, a best-price table. For a football match a row is one selection, Arsenal to win, and each column is one book's current price for it. The front end is thin. Whether users trust it comes down to the data behind it.

Four things the data layer must get right

  • Breadth. Users expect the books they bet with, bet365 among them. A UK table without bet365 looks incomplete, however good the rest is.
  • Line-up. Every book's price in a row has to refer to the same fixture, market and selection.
  • Freshness. Prices move. A price that has changed at the book since you read it looks usable and isn't, so your table needs to know how old each price is.
  • Completeness. A missing price reads as a fact. If a book is absent from a row because it wasn't captured, the table implies that book is worse, which is the wrong fact.

Lining up prices is the hard part

Books describe the same thing differently. One lists a team as Celtic, another as Celtic FC. One names a market by its own internal type, another uses a different word for the same bet. Until those are reconciled you have two lists side by side, and a best price picked across them is a guess.

Lining up happens at three levels: the fixture, the market and the selection. Get all three right and the best price is a plain max() across the row. Get one wrong and you either miss the best price or compare two different bets and publish a false best. Normalising odds across books goes through the mechanics.

The costly bug is a false comparison

A missing price is visible. Two cells that look aligned but refer to different outcomes are not, and they put a wrong best price in front of a user who then clicks out to the book. Check your mapping against the book's own page before a new market goes live on the table.

What the raw feed hands a comparison site

The raw feed (GET /v2/odds/raw) carries every venue's prices per event in one schema, from 140+ bookmakers, 60+ of them live in the UK & Ireland, bet365 included. One fixture is one event, with every venue that prices it listed under bookmakers[]. Each venue's markets carry their own last_update, the time that market last changed.

One raw event, trimmed to a pair of bookmakers · example

{
  "id": "or_evt_ed3ca8739560",
  "cluster_id": null,
  "sport_key": "soccer_uefa_europa_league",
  "sport_title": "UEFA Europa League",
  "commence_time": "2026-09-17T19:00:00Z",
  "home_team": "Celtic FC",
  "away_team": "Ferencvaros",
  "bookmakers": [{
    "key": "7bet",
    "title": "7bet",
    "region": "uk",
    "last_update": "2026-09-17T16:31:51Z",
    "markets": [{
      "key": "h2h",
      "last_update": "2026-09-17T15:45:04Z",
      "outcomes": [
        { "name": "Celtic FC", "price": 1.71 },
        { "name": "Draw", "price": 4.0 },
        { "name": "Ferencvaros TC", "price": 4.0 }
      ]
    }]
  }, {
    "key": "betgoodwin",
    "title": "BetGoodwin",
    "region": "uk",
    "last_update": "2026-09-17T16:31:43Z",
    "markets": [{
      "key": "h2h",
      "last_update": "2026-09-17T13:39:55Z",
      "outcomes": [
        { "name": "Celtic", "price": 1.7 },
        { "name": "Draw", "price": 4.0 },
        { "name": "Ferencváros", "price": 4.5 }
      ]
    }]
  }]
}

Look at the outcome names: one venue sends Celtic FC and Ferencvaros TC, the other Celtic and Ferencváros. Raw lines up the fixture for you, but each venue keeps its own market keys and outcome labels, as it lists them. Mapping those onto your own selections is the part your code owns. It is a small, testable table per market, and it is where a comparison site's accuracy is decided.

Your code: best price per selection · example

// selectionFor is your mapping: (venue, market key, outcome name) -> your selection id, or null
const best = new Map()
for (const venue of event.bookmakers) {
  for (const market of venue.markets) {
    for (const o of market.outcomes) {
      const sel = selectionFor(venue.key, market.key, o.name)
      if (!sel || o.price == null) continue
      const top = best.get(sel)
      if (!top || o.price > top.price) {
        best.set(sel, { venue: venue.key, price: o.price, changed: market.last_update })
      }
    }
  }
}

The book margin behind a row is arithmetic on the same prices, if your table shows it. The overround entry has the formula.

Cutting the feed to your table

A comparison page rarely needs every venue and every market. Raw filters on sports, leagues, bookmakers and markets, and on a kick-off window with commenceTimeFrom and commenceTimeTo, so a page can pull only the books you have deals with. Naming sports, bookmakers or markets also narrows what the call costs, which filters and tokens explains. Add includeLinks=true and each venue that has one carries a link to its page for the event, which is your click-out.

Raw is paged. Follow meta.next_cursor until it is null, and dedupe by event id, since an event whose kick-off moves during a walk can appear twice or be missed. Take a missed one on the next walk. Walking raw covers the cursor and what happens when one expires.

Some markets need no mapping at all. Where an exchange also prices the market, the matched standard board already puts the bookmakers' prices for a selection on one outcome, best first. Affiliates and comparison sites shows which view of a comparison site reads which board. Raw covers everything beyond that.

How fresh the table needs to be

Fresh enough that the best price you show is still the price when the user lands on the book. Every reply carries meta.processed_at, the board time. The published bar for every product on sale is a maximum update time of 10–20 s. The feed carries pre-match prices only, so it cannot fill an in-play column.

Read a market's last_update for what it is: when that price last changed. It is not a heartbeat. A quiet market can show an old last_update while its price is still current, so treat it as the age of the price, never as a sign the venue went away. Venue-level freshness is last_seen in your key's GET /v2/coverage.

To follow changes, walk raw again with updatedSince set to the start of your previous walk. You get only the venues whose markets changed since then, and a recent updatedSince pays a reduced share of the price. Send each reply's ETag back as If-None-Match and an unchanged page answers 304, which costs no tokens (conditional requests).

Build the data layer, or license it?

License the collection and own the mapping. Sourcing every book and keeping it running as sites change is an open-ended job that no user of your table ever sees. The selection mapping and the way you present the row are where comparison sites differ, and they are small enough to own.

If odds collection is your core business, owning it can be the right call. Buy vs build sets out the costs on each side.

Checking the books your table needs

Start on the coverage page, which shows each venue and sport as live or interrupted. The raw feed page and the docs carry the full shape. When the books you need are there, request access for a key.