BlogBuilding with odds data

The anatomy of an odds API response

The odds API response format on an OddsRelay matched board is one JSON envelope: a meta object that dates the board, and a data array of events that nest markets, outcomes, then back and lay offers.

Each-way and dutching reshape the offers, and raw swaps the pairing for one entry per venue.

The whole envelope, once

Here is a standard board reply cut down to one football event, one market and one outcome. Every sport on a matched board nests the same way.

GET /v2/odds/standard · example

{
  "meta": {
    "feed_type": "standard",
    "region": "uk",
    "odds_format": "decimal",
    "processed_at": "2026-09-20T12:20:57.271Z",
    "last_seen": {
      "william_hill": { "soccer": "2026-09-20T12:20:55Z" },
      "bet365": { "soccer": "2026-09-20T12:20:54Z" },
      "smarkets": { "soccer": "2026-09-20T12:20:56Z" }
    },
    "count": 1,
    "version": "v2",
    "next_cursor": null
  },
  "data": [{
    "event_id": "or_evt_917dd44bce05",
    "sport_key": "soccer_premier_league",
    "sport_title": "Premier League",
    "competition_key": "soccer_english_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 },
          { "bookmaker": "bet365", "price": 2.87, "link": null }
        ],
        "lay": [
          { "exchange": "smarkets", "price": 3.0, "available": 175, "link": null }
        ]
      }]
    }]
  }]
}

What the meta object dates

meta describes the board the body was cut from. It never repeats per event.

  • feed_type is the board you asked for: standard, each-way, extra-place, bog, dutching, 2up. bog and 2up share the standard shape.
  • region is uk, the board for the UK & Ireland.
  • odds_format is decimal by default: the total return per unit staked, stake included. Standard and dutching serve decimal only, and some other boards accept oddsFormat=american, as the American odds guide explains.
  • processed_at is the board time in ISO-8601 UTC, repeated in the X-Processed-At header.
  • last_seen maps each venue and sport to the last instant that venue was read. Its sport keys are key bases such as soccer, one level above a sport_key like soccer_premier_league.
  • count is the number of events in data.
  • version is v2, and next_cursor is null because a matched board arrives whole in one reply.

Events hold markets, markets hold outcomes

Odds data is a hierarchy, and every level answers one question. The event says what is being bet on and when. The market asks a question with a fixed set of answers. The outcome is one answer, and the offers under it carry the prices. Read any path as a sentence: for this event, in this market, this outcome is priced here.

  • event_id names the fixture, and one match keeps one id on every board. Treat it as opaque. It never changes once served, so you can store it. How to store and version odds data covers the rare id merged into another.
  • sport_key (soccer_premier_league in the sample) follows the league name the venues use, so several competitions can share one, and one competition's matches can carry different ones. sport_title is its display name, and commence_time is kick-off in UTC.
  • competition_key names the competition: one key per competition, the same on every live board, never changed once served, and null on racing, when no venue's league name identifies one, and on every past board in historical odds. sports= with that key returns the whole competition. To pick out one competition, filter on competition_key, not sport_key.
  • Team sports carry home_team and away_team. Racing carries venue, the course. A fixture that does not split into sides carries event_name.
  • in_play appears only when you ask for in-play events with inPlay (include or only on raw, only on the standard and dutching boards), and it is true while the event's venues show it in play. By default every board leaves in-play events out.
  • Each market has a key such as h2h, and the matched boards use one key per market across every book. An outcome on a line market adds point, the line itself.

An event carries identity and timing and no prices. Keeping prices at the leaves is what lets one event hold every book's offer without a special case per bookmaker. When a market is suspended or voided, how odds feeds handle suspended and void markets covers what your code should do as its prices leave the tree.

Back offers and lay offers

Each outcome holds a back array and a lay array. A back offer is a bookmaker's price: bookmaker is the venue key from GET /v2/bookmakers, price is decimal odds, and link is the venue's page for the event, or null. The array is sorted best price first.

A lay offer is an exchange's price, sorted lowest first. exchange names the venue, and the offer adds available, the money on offer at that price. The lay side comes from Betfair, Smarkets and Matchbook. Only lays with enough money behind them make it onto the board. Rating and qualifying loss stay out of the reply: they are sums your code runs on the two prices, as below, and the oddsmatcher widget runs the same sums on screen.

Your arithmetic on one pair · example

// c is your own exchange commission, e.g. 0.02 for 2%
const layStake = (back.price * stake) / (lay.price - c);
const net = layStake * (1 - c) - stake; // the same whichever side wins
const rating = (back.price / lay.price) * 100;
const fits = lay.available >= layStake;

Doing that sum yourself is the right design. Only you know your commission rate, and a number computed on someone else's assumption is wrong for every customer on a different one. An oddsmatcher in Python rates every back and lay pair on a whole board, and the CSV export tutorial writes each offer to its own spreadsheet row. For past prices, historical odds in Python prints one event's price changes.

