How to display odds data in your app
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:
{
"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_, and lay becomes an object with win and place arrays. On dutching there is no lay at all: back offers carry a dutch_, 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.
// 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_, 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_ or betfair_. GET / 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 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.9and 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 benull, so render a dash rather thanNaN.
The standard and dutching boards serve decimal prices only. The other boards take oddsFormat= and convert for you. There is no fractional format, so a UK-facing table that wants 15/ does that conversion itself. The American odds guide has the exact rounding rule, and the 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 covers the formulas. The simplest column is one line:
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 lists them.
Show each price's age
Matched offers carry no timestamp of their own, so read the age from the envelope. meta.processed_ is the board's time, and it suits a small "prices as of 14:02:31" line above the table. meta.last_ 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_ is keyed by bookmaker and then by sport group (soccer, horse_), not by an event's full sport_. The freshness guide 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 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
ETagback asIf-None-Match. An unchanged board is a304with 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: gzipso 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 ? prices a call without spending anything. The filters and tokens guide explains the pricing, and polling an odds feed efficiently goes deeper on the loop.
Wire the table to real rows
Every field above is in the API reference, and the coverage page lists the bookmakers and sports the feed covers.
A table other people see runs on Enterprise. Build and check it against your own Free key first.
Related posts
- The anatomy of an odds API responseEvery field in a /v2 odds response, from the meta envelope down to each back and lay offer.
- Efficient odds API polling with ETag and 304sSend the ETag back as If-None-Match and an unchanged reply is an empty 304 that costs no tokens.
- How to build an oddsmatcherAn oddsmatcher is paired price data plus the product on top. Which half deserves your engineering time.
18+ · OddsRelay supplies odds data only and takes no bets. Please gamble responsibly.