# How to display odds data in your app

> Turn an odds API response into table rows, format decimal prices without losing precision, show each back and lay pair with its age, and poll without waste.

Published 1 July 2026 · updated 5 November 2026

**To display odds data in your app, flatten the feed's nested events into one row per outcome, put the best back offer beside the best lay offer, and format prices only when you render them. Keep your key on your server and poll with a conditional request, so an unchanged board costs nothing.**

## What a matched board looks like

A matched board reply is an envelope: `meta` describes the board and `data` holds the events. Each event carries `markets`, each market carries `outcomes`, and each outcome carries two arrays of offers. `back` holds bookmaker prices, best price first. `lay` holds exchange prices with `available`, the money on offer at that price, lowest price first. Here is one event from the standard board, trimmed:

GET /v2/odds/standard · example:

```json
{
  "meta": {
    "feed_type": "standard", "region": "uk",
    "odds_format": "decimal",
    "processed_at": "2026-09-17T02:20:57.271Z",
    "last_seen": { "william_hill": { "soccer": "2026-09-17T02:20:55Z" } },
    "count": 1,
    "version": "v2",
    "next_cursor": null
  },
  "data": [{
    "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 }]
      }]
    }]
  }]
}
```

Two shapes differ, and a table that handles them from day one saves a rewrite. On `each-way` and `extra-place`, a racing event carries `venue` instead of teams, back offers add `places` and `place_fraction`, and `lay` becomes an object with `win` and `place` arrays. On `dutching` there is no `lay` at all: back offers carry a `dutch_id`, and you group one market's offers by it.

## Flatten the response into rows

Walk the tree once and emit a flat array your table component can loop over. The usual layout is one row per outcome, so the loop runs event, then market, then outcome, and takes the first offer on each side as the headline pair. The rest of each array is still there for an expandable "more prices" view.

Flattening a matched board into rows · example:

```js
// One row per outcome: the best back offer beside the best lay offer.
function toRows(body) {
  const rows = [];
  for (const event of body.data) {
    const fixture = event.home_team ? `${event.home_team} v ${event.away_team}` : event.event_name ?? event.venue;
    for (const market of event.markets) {
      for (const o of market.outcomes) {
        const lays = Array.isArray(o.lay) ? o.lay : o.lay?.win ?? [];
        rows.push({
          key: `${event.event_id}|${market.key}|${o.name}|${o.point ?? ""}`,
          fixture,
          kickOff: event.commence_time,
          market: market.key,
          outcome: o.name,
          back: o.back[0],
          lay: lays[0] ?? null,
          moreBacks: o.back.slice(1),
        });
      }
    }
  }
  return rows;
}
```

Keep the mapping thin. Read the fields you display and avoid a bespoke model you then have to maintain. The row key lets your framework diff the table between polls. It is built from `event_id`, which is opaque, the same on every board and never changed once served. Line markets carry a `point`, which is why it belongs in the key.

