How to display odds data in your app
OddsRelay · · Updated · 7 min read
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 at the moment you render them. Keep your key on your server and poll with a conditional request, so an unchanged board costs nothing and your table never re-fetches on a render.
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,
"unreliable_links": [],
"version": "v2",
"next_cursor": null
},
"data": [{
"event_id": "or_evt_917dd44bce05",
"sport_key": "soccer_epl",
"sport_title": "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 }],
"lay": [{ "exchange": "betfair_exchange", "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.
How do you map an odds response to UI 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_id, which is stable across boards but opaque, so use it as a render key and don't store it as a durable one. 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 walks the full envelope field by field.
How do you format a decimal price 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=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 has the exact rounding rule, and the odds converter is a quick check on your own output.
How do you show the matched pair clearly?
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, Matchbook and BETDAQ. Each back price arrives paired against exchange lay and liquidity-gated. The rating and qualifying loss are simple arithmetic on the pair, and the oddsmatcher widget shows them.
| 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;
Before you render a link, check meta.unreliable_links. It names the venue and sport pairs whose links are known not to open the event page, and a button that lands on a bookmaker's home page leaves the user hunting for the event.
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.
How do you show how old a price is?
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 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, which gives you a reference point for that threshold.
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 a browser key locked to the websites you list, and the feed refuses that key from any other site.
How often should the display refresh?
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 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 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 shows which bookmakers and sports are live right now. To build against real replies instead of the examples here, request access.