Each-way and extra-place split the lay

On these boards each back offer adds places, the places the bookmaker pays, and place_fraction, its place terms (0.2 is 1/5 odds). The lay key becomes an object with a win array and a place array, and a place lay offer carries the exchange's own places.

One each-way outcome · example

{
  "name": "Albert Palais",
  "back": [
    { "bookmaker": "sky_bet", "price": 6.0, "places": 4, "place_fraction": 0.2, "link": null }
  ],
  "lay": {
    "win":   [{ "exchange": "matchbook", "price": 6.4, "available": 21.71, "link": null }],
    "place": [{ "exchange": "matchbook", "price": 2.16, "available": 39.42, "places": 3, "link": null }]
  }
}

The extra-place board uses the same shape. What it surfaces is a back offer paying more places than the exchange's place market, as in the sample above: four places against three. The each-way and extra-place post covers the terms.

Dutching groups back legs by dutch_id

The dutching board has no lay key. Every back offer carries dutch_id, eight hex characters naming the dutch it belongs to. A dutch is two or three legs, one per outcome, each at a different venue. Group one market's offers by dutch_id and compute 100 ÷ Σ(1/price) for each group.

A three-leg dutch in one market · example

"outcomes": [
  { "name": "Arsenal", "back": [{ "bookmaker": "william_hill", "price": 2.5, "link": null, "dutch_id": "b8d7916c" }] },
  { "name": "Draw", "back": [{ "bookmaker": "bet365", "price": 3.4, "link": null, "dutch_id": "b8d7916c" }] },
  { "name": "Chelsea", "back": [{ "bookmaker": "smarkets", "price": 3.1, "link": null, "dutch_id": "b8d7916c" }] }
]

Raw keeps every venue apart

GET /v2/odds/raw answers a different question: what is each venue quoting, as that venue labels it. The event key is id, and prices sit under a bookmakers array with one entry per venue.

GET /v2/odds/raw, one event · example

{
  "id": "or_evt_917dd44bce05",
  "sport_key": "soccer_premier_league",
  "sport_title": "Premier League",
  "competition_key": "soccer_english_premier_league",
  "commence_time": "2026-09-20T14:00:00Z",
  "home_team": "Arsenal",
  "away_team": "Chelsea",
  "bookmakers": [{
    "key": "william_hill",
    "title": "William Hill",
    "region": "uk",
    "last_update": "2026-09-20T12:18:09Z",
    "markets": [{
      "key": "h2h",
      "last_update": "2026-09-20T12:17:02Z",
      "outcomes": [{ "name": "Arsenal", "price": 2.9 }, { "name": "Chelsea", "price": 2.5 }]
    }]
  }]
}
  • A bookmaker outcome is { name, price }. An exchange quoting a lay side is { name, back_price, lay_price, available }, with no commission applied.
  • Raw lists every venue's markets under one shared set of market keys, the markets.raw list in keyless /v2/coverage, while each venue keeps its own outcome names. Reconciling those names across books is the work the matched boards have already done. Normalising odds across bookmakers covers that work. An odds screen in Python shows what unreconciled names do to a best-price table.
  • A market's last_update is when that venue's market last changed. It is not a heartbeat: a quiet market's time ages while the venue is still read. The venue's last_update is the newest of its markets.
  • A winner market sold each-way, outright_winner in golf, horse racing and greyhound racing, adds the venue's terms: places, place_fraction and extra_place, which is true when the venue pays more places than its standard terms. Any other set of terms a book offers for the same event is a market of its own, outright_winner_ew_<places>, and an exchange's own each-way market is outright_each_way.
  • Raw is paged. Its meta adds total, board_version and a real next_cursor, and the walking raw guide covers paging and updatedSince.

The bare format moves meta into headers

Add ?format=bare and the reply is the data array alone. The board time stays in X-Processed-At and the event count moves to X-Count. last_seen is never sent on a bare reply, so keep the envelope if you compute price ages. Either way, each data reply carries an ETag: send it back as If-None-Match and an unchanged board is a free 304, as the conditional requests guide explains. Polling an odds feed efficiently builds the request loop around it and explains why polling suits odds data.

Every price's age is in the reply

A matched offer has no time field. Take meta.last_seen for the offer's venue and sport, and subtract it from now. That gives an upper bound on the price's age, and processed_at dates the board as a whole. How old is too old to show is your call. 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. The freshness guide has the lookup code. Displaying odds data in your app puts that age beside each price, and monitoring a feed in production watches it over time.

Read a live reply next

The API reference lists every field and parameter, with the contract's own trimmed samples, and live coverage shows what is on the feed today. An AI coding tool can read the same contract, if you check what it writes.

Related posts

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