Venue fields are snake-case keys such as `william_hill` or `betfair_exchange`. `GET /v2/bookmakers` returns every key with its display name, costs no tokens, and carries an `ETag`, so fetch it once, cache it, and map keys to display names from there. The [anatomy of an odds API response](https://oddsrelay.io/blog/anatomy-of-an-odds-api-response) walks the full envelope field by field.

## Format prices without losing precision

Format for display and never mutate the value underneath. Decimal prices like `2.9` and `3.0` read better as `2.90` and `3.00` in a column, but any comparison or calculation should run on the number the feed sent.

- **Store the number, format a string.** Compute on `2.9` and display `"2.90"`. Never overwrite the stored value with its rounded form.
- **Fix the decimal places for alignment.** Two places keeps a price column lined up.
- **Round once, at the end.** Anything you derive from two prices picks up floating-point noise, so keep full precision through the calculation and round only the displayed result.
- **Localise only the separator.** A comma decimal separator is a display change, and the number you compare stays the same.
- **Handle `null`.** A price field can be `null`, so render a dash rather than `NaN`.

The standard and dutching boards serve decimal prices only. The other boards take `oddsFormat=american` and convert for you. There is no fractional format, so a UK-facing table that wants `15/8` does that conversion itself. The [American odds guide](https://oddsrelay.io/docs/guides/american-odds) has the exact rounding rule, and the [odds converter](https://oddsrelay.io/widgets/odds-converter) is a quick check on your own output.

## Show the back and lay prices together

Show the back and lay side by side so a user reads the relationship in one glance. The back side is a bookmaker's price, and the lay side comes from Betfair, Smarkets and Matchbook. Pairs whose lay has no money behind it are left off the board.

| Field | UI element | Note |
| --- | --- | --- |
| `back[0].bookmaker` + `back[0].price` | Book name or logo and the back price | The best back price for this outcome |
| `lay[0].exchange` + `lay[0].price` | Exchange label and the lay price | The lowest lay price on offer |
| `lay[0].available` | Availability hint | The money on offer at that lay price. Flag it when it is small against a typical stake |
| `back[0].link` | An open-at-bookmaker button | The venue's page for this event, or `null` |
| Your rating column | A sortable score | Computed in your code from the two prices |

Qualifying loss depends on the stake and the user's exchange commission, so it belongs in your code too. The [ratings and qualifying loss explainer](https://oddsrelay.io/blog/qualifying-loss-and-ratings-explained) covers the formulas. The simplest column is one line:

Rating arithmetic in your own code · example:

```js
const rating = row.back?.price && row.lay?.price
  ? (row.back.price / row.lay.price) * 100
  : null;
```

Give users a way to narrow the table. With 60+ bookmakers live in the UK & Ireland, an unfiltered board is a long scroll. Filter on the server where you can: the matched boards accept `sports`, `bookmakers`, `exchanges`, `markets` and a kick-off window, and the [matched board filters guide](https://oddsrelay.io/docs/guides/matched-board-filters) lists them.

## Show each price's age

Matched offers carry no timestamp of their own, so read the age from the envelope. `meta.processed_at` is the board's time, and it suits a small "prices as of 14:02:31" line above the table. `meta.last_seen` gives the last read for each venue and sport. Now minus that time is an upper bound on how old that venue's prices can be, which makes it the right input for a per-row age badge.

`last_seen` is keyed by bookmaker and then by sport group (`soccer`, `horse_racing`), not by an event's full `sport_key`. The [freshness guide](https://oddsrelay.io/docs/guides/freshness) shows the lookup in a few lines of JavaScript. What counts as too old is a product decision for you. 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. That gives you a reference point for the threshold. Further from kick-off, prices in the other markets can be older.

> **Old prices need a visible mark** When a row's age passes your threshold, grey it out or badge it. A price that looks current and is not is worse than a gap, because a user acts on it.

## Keep the key on your server

A server key belongs in an environment variable on your backend, never in a browser bundle, a query string or client-side code. Have your backend poll the feed and serve the rows to your own pages. One backend poll then serves every visitor, so your token spend follows your poll count, and a traffic spike adds no calls.

If you want the board rendered in the browser without building the backend, the [oddsmatcher widget](https://oddsrelay.io/widgets/oddsmatcher) is the browser-side delivery. It runs under an Enterprise browser key locked to the websites you list, and the feed refuses that key from any other site.

## Refresh when the data changes

Decouple the poll from the render. The feed is pull only, so a timer on your backend fetches the board and your UI re-renders from the last snapshot it holds. Pick an interval short enough that users see prices move and long enough that you stay inside your account's rate limits. A `429` carries `Retry-After`, so wait that long before the next call.

- **Send the `ETag` back** as `If-None-Match`. An unchanged board is a `304` with no body, and it costs no tokens.
- **On a `304`, keep the current rows** and skip the parse.
- **On a `200`, re-map** and refresh the age stamps your badges read.
- **Send `Accept-Encoding: gzip`** so board replies arrive compressed.

A live-feed call's token price comes from the request, the board and its filters, never from the size of the reply, and `?quote=true` prices a call without spending anything. The [filters and tokens guide](https://oddsrelay.io/docs/guides/filters-and-tokens) explains the pricing, and [polling an odds feed efficiently](https://oddsrelay.io/blog/polling-an-odds-feed-efficiently) goes deeper on the loop.

## Wire the table to real rows

Every field above is in the [API reference](https://oddsrelay.io/docs/reference), and the [coverage page](https://oddsrelay.io/coverage) lists the bookmakers and sports the feed covers.

A table other people see runs on [Enterprise](https://oddsrelay.io/enterprise). Build and check it against your own Free key first.

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

Source: https://oddsrelay.io/blog/how-to-display-odds-data-in-your-app
