BlogRacing and each way

Tipster proofing: recording the price on offer

Tipster proofing is the record of every selection, its advised price and the bookmaker offering it, lodged with an independent proofer before each race. An odds feed can supply most of the data in that record: each bookmaker's price for the runner, that bookmaker's each-way terms where the feed carries them, and the time of each reply.

What the ASA asks a proofing record to show

Four things, according to the ASA's advice for betting tipsters:

  • Each recommended bet was registered with a demonstrably independent body before the race.
  • The record is complete. A partial record is not acceptable, even when the bets in it were registered.
  • The price was available at a bookmaker. The ASA has accepted proofing emails sent before each race alongside screenshots of the bookmakers' pages showing the bets were on offer.
  • The price stayed available for a significant time after the selection was published, long enough for followers to have a reasonable chance of taking it.

That page is CAP Executive advice, dated 22 May 2025, and it does not bind the ASA. Read it yourself and take legal advice on what your adverts claim.

Every test comes down to one row per selection: the race, the runner, the bookmaker, the price, the each-way terms and the time the price was seen. Lodging the row with your proofer before the off stays your step. Stored replies show what the feed carried. Whether they count as evidence is for your proofer to say, and they do not make the record independent.

The fields behind each line of the record

Raw holds the price, and the each-way and extra-place boards hold the terms. The raw board, GET /v2/odds/raw, carries every runner each bookmaker lists, at its price and under its own spelling. The each-way board carries the each-way terms, but only beside prices it matches against an exchange win lay and an exchange place lay paying the same number of places.

When a bookmaker pays extra places and no exchange has a place market paying as many, its terms are on the extra-place board, GET /v2/odds/extra-place, beside a place lay paying fewer places. A runner missing from either board does not mean the bookmaker left it out.

Record columnField in the replyWhat it holds
Raceid on raw, event_id and venue on the matched boards, commence_time on every boardThe scheduled off, and on the matched boards the course. The id is opaque and never changes once served.
Runnerthe outcome's nameOn raw, each bookmaker's own spelling. On the matched boards, one outcome per runner under the spelling most bookmakers use.
Bookmakerbookmakers[].key on raw, back[].bookmaker on the matched boardsA key as /v2/bookmakers lists it, such as sky_bet.
Price on offerthe raw outcome's priceDecimal by default.
Each-way termsback[].places, back[].place_fractionPlaces paid and the place fraction (0.2 is one-fifth), for each bookmaker on the each-way or extra-place board.
Where it was listedlink on raw with includeLinks=true, or back[].linkThat bookmaker's page for the event, when it has one.
When it was seenmeta.processed_atThe reply's board time, also sent as the X-Processed-At header.
How old a price can bemeta.last_seenOn a matched board's reply, each bookmaker's and exchange's last read, per sport.

Store each whole reply beside the row, as well as the numbers you copy out of them. The feed holds no price history, so a price you did not keep when the selection went out cannot be fetched later. The bookmaker's link opens its page for the event, but that page shows what is on offer when it is opened, not what was on offer when the selection went out.

Read the price when the selection goes out

Make the three calls with the same filters: sports=horse_racing, the bookmakers named in the selection, and a kick-off window that holds the off. Add quote=true to each the first time: it prices the exact call and costs nothing. The matched board filters guide lists every filter the each-way and extra-place boards take.

Price the call, then make all three · example

# Price the exact call first: free (the same works for the other two)
curl --compressed -H "Authorization: Bearer $ODDSRELAY_KEY" \
  "https://api.oddsrelay.io/v2/odds/raw?sports=horse_racing&bookmakers=sky_bet,boylesports&commenceTimeFrom=2026-10-14T13:50:00Z&commenceTimeTo=2026-10-14T13:50:00Z&includeLinks=true&quote=true"

# The prices and each bookmaker's link: the body to raw.json, the headers to raw-headers.txt
curl --compressed -H "Authorization: Bearer $ODDSRELAY_KEY" -o raw.json -D raw-headers.txt \
  "https://api.oddsrelay.io/v2/odds/raw?sports=horse_racing&bookmakers=sky_bet,boylesports&commenceTimeFrom=2026-10-14T13:50:00Z&commenceTimeTo=2026-10-14T13:50:00Z&includeLinks=true"

# The each-way terms for the same race
curl --compressed -H "Authorization: Bearer $ODDSRELAY_KEY" -o each-way.json \
  "https://api.oddsrelay.io/v2/odds/each-way?sports=horse_racing&bookmakers=sky_bet,boylesports&commenceTimeFrom=2026-10-14T13:50:00Z&commenceTimeTo=2026-10-14T13:50:00Z"

# The terms of a bookmaker paying extra places, when it is not on the each-way board
curl --compressed -H "Authorization: Bearer $ODDSRELAY_KEY" -o extra-place.json \
  "https://api.oddsrelay.io/v2/odds/extra-place?sports=horse_racing&bookmakers=sky_bet,boylesports&commenceTimeFrom=2026-10-14T13:50:00Z&commenceTimeTo=2026-10-14T13:50:00Z"

