---
title: "Best racing price per runner across books"
method: GET
path: "/v1/racing/best-odds"
tags: ["Racing"]
---

# Best racing price per runner across books

`GET /v1/racing/best-odds`

## Request

- Base URL: `https://api.puntersedge.online`
- URL: `https://api.puntersedge.online/v1/racing/best-odds`
- Auth: API key in header `X-API-Key`

Cost: 3 credits. The best win, place and tote price for every runner in the next races,
with the bookmaker offering it and a deep link to their race page.

`market_percentage` is the sum of implied probabilities at the best available price. **Under
100 means the best prices across books beat the field** — a cross-book arbitrage. Any single
book's market is always over 100.

`price_spread_pct` per runner is how much better the best price is than the worst currently
quoted: how much a punter loses by not shopping around.

**Win and place coverage are counted separately.** `books_quoting_win` is the number of
bookmakers with a WIN price on that runner and `books_quoting_place` the number with a
PLACE price. They are not the same set — a book can quote the win and not the place on
the same runner — so a place bet sized off a win-market count is sized off the wrong
number. `books_quoting` is retained unchanged for existing clients and equals
`books_quoting_win`; read the explicit names in new code. `books_compared`, at race
level, is the number of distinct books contributing any win price to the whole race.

⚠️ `best_tote` is **not comparable to `best_win`**. A pre-race tote dividend is an estimate
that moves as the pool fills, and taking the highest across books and products is upward
biased by construction — the noisiest estimate wins. Greyhound pools are thinnest and swing
hardest: measured 2026-08-15, greyhound tote-to-fixed ratios reached 8.8x on betright and
9.9x on tabtouch, against averages near 1.0 for horse and harness. A tote figure far above
the fixed price is usually a thin pool, not an edge. Settle against a FINAL dividend, and
read the `product` code — `PROV` is indicative, `SP`/`MIDDIV`/`RD+`/`BT+SP` are named
products.

The comparison covers the Australian fixed-odds bookmakers, on New Zealand races as well
as Australian ones. Not every book quotes New Zealand, so an NZ race is compared across
fewer books than an AU race; the live per-country book counts are published at
https://puntersedge.online/coverage-report. Betfair Exchange prices are
withheld pending a Betfair Exchange data licence, and non-AU reference books are excluded
because an Australian punter cannot bet them — so `betfair_ex_au` never appears as a
`best_win` bookmaker, and `market_percentage` is the fixed-odds market rather than an
exchange-inclusive one.

**Hong Kong** (`country=HK`, feature_requests #29, 2026-09-09). Sha Tin and Happy Valley
are compared across whichever Australian books quote the meeting — win and place, the
same `best_win` / `best_place` shape as an Australian race. PRICES ONLY: no results, no
form, no race conditions, no `runner_ref` — `horse_ref` alone is present, because it is
derived from the runner's name and needs no Racing Australia read. Unlike the rest of
the foreign card this is a
real cross-book comparison rather than one book against itself, but it is a smaller panel
than an Australian race and only exists on the HKJC's nights — Happy Valley on Wednesday
evenings, Sha Tin on Saturday or Sunday afternoons (AEST).

**Scratched runners are never priced here.** They appear in `scratchings` only, and are left
out of `market_percentage`.

**Placeholder prices are kept out of the best line (since 2026-09-10).** A quote of $21 or
more that is at least 4x the median of three or more other books for the same runner is
treated as a placeholder, not a price: it is excluded from `best_win` / `best_place`,
`market_percentage` and `price_spread_pct`, and listed on the runner as `suspect_win` /
`suspect_place` (`bookmaker`, `price`, `consensus`) so nothing is hidden. A book that has
not priced a runner yet can publish $101 against a $9 field; before this that was the best price.
The same treatment, with a `reason` on the entry, covers a book quoting before the race's
newest scratching (`pre_scratching`), a book whose whole win book sums under 95%
(`book_under_round`), and since 2026-10-20 a book that has not re-priced the race for an hour
while the other books have, on a runner where it is 1.6x or more the median of eight or more
others (`book_not_repriced`, with `consensus` and `behind_market_s`).

**Every published price carries its own age.** `best_win`, `best_place` and `best_tote` each
include `last_update`, `age_seconds`, `stale` and `refresh_tier`, because the book with the best price is not
always the book that answered most recently. `best_win` and `best_place` both carry the
offering book's race-page `source_url`.
On Plus and above both also carry `betslip_url`, the runner-level betslip link of that
same book - null when the book has no such route, and absent on plans below Plus. See the
table below. Race level, `data_age_seconds` is the oldest of
those winning quotes and `stale_bookmakers` names any past its own `refresh_tier`
threshold (120s on `live`, 1800s on `card`; each published quote carries its own tier, and
a Betr quote 30-180 minutes from the jump is `card` on a `live` race — see
/v1/racing/next-to-go for what the tiers mean). Responses are cached for 20s
and say so via `cached` / `cache_age_seconds`; ages are recomputed at serve time, so a cached
payload reports its real age rather than the age it had when it was stored.

**Same Race Multi legs** (`best_top2`, `best_top3`, `best_top4`, since 2026-09-05).
The best price across books for the runner to finish in the first two, three or four,
in the same shape as `best_place` (price, bookmaker, source_url, age fields) plus
`books_quoting`, the number of books offering that leg. Present only when at least one
book quotes the leg: Ladbrokes, Neds and PointsBet carry all three, Sportsbet where its
racecard offers them. Every published leg satisfied
`win >= top2 >= top3 >= top4 > 1.0` at its source.

**Runner-level betslip links** (`betslip_win_url`, `betslip_place_url`,
`betslip_app_only`). Where `source_url` opens the book's race PAGE, these open the book's
BETSLIP with this runner already selected. **Plus and above** (since 21 September 2026):
the betslip fields are served on Plus, Business, Platform and Scale, and a Standard or
Hobby key issued before that date keeps them - a key never loses what it was sold. On every
other plan the fields are simply absent, exactly as on a book with no route; `source_url`,
the race-page link, is on every plan and is unaffected. Coverage, honestly:

| book | opens in | preselects |
|---|---|---|
| `unibet` | web browser | runner **and** market (fixed win or fixed place) |
| `tab` | web browser | runner only - the slip shows a win box and a place box, so `betslip_win_url` and `betslip_place_url` are deliberately the same URL |
| `ladbrokes_au` | **native app only** (iOS/Android 8.69.0+) | runner and market |
| `neds` | **native app only** | runner and market |

Every other bookmaker has no such route and carries none of these fields.
`betslip_app_only` is `true` for Ladbrokes and Neds: in a desktop browser those URLs
redirect to the app-download page, so send them to a phone with the app installed, or
fall back to `source_url`. Measured 2026-09-03. The Ladbrokes pattern is vendor
documented; the Neds one is **inferred** from it (same platform, same entrant ids) and
has not been tap-tested on a Neds install.

These routes are read from the bookmakers' own site code. **None of them is published or
supported by the bookmaker**, so any can change without notice. A daily automated check
loads a live Unibet link and a live TAB link in a real browser and confirms the betslip
populates; when a book fails that check its betslip fields go **null** until it passes
again, so a broken route degrades to "no link" rather than sending a member to a dead
page. Ladbrokes and Neds cannot be checked this way - a headless browser cannot open a
native app - and ship on the vendor's documentation instead.

The fields are **absent** on a book that has no link, exactly as `source_url` is; treat
absent and `null` alike as "no betslip link". Nothing about `source_url` changed.

Like `source_url` these are plain URLs, and extra query parameters you append are passed
through untouched. A link that opens a native app leaves the browser, so cookie-based
attribution set by your own site does not travel with it.

**Your own links** (`source_link` and `betslip_link` on every quote object, since
2026-10-14, Plus and above). Store your bookmaker link templates once with
`PUT /v1/link-templates` (for example your affiliate tracking link per bookmaker, with
`{url}` where the bookmaker's URL goes) and each quote also carries a ready-made twin of
its URLs, built through them: put the `*_link` value on your page. A twin is absent where
there is nothing to link, and an account with no stored templates sees none. The `*_url`
fields are unchanged.

## Query parameters

- `hours_ahead` integer: How many hours ahead to scan for priced races, from now. Default 6, which is what this endpoint has always used. The ceiling is your plan horizon, returned as X-Horizon-Hours: 24 Free and Hobby, 48 Standard, 168 Plus and above. Raising it past about 12h returns races no book has opened yet -- the first price lands a median 10.1h before the jump on AU thoroughbreds -- so a wide window is for building tomorrow's board, not for finding more prices today.
- `date` string, nullable: Single-day alias: date=YYYY-MM-DD scans that Australian Eastern meeting day instead of a rolling hours_ahead window. Mutually exclusive with hours_ahead, bounded by the same X-Horizon-Hours ceiling, forward-looking only. num_races still caps the rows returned, so pair a date with num_races=150 for a full card.
- `num_races` integer: Up to 150. Pass 150 to compare every race on the day's card in one call instead of paging.
- `limit` integer: Alias for `num_races`, accepted because it is the near-universal REST spelling and because /v1/racing/results already takes it — learning `limit` on one racing endpoint and being refused on the next is the inconsistency this closes. Same bounds and same meaning; when both are given, `limit` wins. Measured 2026-09-14: callers sent `limit` here 28 times and were refused every time.
- `categories` string: horse,greyhound,harness or omit for all
- `category` string: Alias of `categories`. Both spellings are accepted on every racing endpoint. An unknown value is a free 422 naming the valid ones.
- `venue` string: Venue names, comma-separated and case-insensitive (e.g. Randwick or Randwick,Flemington). Matched exactly against the race's `venue` after case folding, so pass the venue as this API spells it — read `venue` off an unfiltered call rather than guessing. Omit for every venue. Same rule and same spelling as /v1/racing/next-to-go. This narrows the window the endpoint already serves; it does not look beyond it, so a venue with no race inside the window returns an empty list rather than an error. There is no race-number filter: `venue` selects the meeting and you read `race_number` off the races it returns.
- `bookmakers` string: Restrict the comparison to these bookmaker keys, case-insensitive. An unrecognised key is a free 422 naming the valid keys, so a typo costs nothing.
- `country` string: ISO country codes, comma-separated (e.g. AU; Britain is GB, not UK). Omit for every country. Outside AU, NZ, GB/IE and ZA a race is usually quoted by a single book, so a cross-book comparison on it compares nothing — Hong Kong (HK) is the exception among those countries, carrying several Australian books on the HKJC race nights, win and place, prices only. Pair with include_unresolved=true or you will silently lose races whose meeting is not confirmed yet.
- `include_unresolved` boolean: Include races whose country is not resolved yet (country is null). OFF by default. Most of an Australian card is unresolved until the meeting is confirmed — 58.8% of horse races in a measured window — so country=AU alone can return far fewer races than are running, or none at all. Pass true to get them, and read `country` per race to see which are still unresolved.

## Headers

- `X-API-Key` string
- `X-RapidAPI-Proxy-Secret` string

## Response `200`

Successful Response

- RacingBestOddsOut[]
  - `race_id` string, required
  - `venue` string, nullable, required
  - `race_number` integer, nullable, required
  - `category` string, nullable, required
  - `country` string, nullable, required
  - `start_time` string, required
  - `race_name` string, nullable
  - `track_condition` string, nullable
  - `market_percentage` number, nullable
  - `books_compared` integer, required
  - `scratchings` object[]
  - `data_age_seconds` integer, nullable: Age in seconds of the OLDEST bookmaker quote behind this race.
  - `freshest_age_seconds` integer, nullable
  - `refresh_tier` string: Which refresh rule this race is on, and therefore what the race-level `stale` means. `live` = inside 3 hours of the jump: books re-price it on every 8-20s poll, and `stale` fires past 120 seconds. `card` = further out: books re-price it about every 15 minutes so a full day fits inside their rate limits, and `stale` fires past 1800 seconds. Every published quote carries ITS OWN `refresh_tier`, and it can be `card` on a `live` race: Betr's upstream rate-limits us, so that book stops re-pricing every poll 30 minutes before the jump rather than 3 hours (PlayUp, on a 6-requests-a-minute budget, at 6 minutes), and its quotes between there and 3 hours are refreshed every 5-15 minutes, published as `card`, and judged against 1800 seconds (since 2026-09-07; before that they were marked stale). A book's tier is never tighter than its race's. `age_seconds` is the TRUE age in every case — only the threshold changes.
  - `stale` boolean
  - `stale_bookmakers` string[]
  - `cached` boolean, nullable
  - `cache_age_seconds` integer, nullable
  - `runners` object[], required

## Other responses

- `401`: Missing or invalid API key. Send `X-API-Key`. See https://puntersedge.online/developers/errors
- `402`: Monthly credit allowance exhausted for this plan. Upgrade at https://puntersedge.online/api/pricing
- `422`: Validation Error
- `429`: Rate limit exceeded for this plan. Honour the `Retry-After` header (seconds) before retrying. See https://puntersedge.online/developers/rate-limits
- `500`: Unexpected server error. Safe to retry with backoff.

---

[API](https://skmtc.dev/puntersedge/apis/puntersedge-odds-api.md) · [All operations](https://skmtc.dev/puntersedge/apis/puntersedge-odds-api/llms.txt) · [OpenAPI document](https://skmtc.dev/puntersedge/apis/puntersedge-odds-api/revisions/f35b6aab1946?raw)