The two replies below are made up for the example. The raw one shows one runner at each bookmaker, with the links left out.

GET /v2/odds/raw · trimmed to one runner · example

{
  "meta": {
    "feed_type": "raw",
    "region": "uk",
    "odds_format": "decimal",
    "processed_at": "2026-10-14T12:02:41.518Z",
    "count": 1,
    "version": "v2",
    "next_cursor": null,
    "total": 1,
    "board_version": 1791979362904
  },
  "data": [{
    "id": "or_evt_7d20c41e9a58",
    "cluster_id": null,
    "sport_key": "horse_racing",
    "sport_title": "Horse Racing",
    "competition_key": null,
    "commence_time": "2026-10-14T13:50:00Z",
    "home_team": null,
    "away_team": null,
    "bookmakers": [
      {
        "key": "boylesports",
        "title": "BoyleSports",
        "region": "uk",
        "last_update": "2026-10-14T11:47:09Z",
        "markets": [{
          "key": "outright_winner",
          "last_update": "2026-10-14T11:47:09Z",
          "outcomes": [{ "name": "Copper Lantern", "price": 6.5 }]
        }]
      },
      {
        "key": "sky_bet",
        "title": "Sky Bet",
        "region": "uk",
        "last_update": "2026-10-14T11:58:12Z",
        "markets": [{
          "key": "outright_winner",
          "last_update": "2026-10-14T11:58:12Z",
          "outcomes": [{ "name": "Copper Lantern", "price": 7.0 }]
        }]
      }
    ]
  }]
}

The each-way reply shows the same runner. Its lay side is there because the each-way board pairs bookmaker prices with exchange lays for matched-betting tools, and the record can leave it out.

GET /v2/odds/each-way · trimmed to one runner · example

{
  "meta": {
    "feed_type": "each-way",
    "region": "uk",
    "odds_format": "decimal",
    "processed_at": "2026-10-14T12:02:40.902Z",
    "last_seen": {
      "boylesports": { "horse_racing": "2026-10-14T12:02:34Z" },
      "matchbook": { "horse_racing": "2026-10-14T12:02:37Z" },
      "sky_bet": { "horse_racing": "2026-10-14T12:02:36Z" },
      "smarkets": { "horse_racing": "2026-10-14T12:02:38Z" }
    },
    "count": 1,
    "version": "v2",
    "next_cursor": null
  },
  "data": [{
    "event_id": "or_evt_7d20c41e9a58",
    "sport_key": "horse_racing",
    "sport_title": "Horse Racing",
    "competition_key": null,
    "commence_time": "2026-10-14T13:50:00Z",
    "venue": "Nottingham",
    "markets": [{
      "key": "each_way",
      "outcomes": [{
        "name": "Copper Lantern",
        "back": [
          { "bookmaker": "sky_bet", "price": 7.0, "places": 4, "place_fraction": 0.2, "link": null },
          { "bookmaker": "boylesports", "price": 6.5, "places": 3, "place_fraction": 0.25, "link": null }
        ],
        "lay": {
          "win": [{ "exchange": "smarkets", "price": 7.6, "available": 41.0, "link": null }],
          "place": [
            { "exchange": "smarkets", "price": 2.0, "available": 25.0, "places": 4, "link": null },
            { "exchange": "matchbook", "price": 2.5, "available": 30.0, "places": 3, "link": null }
          ]
        }
      }]
    }]
  }]
}

For Copper Lantern the row is sky_bet at 7.0, paying four places at one-fifth, seen at raw's processed_at time. The code below builds it. It matches the runner on a folded name, lowercased with the punctuation taken out, because bookmakers spell names differently (Ella's Gold at one, Ellas Gold at another). Anything it cannot find is recorded as missing rather than guessed:

Your code: one proof row from the stored replies · example

// selection, advisedBookmaker: what the tipster lodged. raw, eachWay, extraPlace: the three stored replies.
const fold = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, "");
const winOutcomes = (book) => book.markets.find((m) => m.key === "outright_winner")?.outcomes ?? [];
const isSelection = (o) => fold(o.name) === fold(selection);

// Two courses can share an off, so the runner's name picks the race.
const race = raw.data.find((e) => e.bookmakers.some((b) => winOutcomes(b).some(isSelection)));
const book = race?.bookmakers.find((b) => b.key === advisedBookmaker);
const runner = book && winOutcomes(book).find(isSelection);

// A bookmaker's terms hold for the whole race, so any of its runners on a board gives them.
const boardRace = (reply) => reply.data.find((e) => e.event_id === race?.id);
const termsOn = (reply) => boardRace(reply)?.markets
  .flatMap((m) => m.outcomes)
  .flatMap((o) => o.back)
  .find((b) => b.bookmaker === advisedBookmaker);
const terms = termsOn(eachWay) ?? termsOn(extraPlace);

const proofRow = {
  event_id: race?.id ?? null,
  course: (boardRace(eachWay) ?? boardRace(extraPlace))?.venue ?? null, // raw names no course: keep the one you lodged too
  off: race?.commence_time ?? null,
  selection,
  bookmaker: advisedBookmaker,
  price_on_offer: runner?.price ?? null, // null: not in this reply, which does not prove it was not offered
  places: terms?.places ?? null, // null: not on the each-way or extra-place reply
  place_fraction: terms?.place_fraction ?? null,
  link: book?.link ?? null,
  board_time: raw.meta.processed_at,
};

Each-way terms belong in the record

In a 2015 ruling, the ASA upheld a complaint against a racing tipster service's advertised results and listed the gaps in its evidence. One gap was that the each-way terms could not be seen in some of the screenshots offered as proof, so those screenshots did not confirm the terms the record used.

The stored replies show the terms a bookmaker offered when you called. Bookmakers on the same race can pay different places at different fractions, so the record keeps the terms of the bookmaker named in the selection. If neither the each-way nor the extra-place reply has that bookmaker in the race, read its terms on its own page.

Settling the place part is your arithmetic. Its price is 1 + (win − 1) × place_fraction, which comes to 2.2 for the sky_bet price above. Each-way and extra-place data goes further into the terms, and the each-way feed and extra-place feed pages list what each board carries.

Best odds guaranteed (BOG) and the starting price

The feed carries no starting prices, closing prices or results, so a record that settles at the starting price takes both from a results source. Under Best odds guaranteed, a bookmaker pays a winner at the starting price when that beats the price taken, on its own terms.

The bog board, GET /v2/odds/bog, holds win prices from the bookmakers listed as offering BOG on each race. Store its reply beside the others: it records which bookmakers were listed when the selection went out. The bookmaker's own terms settle whether BOG applied, and they also say which races qualify and any cap on a stake. A bookmaker missing from the board may still offer it. The BOG feed page describes the board.

The same ruling found the starting prices on most of the sampled bets shorter than the prices in the service's record. A record kept at the advised price also has to show that the price stayed on offer.

The advised price has to stay on offer

The ASA's advice sets no figure for how long the price must stay. Your proofer may set one.

Repeat the raw call after the selection goes out, and add a row each time the named bookmaker still lists the runner at the advised price or better. Only those rows show the price held. A reply without the runner proves nothing either way, because a bookmaker can be off the board briefly until it is read again.

Raw carries no each-way terms, so for an each-way selection those rows show only that the win price held. To show the terms held too, repeat the each-way call, or the extra-place call for a bookmaker found there, and keep its terms beside each row.

Ask for format=bare on the repeat calls, and send each reply's ETag back as If-None-Match. A bare reply leaves the board time out of the body, so its ETag changes only when the data in it does. Until then the reply is a 304, with no body and no tokens spent. Its X-Processed-At header gives the time to note against the reply you stored. Polling for fewer tokens explains why the default envelope rarely gets a 304.

A bare reply is the data array alone. Wrap it as { data: reply, meta: { processed_at: <X-Processed-At> } } before the proof-row code reads it. The first bare call is a full 200, because the ETag stored with an envelope reply does not match a bare one, so send back its ETag from then on.

The time now minus the each-way reply's meta.last_seen.sky_bet.horse_racing is an upper bound on the age of that bookmaker's price, and keyed /v2/coverage carries the same times. Every product on sale meets a maximum update time of 10–20 s: how old a bookmaker's price can be when it reaches you. The freshness guide explains last_seen.

Football selections and the raw board

A football selection goes in the same record, with its price from raw as well. sports=soccer takes every football competition key, because the sport filter matches by prefix, and markets=h2h keeps the match result. The standard board shows each outcome (home, draw, away) once, but only beside prices it matches against an exchange lay, so a selection's outcome or bookmaker can be missing there.

GET /v2/odds/event/{id} returns one raw event with the venues joined to it, and includeLinks=true adds each venue's event page. A football fixture can arrive as more than one raw event, so if the named bookmaker is not in it, look on the raw board for another event at the same kick-off with the same teams under other spellings. Raw keeps each venue's own outcome names, so your code matches the selection to each venue's spelling, as the racing code does. Each raw market also carries last_update, the time that venue's market last changed.

Proof one race meeting first

The Free plan gives 50,000 tokens for 14 days from sign-up, with no card. Quote the raw, each-way and extra-place calls for one meeting, then make them, store each reply and build a row per selection. Proofing that shows the prices to your followers runs on Enterprise.

Odds data for tipsters and content sites covers the current best price shown beside each selection on your own pages.

Sources, checked 13 October 2026

  1. ASA (CAP advice): Gambling, betting and gaming: Tipsters, dated 22 May 2025 (opens in a new tab)
  2. ASA ruling on a racing tipster service, A14-289661, 10 June 2015 (opens in a new tab)

Related posts

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