# Horse Racing API — complete manual

Everything in one file: every endpoint, its parameters, its errors, every guide, and what this dataset does not contain.

- Base URL: `https://api.apihorseracing.com/v1`
- Authentication: `X-API-Key` header, or `Authorization: Bearer`
- Site: https://apihorseracing.com
- Endpoints: 63
- Generated: 2026-09-22 from the same registry the API routes on

## Before anything else

**This is race data, not predictions.** Nothing here forecasts a race. Every statistic describes races that have already run, and nothing published is betting advice.

**What this dataset does not contain**, on any plan, at any price:

- Sectional times. Not held for any race, in any jurisdiction, at any point.
- How a race was run: no in-running positions, no commentary, no rider comment.
- Winning margins. `beaten_distance` is not published across this archive.
- A live bookmaker odds feed. Starting prices and the opening-to-off move only.

Opening prices exist for Britain and Ireland only, on roughly seven runners in ten.

**Every response is an envelope.** `meta` carries the request id, `data_as_of`, the plan the key is on and the window that plan reads. `data` carries the answer. An error replaces `data` with `error`, which has a `code` and a `message`.

## Plans and what each reads

| Plan | Price | Reads | Statistics | Rate | Monthly |
| --- | --- | --- | --- | --- | --- |
| Free key | $0 | 24h – 7d | no | 60/min | 15,000 |
| Live | $49/mo | today + 3 days | no | 180/min | 250,000 |
| Archive | $99/mo | back to 2017 | no | 90/min | 150,000 |
| Complete | $119/mo | 2017 → upcoming | no | 180/min | 400,000 |
| Complete + Analyst | $199/mo | 2017 → upcoming | included | 240/min | 1,000,000 |

A free key reads the same production database, held back to between seven days and twenty-four hours old. It does not expire. A date outside the window returns `outside_window` with the exact range the key reads.

## Coverage

| Jurisdiction | Races | Runners | With a finishing position |
| --- | --- | --- | --- |
| United States | 240,146 | 2,066,129 | 64% |
| Great Britain | 101,070 | 975,469 | 83% |
| France | 80,596 | 970,824 | 81% |
| Ireland | 26,471 | 353,733 | 79% |
| South Africa | 14,198 | 159,096 | 84% |
| Hong Kong | 4,383 | 59,221 | 89% |
| United Arab Emirates | 2,032 | 25,274 | 89% |
| India | 2,011 | 19,698 | 88% |
| Germany | 1,955 | 19,077 | 44% |
| Northern Ireland | 1,566 | 18,675 | 77% |
| BR | 529 | 4,521 | 84% |

Counted from the archive rather than claimed. `GET /v1/meta/coverage` returns the same figures, plus what your own key reaches.

## Endpoints

### Reference & coverage

What exists, and how much of it. Call these before writing anything else.

#### `GET /v1/countries`

Every country in the archive with its race count and which fields it publishes.

Plans: free, live, archive, complete, analyst.

The honest starting point. Before you write a line against this API, this endpoint tells you how much of each jurisdiction we hold and which fields it publishes, so you can decide whether your idea is possible rather than discovering it is not.

Every figure is counted from the loaded archive at the last data load, not declared in a configuration file. If the crawl reaches a new country it appears here without anyone editing anything, and if a jurisdiction stops publishing ratings the flag flips on its own.

#### `GET /v1/courses`

Every course, with first and last meeting and how many races we hold.

Plans: free, live, archive, complete, analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `region` | query | string | no | Restrict to one jurisdiction, e.g. GB. Values come from /v1/countries. |
| `country` | query | string | no | Restrict to one source country code, which is finer than region: Scot and Wale both roll up to GB. |

Errors: `invalid_param`.

Every course in the archive, ordered by how many of its races we hold, with the span of dates covered. Courses are the one entity you will almost certainly cache, because there are a few hundred of them and they do not change.

#### `GET /v1/meta/coverage`

Totals, the full-order split, and what your own plan can read.

Plans: free, live, archive, complete, analyst.

The whole archive in one object: how many races, how many runners, how far back, and the split between races carrying a full finishing order and those publishing only the placings.

It also returns your own plan and window, which makes it the one call worth making at startup: it answers what exists and what you personally can reach, in the same response.

never_published is the part worth reading twice. Sectional times and bookmaker odds do not exist in this dataset and never will, and we would rather you learned that here than after a card payment.

#### `GET /v1/search`

One lookup across horses, trainers, jockeys and courses.

Plans: free, live, archive, complete, analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `q` | query | string | yes | At least two characters. Matched against names; a prefix match sorts above a match in the middle. |
| `type` | query | string | no | Restrict to one of horse, trainer, jockey or course. |
| `limit` | query | integer | no | Per type, 1 to 25. Defaults to 10. |

Errors: `invalid_param`.

One lookup across horses, trainers, jockeys and courses, for the very common case of having a name and needing an identifier. Making somebody guess which of four endpoints holds the thing they typed is a poor first experience.

Results are ordered so that a prefix match beats a match in the middle, which is why searching Frank puts Frankel above Hurricane Frank.

#### `GET /v1/courses/{course_id}`

Run types, surfaces and distances actually raced there.

Plans: free, live, archive, complete, analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `course_id` | path | string | yes | Opaque course identifier, as returned by /v1/courses or anywhere a course appears. |

Errors: `invalid_id`, `not_found`.

One course, with the shape of racing that actually happens there rather than what it is supposed to stage. Run types and surfaces are grouped with their own counts and date spans, and the distances are the ones really raced, most used first.

That distinction matters. A track described as all-weather flat may carry a handful of turf meetings in its history, and a query written against the description would silently include them.

#### `GET /v1/reference`

Valid goings, classes and run types, counted from the data.

Plans: free, live, archive, complete, analyst.

The valid values for every filter, counted from the data. Goings, classes, run types, surfaces, regions and casualty reasons, each with how many races or runners carry it.

This exists because a filter written against a guessed string silently matches nothing. "Good to Soft" and "Good To Soft" are different values to a database and identical to a person, and a hardcoded list in your code goes stale the first time the source changes its wording.

#### `GET /v1/meta/reports`

How many race reports exist, from when, and for which jurisdictions.

Plans: free, live, archive, complete, analyst.

How many race reports exist, the dates they span, and the split by jurisdiction and by year.

Reachable on every plan including a free key, on purpose. Whether the reports are worth paying for depends on how many there are and which years they cover, and that is not a question you should have to pay to answer.

Coverage is partial and always will be. Reports are written for Britain and Ireland from 2020, because the market movement that gives them most of their substance is not published elsewhere. A race with no report returns null rather than an error.

### Race reports

A written account of every race, from the records themselves.

#### `GET /v1/reports`

Search written race reports by date, course or jurisdiction.

Plans: complete, analyst.

#### `GET /v1/reports/{race_id}`

The written account of a single race, with a line for every runner.

Plans: complete, analyst.

### Racecards & meetings

Today, tomorrow, and any day in the archive.

#### `GET /v1/racecards/today`

Every meeting and race today, grouped by course.

Plans: free, live, archive, complete, analyst.

Errors: `outside_window`, `upgrade_required`.

Every meeting and race carded today, grouped by course, in the order they are due off. This is the first call most consumers make and the one they make most often.

A meeting is one course on one day, and races arrive nested inside it because that is how a card is read. Nobody wants a flat list of forty races across nine tracks to group themselves.

The times are local to the course, deliberately. A 14:35 at Musselburgh and a 14:35 at Meydan are both the time on the racecourse clock, which is what a card shows and what your users expect. Everything else in the API is UTC.

#### `GET /v1/racecards/upcoming`

Declarations for the next few days.

Plans: free, live, archive, complete, analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `days` | query | integer | no | How many days from today, 1 to 7. Defaults to 2, meaning today and tomorrow. |

Errors: `upgrade_required`, `invalid_param`.

Declarations from today forward, as a flat list ordered by date and time rather than grouped, because a multi-day view is read chronologically rather than by track.

How far ahead anything exists depends on the jurisdiction. Britain declares 48 hours out for most races and five days for some; other countries publish later. Asking for seven days does not create races that have not been declared.

#### `GET /v1/meetings/{date}`

One row per course on that day, with going and race count.

Plans: free, live, archive, complete, analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `date` | path | string | yes | YYYY-MM-DD. |

Errors: `outside_window`, `invalid_param`.

One row per course racing on that date, with the going, the surface, the weather where it was published, and how many races the meeting held. No runners and no race detail.

It exists because a fixture list is a different question from a card. If you are showing "what is on today" you want nine rows, not four hundred runners, and fetching the full card to count meetings is wasteful on both sides.

Note that this path and /v1/meetings/{meeting_id} are the same shape. The router decides between them on the shape of what you send, so a date goes to this one and an identifier goes to the other. Send something that is neither and you get invalid_id rather than the wrong endpoint.

#### `GET /v1/results/latest`

The most recently settled races your plan can read.

Plans: free, live, archive, complete, analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `limit` | query | integer | no | 1 to 100. Defaults to 25. |

Errors: `upgrade_required`, `invalid_param`.

The most recently settled races your plan can read, newest first, with the winning time and the book percentage attached.

The phrase "your plan can read" is doing real work. On a free key latest means the newest race inside the delayed window, not the newest race in the archive followed by a refusal. The endpoint gives you the best answer available to you rather than an error.

#### `GET /v1/racecards/{date}`

The full card for any date, grouped into meetings.

Plans: free, live, archive, complete, analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `date` | path | string | yes | YYYY-MM-DD. Anything else returns invalid_param rather than being guessed at. |

Errors: `outside_window`, `invalid_param`.

The full card for any date in the archive, grouped into meetings exactly as today's card is. Past dates come back with results attached where they exist; future dates come back as declarations.

This is the endpoint to reach for when backfilling. Walking dates is cheaper than searching, because a day is a natural page: one request returns everything that ran, with no cursor to manage and no risk of a page shifting under you.

#### `GET /v1/meetings/{meeting_id}`

A meeting and every race on it.

Plans: free, live, archive, complete, analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `meeting_id` | path | string | yes | Opaque meeting identifier, as returned by /v1/meetings/{date} or on any race. |

Errors: `invalid_id`, `not_found`, `outside_window`.

One meeting and every race on it: the conditions once, at the top, and then the card in time order. The shape you want when a user has clicked a track rather than a race.

Going, surface and weather belong to the meeting rather than to each race, so they are returned once here instead of repeated on every row.

#### `GET /v1/results/{date}`

Settled races for a date, grouped into meetings.

Plans: free, live, archive, complete, analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `date` | path | string | yes | YYYY-MM-DD. |

Errors: `outside_window`, `invalid_param`.

Settled races for a date, grouped into meetings. The same shape as the card, filtered to races that have a published result, so you can render the two from one code path.

A race is here once a result exists, which is not the same as the race having been run. Results arrive within minutes in Britain and Ireland and can take longer elsewhere.

Nothing here changes once it is here. A result already loaded is final, so this is safe to store and never re-fetch, unless you specifically want to pick up an upstream correction.

### Races & markets

One race, in as much detail as exists.

#### `GET /v1/races/search`

Filter by date, region, run type, going, class, field size. Cursor paginated.

Plans: free, live, archive, complete, analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `date_from` | query | string | no | YYYY-MM-DD. Clamped to the oldest date your plan can read. |
| `date_to` | query | string | no | YYYY-MM-DD. Clamped to the newest date your plan can read. |
| `region` | query | string | no | Jurisdiction, e.g. GB. Values from /v1/countries. |
| `course_id` | query | string | no | Restrict to one course. |
| `run_type` | query | string | no | Flat, Hurdle, Chase or NH Flat. Values from /v1/reference. |
| `class` | query | string | no | Race class. Published in Britain and largely absent elsewhere. |
| `going` | query | string | no | Exact match on the going as published. Values from /v1/reference. |
| `surface` | query | string | no | Turf, Dirt, Polytrack and so on. |
| `handicap` | query | boolean | no | Whether the race was run off official ratings. |
| `min_runners` | query | integer | no | Smallest field to include. |
| `max_runners` | query | integer | no | Largest field to include. |
| `result_available` | query | boolean | no | Settled races only, or unsettled only. |
| `limit` | query | integer | no | 1 to 200. Defaults to 50. |
| `cursor` | query | string | no | Opaque. Pass next_cursor from the previous response; never construct one. |

Errors: `outside_window`, `invalid_param`, `invalid_id`.

Find races by anything the race itself carries. The workhorse of the group, and the endpoint most likely to be behind whatever you are building.

Pagination is a cursor on the race date and identifier rather than an offset. Both are fixed once a race exists, so a page cannot shift under you while the archive is loading behind it, which an offset absolutely would.

Dates are clamped rather than refused. Ask a free key for 2017 to today and it will search the seven days it can read and tell you so in meta.searched, instead of returning an error or, worse, quietly returning a different range than you asked for. Only a range entirely outside your window is refused.

#### `GET /v1/races/{race_id}/result`

Finishing order, casualties, dividends and prize money.

Plans: free, live, archive, complete, analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `race_id` | path | string | yes | Opaque race identifier. |

Errors: `invalid_id`, `not_found`, `outside_window`.

The finishing order, split into those who completed and those who did not, with the winning time, the book percentage, tote dividends and prize money.

The split is deliberate. Lumping a horse that pulled up in with a horse that finished eleventh, both carrying a null position, is how you end up counting casualties as runners. Here the unplaced array carries a casualty reason where one exists, so you always know which you are looking at.

Six casualty reasons are published: PulledUp, Fell, UnseatedRider, BroughtDown, RefusedToRace and RanOut. In jumps racing these are a real signal rather than an edge case; Britain records one on about five percent of runners.

#### `GET /v1/races/{race_id}/market`

Book percentage, every price, and what moved before the off.

Plans: free, live, archive, complete, analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `race_id` | path | string | yes | Opaque race identifier. |

Errors: `invalid_id`, `not_found`, `outside_window`.

Every price in a race, what the book added up to, and which runners moved before the off. Two things here do not exist in a results feed.

Book percentage is the field's implied probabilities summed. A hundred would be a fair book; the excess is the margin taken out. Ours average about 117 in fields of two to seven and 133 in sixteen or more, and that spread is the reason backing every runner blind loses more than you would expect from the headline figure.

Price movement is the other. The source publishes no odds history on a settled race, only an opening price and any prices touched, so price_path carries a source field naming exactly which kind you have on every runner rather than leaving you to infer it from a null.

Both favourite and market_position are derived here rather than taken from the source, which leaves both empty on a settled race. Runners are ranked on decimal price, shortest first, and joint favourites share rank one.

#### `GET /v1/races/{race_id}/dividends`

Tote returns and prizes, where the jurisdiction published them.

Plans: free, live, archive, complete, analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `race_id` | path | string | yes | Opaque race identifier. |

Errors: `invalid_id`, `not_found`, `outside_window`.

Tote returns to a one unit stake, and the prize money by finishing position.

The source publishes these as strings with a currency glued on and place returns comma-joined into a single field. They are normalised here into numbers with a currency alongside and place dividends as an array, so nobody has to parse "13.0 GBP" out of a string.

An absent dividend is a fact about the race, not a gap in our data. Most jurisdictions outside Britain and Ireland publish none, and the endpoint returns a null with a note saying so rather than an error.

#### `GET /v1/races/{race_id}`

Conditions, the market, and every runner with its price and position.

Plans: free, live, archive, complete, analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `race_id` | path | string | yes | Opaque race identifier, as returned anywhere a race appears. |

Errors: `invalid_id`, `not_found`, `outside_window`.

One race with everything on it: the conditions, the market summary, and every runner with its weight, rating, form, connections, price and finishing position. If you are going to make one call about a race, make this one.

Runners come back ordered by finishing position where a result exists, with non-completers after the placed horses rather than sorted to the top by a null. Before the off they are in cloth order.

The four fields worth knowing about are here: book_percentage on the race, and price_path, casualty and beaten_distance on each runner. What each one contains depends on where the race was run, and the field table below says which.

#### `GET /v1/races/{race_id}/runners`

The field alone, without the race conditions.

Plans: free, live, archive, complete, analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `race_id` | path | string | yes | Opaque race identifier. |

Errors: `invalid_id`, `not_found`, `outside_window`.

The field alone, without the race conditions repeated around it. Identical runner objects to the ones inside a full race, so the same parsing code handles both.

It exists for the case where you already hold the conditions and only want the field refreshed, which is the shape of a non-runner check or a late market update. Fetching a whole race to see whether one horse was withdrawn is wasteful on both sides.

#### `GET /v1/races/{race_id}/analysis`

Market shape, movers, and what this course and distance usually produces.

Plans: free, live, archive, complete, analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `race_id` | path | string | yes | Opaque race identifier. |

Errors: `invalid_id`, `not_found`, `outside_window`.

One race in context: how the market was shaped, who the favourite was, which prices moved, and what this course and distance usually produces.

It is the things a reader would otherwise assemble from four calls. The market shape in particular is a summary that is tedious to compute per race: the shortest and longest prices, the median, and the spread between them, which tells you whether this was a market with a short favourite and a long tail or one where everything was six to one.

This is context for a single race, not statistics over an archive, which is why it is on every paid plan. The archive-wide work lives on the Analyst endpoints.

### Horses & pedigree

Form, breeding, and whether a horse is improving.

#### `GET /v1/horses/{horse_id}/form`

Recent runs, newest first, clipped to your window.

Plans: free, live, archive, complete, analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `horse_id` | path | string | yes | Opaque horse identifier. |
| `limit` | query | integer | no | 1 to 100. Defaults to 20. |
| `cursor` | query | string | no | Opaque. Pass next_cursor from the previous response. |

Errors: `invalid_id`, `not_found`, `outside_window`, `invalid_param`.

The form line: every run we hold, newest first, with the date, course, distance, going, class, the price it went off at and where it finished.

This is the endpoint most likely to surprise you on a narrow plan. The form is clipped to the window your plan reads, so a free key sees a week of a horse's career rather than nine years of it. meta says how many runs were withheld, so you can tell a short career from a clipped one.

Days since the previous run is computed here rather than left for you to subtract, because it is the field almost everybody wants and getting it wrong across a year boundary is a classic bug.

#### `GET /v1/horses/{horse_id}/pedigree`

Sire, dam and damsire, plus others by the same sire.

Plans: free, live, archive, complete, analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `horse_id` | path | string | yes | Opaque horse identifier. |

Errors: `invalid_id`, `not_found`.

Sire, dam and damsire, plus other horses in the archive by the same sire so you can see what a stallion actually produces rather than what he is said to produce.

Breeding records are keyed on the name rather than on a source identifier. The source's own identifiers for sire and dam were unusable: they repeated the subject horse's identifier on every record, which would have made every horse its own father. Keying on the name is imperfect where two stallions share one, and honest about being imperfect.

This pass runs behind the results crawl, so coverage is climbing. The field table below and /v1/meta/coverage both tell you where it has reached.

#### `GET /v1/horses/{horse_id}/layoff`

Performance split by days since the last run.

Plans: analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `horse_id` | path | string | yes | Opaque horse identifier. |

Errors: `invalid_id`, `not_found`, `upgrade_required`.

How this horse has run after different lengths of time off, in bands: within a week, one to four weeks, one to three months, three to six, and over six.

Each band carries the full statistics block, so you get A/E rather than a bare strike rate. That distinction matters here more than almost anywhere: a horse returning from six months off is usually priced accordingly, and a strike rate alone cannot tell you whether the market has over-corrected.

For one horse the bands will almost never be significant. Use /v1/analysis/layoff for the population answer and this endpoint for the individual pattern.

#### `GET /v1/horses/compare`

Every race these horses have both run in, and who finished ahead.

Plans: free, live, archive, complete, analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `ids` | query | string | yes | Two to five horse identifiers, comma separated. |

Errors: `invalid_id`, `invalid_param`, `outside_window`.

Every race these horses have both run in, and who finished ahead of whom. The question a form line cannot answer, because it requires joining two careers on the races they share.

Only races where both completed count towards the head-to-head record. A horse that pulled up has not been beaten by the winner in any meaningful sense, and counting it as a defeat would quietly reward the horses that stayed on their feet.

#### `GET /v1/horses/search`

By name, with career totals attached.

Plans: free, live, archive, complete, analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `q` | query | string | yes | At least two characters, matched against the name. A prefix match sorts above a match in the middle. |
| `limit` | query | integer | no | 1 to 100. Defaults to 25. |
| `cursor` | query | string | no | Opaque. Pass next_cursor from the previous response. |

Errors: `invalid_param`.

Horses by name, with career runs, wins and the span of their racing attached to each result. Those figures are not decoration: horse names repeat constantly, across jurisdictions and across generations, and the career line is how a human tells two apart.

There is no single global horse identity. A horse that raced in Ireland and then in Britain may appear once or twice depending on what the source published, and we do not merge them on a guess. Matching records across jurisdictions on a name and a foaling year produces confident, wrong answers.

#### `GET /v1/horses/{horse_id}`

Career record and breeding where it has been fetched.

Plans: free, live, archive, complete, analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `horse_id` | path | string | yes | Opaque horse identifier. |

Errors: `invalid_id`, `not_found`.

One horse: its career record, its connections, and its breeding where the pedigree pass has reached it.

The career figures are counted from the races we hold rather than taken from a source field, which means they are honest about what the archive contains rather than about the horse's whole life. A horse that raced before 2017 will show fewer runs here than it actually had, and the first and last run dates tell you the span the numbers cover.

Sire and dam arrive on a second pass behind the results crawl, because breeding lives on the horse profile rather than on a race. A null there means not yet fetched, not unknown.

#### `GET /v1/horses/{horse_id}/trend`

Each run measured against what the market expected of it.

Plans: analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `horse_id` | path | string | yes | Opaque horse identifier. |
| `window` | query | integer | no | Runs per rolling window, 3 to 20. Defaults to 6. |

Errors: `invalid_id`, `not_found`, `upgrade_required`, `invalid_param`.

Each run measured against what the market expected of it, in date order, with a rolling average so a direction is visible rather than inferred.

A finishing position on its own says almost nothing about improvement: third of four at odds-on is a worse run than sixth of twenty at 33/1. This endpoint scores each run against its own market instead, so a horse beating its price repeatedly shows as a rising line whatever the finishing positions look like.

It answers the question everybody asks of form and form cannot answer: is this horse going the right way.

#### `GET /v1/horses/{horse_id}/stats`

The standard block, sliceable seventeen ways.

Plans: analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `horse_id` | path | string | yes | Opaque horse identifier. |
| `by` | query | string | no | A breakdown dimension: course, region, run_type, distance, going, surface, class, year, month, draw, field_size, layoff, sp_band, trainer, jockey, owner or sire. Omit for the overall block. |
| `min_runs` | query | integer | no | Drop breakdown rows with fewer runs than this. Defaults to 1. |

Errors: `invalid_id`, `not_found`, `upgrade_required`, `invalid_param`.

The standard statistics block for one horse: runs, wins, places, strike rate, A/E against the market, and level stakes profit, optionally split any of seventeen ways.

A/E is the number this tier exists for. It is actual wins against the wins the market implied, where the implied figure is normalised so that a field sums to one rather than to the book percentage. Above 1.00 means winning more often than the prices said; below means less. It is the only figure here that is not distorted by having run in easier or harder races, because the market has already priced that in.

Every A/E comes back with a range and a significant flag. An A/E of five over four runs is noise and used to look like the strongest signal in the payload. The interval is a Poisson one on the win count; significant is true only where it excludes 1.00, and low_sample is true under about thirty runs. A single horse almost never reaches significance. That is not a failing of the data, it is what a career of thirty runs can support.

Void and abandoned races are excluded, because a race with no winner contributed a full unit of expected wins and no actual win, dragging every A/E below 1.00.

### Trainers

A yard, and how it has actually paid.

#### `GET /v1/trainers/search`

By name.

Plans: free, live, archive, complete, analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `q` | query | string | yes | At least two characters, matched against the name. A prefix match sorts above a match in the middle. |
| `limit` | query | integer | no | 1 to 100. Defaults to 25. |
| `cursor` | query | string | no | Opaque. Pass next_cursor from the previous response. |

Errors: `invalid_param`.

Trainers by name, with runners, wins and the span of their record attached so you can tell a busy yard from a permit holder before you open either.

Names come back as the source publishes them, initials and all. A yard can appear under more than one spelling across nine years, and we do not merge those on a fuzzy match; guessing that two spellings are the same person is how a strike rate ends up describing two different yards.

#### `GET /v1/trainers/{trainer_id}/form`

Newest first, with the price and the finish.

Plans: free, live, archive, complete, analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `trainer_id` | path | string | yes | Opaque trainer identifier. |
| `limit` | query | integer | no | 1 to 100. Defaults to 25. |
| `cursor` | query | string | no | Opaque. Pass next_cursor from the previous response. |

Errors: `invalid_id`, `not_found`, `outside_window`, `invalid_param`.

Recent runners from the yard, newest first, with the horse, the race, the price and the finishing position on every row.

This is the endpoint behind every "yard in form" panel, and it is worth being honest about what it can and cannot support. Fourteen days of results is between twenty and two hundred runners depending on the yard, and a hot streak over twenty runners is indistinguishable from chance.

Clipped to your plan's window, like every form endpoint, with meta stating how many runs were withheld.

#### `GET /v1/trainers/{trainer_id}/jockeys`

Which riders have paid for this yard.

Plans: analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `trainer_id` | path | string | yes | Opaque trainer identifier. |
| `min_runs` | query | integer | no | Drop riders with fewer rides than this. Defaults to 5. |

Errors: `invalid_id`, `not_found`, `upgrade_required`, `invalid_param`.

Which riders have actually paid for this yard, as a full statistics block per jockey rather than a bare win count.

The trainer and jockey combination is the most quoted statistic in racing and one of the least examined. A stable jockey rides the best horses in the yard, so a high strike rate is close to guaranteed and says nothing. A/E strips that out: it asks whether the pairing beat its prices, not whether it won.

The interesting rows are usually not the stable jockey. An outside rider booked occasionally, with a high A/E over enough rides, is a booking the market may not be reading correctly.

#### `GET /v1/trainers/{trainer_id}/courses`

The slice that finds where a yard actually wins.

Plans: analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `trainer_id` | path | string | yes | Opaque trainer identifier. |
| `min_runs` | query | integer | no | Drop courses with fewer runs than this. Defaults to 5. |

Errors: `invalid_id`, `not_found`, `upgrade_required`, `invalid_param`.

The yard split by course, which is the slice that most often finds something. It is the same data as by=course on the statistics endpoint, returned already sorted and with the thin rows dropped.

Course affinity is one of the few trainer patterns that survives contact with a confidence interval. Yards genuinely do travel to particular tracks with a purpose, and the market does not always fully price it.

It is also where the multiple comparisons problem bites hardest. Test one yard against sixty courses at the usual threshold and roughly three will look significant by chance alone. Treat a single standout course as a hypothesis, not a finding.

#### `GET /v1/trainers/{trainer_id}`

Runs, wins and the horses sent out.

Plans: free, live, archive, complete, analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `trainer_id` | path | string | yes | Opaque trainer identifier. |

Errors: `invalid_id`, `not_found`.

One trainer: runners, wins, places, the date span of the record, and the horses sent out, most raced first.

These are counted from the races we hold rather than taken from a source field, so they describe what is in the archive rather than a career. The first and last dates tell you the span the figures cover, which matters for anyone who was training before 2017.

A strike rate is deliberately not the headline here. It is available, but on its own it mostly measures the quality of horse a yard attracts. /v1/trainers/{id}/stats is where the useful version lives.

#### `GET /v1/trainers/{trainer_id}/stats`

Strike rate, A/E and level stakes, by any dimension.

Plans: analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `trainer_id` | path | string | yes | Opaque trainer identifier. |
| `by` | query | string | no | A breakdown dimension: course, region, run_type, distance, going, surface, class, year, month, draw, field_size, layoff, sp_band, jockey, owner or sire. Omit for the overall block. |
| `min_runs` | query | integer | no | Drop breakdown rows with fewer runs than this. Defaults to 1, but 30 is a more useful floor for anything you intend to act on. |
| `date_from` | query | string | no | YYYY-MM-DD. Clamped to your plan's window. |
| `date_to` | query | string | no | YYYY-MM-DD. Clamped to your plan's window. |

Errors: `invalid_id`, `not_found`, `upgrade_required`, `invalid_param`.

The standard statistics block for a yard, split any of sixteen ways: runs, wins, places, strike rate, A/E against the market, and level stakes profit, each with a confidence interval.

A trainer is where A/E finally has enough data to mean something. A busy yard runs thousands of horses across the archive, which is the sample size a single horse can never reach, so significance is achievable here rather than theoretical.

Read A/E rather than strike rate. A yard with a 22% strike rate and an A/E of 0.93 is winning often because it runs good horses, and losing money doing it. A yard at 9% with an A/E of 1.11 is beating its prices. Only the second of those is information the market has not already priced.

Void and abandoned races are excluded, so a wet January does not quietly penalise whoever had most entries that month.

#### `GET /v1/trainers/{trainer_id}/owners`

Split by who owns the horse.

Plans: analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `trainer_id` | path | string | yes | Opaque trainer identifier. |
| `min_runs` | query | integer | no | Drop owners with fewer runners than this. Defaults to 5. |

Errors: `invalid_id`, `not_found`, `upgrade_required`, `invalid_param`.

The yard split by who owns the horse. A proxy for something the data does not record directly: which horses in a yard are the expensive ones.

A large owner's horses in a mixed yard are usually its best, and they are often priced as such. Where they are not, the A/E shows it.

Owners are identified by name here, because the source publishes no owner identifier at all. Syndicates and partnerships appear as they are written, and the same people under two forms of a name are two owners to us. This endpoint is a shape to explore rather than a clean dimension.

### Jockeys & owners

The same shapes as trainers, deliberately.

#### `GET /v1/jockeys/{jockey_id}/form`

Newest first.

Plans: free, live, archive, complete, analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `jockey_id` | path | string | yes | Opaque jockey identifier. |
| `limit` | query | integer | no | 1 to 100. Defaults to 25. |
| `cursor` | query | string | no | Opaque. Pass next_cursor from the previous response. |

Errors: `invalid_id`, `not_found`, `outside_window`, `invalid_param`.

Recent rides, newest first, with the horse, the trainer, the race, the price and the finishing position.

The trainer on every row is the useful part. A run of winners for one yard reads very differently from the same run spread across six, and the form line shows which without a second call.

Clipped to your plan's window, with meta stating how many rides were withheld.

#### `GET /v1/jockeys/{jockey_id}/trainers`

Which yards a rider does well for.

Plans: analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `jockey_id` | path | string | yes | Opaque jockey identifier. |
| `min_runs` | query | integer | no | Drop yards with fewer rides than this. Defaults to 5. |

Errors: `invalid_id`, `not_found`, `upgrade_required`, `invalid_param`.

Which yards a rider does well for, as a full statistics block per trainer.

This is the same pairing as /v1/trainers/{id}/jockeys viewed from the other end, and the two answer different questions. From the trainer's side you are asking which rider gets the best out of these horses. From here you are asking which yards supply this rider with horses that beat their prices.

A retained rider will have one row with almost all the rides in it. The rows worth reading are the outside yards, where a booking was a choice rather than an arrangement.

#### `GET /v1/owners/{owner_id}/jockeys`

Riders used, and how they paid.

Plans: analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `owner_id` | path | string | yes | Opaque owner identifier. |
| `min_runs` | query | integer | no | Drop riders with fewer rides than this. Defaults to 5. |

Errors: `invalid_id`, `not_found`, `upgrade_required`, `invalid_param`.

The riders used in these colours, with a full statistics block each.

Ownership and riding are only loosely connected, since the trainer usually makes the booking. What this does show is which riders an operation keeps coming back to across yards, which is a preference the market rarely prices.

#### `GET /v1/owners/{owner_id}/courses`

Where the colours have done well.

Plans: analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `owner_id` | path | string | yes | Opaque owner identifier. |
| `min_runs` | query | integer | no | Drop courses with fewer runners than this. Defaults to 5. |

Errors: `invalid_id`, `not_found`, `upgrade_required`, `invalid_param`.

Where the colours have run and how they have done, per course.

Owners travel for reasons that have nothing to do with form: a local track, a favourite festival, a sponsor. Those preferences are real and visible here, and they are more about the owner than about the horses.

Treat a standout course the same way as on the trainer endpoint. Sixty courses tested at once will produce a few that look significant by chance, and owner samples are smaller, which makes it worse rather than better.

#### `GET /v1/jockeys/search`

By name.

Plans: free, live, archive, complete, analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `q` | query | string | yes | At least two characters, matched against the name. |
| `limit` | query | integer | no | 1 to 100. Defaults to 25. |
| `cursor` | query | string | no | Opaque. Pass next_cursor from the previous response. |

Errors: `invalid_param`.

Jockeys by name, with rides, wins and the span of the record attached.

Names arrive as published, which for jockeys means initials, and occasionally a claimer marked in the name itself. Two spellings of the same rider are two riders to us; merging them on a fuzzy match would silently combine two records.

#### `GET /v1/jockeys/{jockey_id}`

Rides, wins and the span of them.

Plans: free, live, archive, complete, analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `jockey_id` | path | string | yes | Opaque jockey identifier. |

Errors: `invalid_id`, `not_found`.

One rider: rides, wins, places and the span of the record, counted from the races we hold rather than taken from a source field.

The same caution as a trainer applies and rather more strongly. A jockey does not choose his horses. A retained rider at a powerful yard will show a strike rate that reflects the yard, and a freelance riding whatever he can get will show one that reflects that. Neither figure is about riding.

#### `GET /v1/jockeys/{jockey_id}/stats`

The standard block for a rider.

Plans: analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `jockey_id` | path | string | yes | Opaque jockey identifier. |
| `by` | query | string | no | A breakdown dimension: course, region, run_type, distance, going, surface, class, year, month, draw, field_size, layoff, sp_band, trainer, owner or sire. Omit for the overall block. |
| `min_runs` | query | integer | no | Drop breakdown rows with fewer rides than this. Defaults to 1; 30 is a more useful floor for anything you intend to act on. |
| `date_from` | query | string | no | YYYY-MM-DD. Clamped to your plan's window. |
| `date_to` | query | string | no | YYYY-MM-DD. Clamped to your plan's window. |

Errors: `invalid_id`, `not_found`, `upgrade_required`, `invalid_param`.

The standard statistics block for a rider, split any of sixteen ways, each row with a confidence interval.

A/E matters more for jockeys than for anyone else in this API, because a jockey's strike rate is almost entirely a function of the horses he is put on. A/E asks whether the rides beat their prices, which is the only version of the question that is about the rider at all.

Even then, be careful about what you conclude. A high A/E over a career is more likely to mean a rider is systematically underrated by the market than that he is better than everyone thinks. Those are different claims, and only the first is about prices.

The two dimensions worth reaching for first are by=course, because riders genuinely ride some tracks better, and by=run_type, because a flat jockey and a jumps jockey are different professions and a few people do both.

#### `GET /v1/owners/search`

By name. Owners are identified by name; the source publishes no id.

Plans: free, live, archive, complete, analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `q` | query | string | yes | At least two characters, matched against the name as published. |
| `limit` | query | integer | no | 1 to 100. Defaults to 25. |
| `cursor` | query | string | no | Opaque. Pass next_cursor from the previous response. |

Errors: `invalid_param`.

Owners by name, with runners, wins and the span of the record.

Owners are the one entity in this API with no source identifier at all. The source publishes a name and nothing else, so the identifier you get back is derived from that name. It is stable, it round-trips, and it is exactly as reliable as the name itself.

That has consequences worth stating plainly. A syndicate written two ways is two owners. "Mrs J Smith" and "Mrs Jane Smith" are two owners. A partnership that adds a member becomes a new owner. We do not merge any of those, because merging on a name similarity produces confident, wrong answers and there is no identifier to check them against.

#### `GET /v1/owners/{owner_id}`

Runs, wins and horses.

Plans: free, live, archive, complete, analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `owner_id` | path | string | yes | Opaque owner identifier, derived from the name. Take it from a search or from any runner. |

Errors: `invalid_id`, `not_found`.

One owner: runners, wins, places, the trainers used and the horses raced.

The trainers list is the more interesting half. An owner with horses in six yards is a different proposition from one loyal to a single trainer, and it is visible here without any statistics.

#### `GET /v1/owners/{owner_id}/stats`

The standard block for an owner.

Plans: analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `owner_id` | path | string | yes | Opaque owner identifier. |
| `by` | query | string | no | A breakdown dimension: course, region, run_type, distance, going, surface, class, year, month, draw, field_size, layoff, sp_band, trainer, jockey or sire. |
| `min_runs` | query | integer | no | Drop breakdown rows with fewer runners than this. Defaults to 1. |

Errors: `invalid_id`, `not_found`, `upgrade_required`, `invalid_param`.

The standard statistics block for an owner, split any of sixteen ways.

Owner statistics are a proxy for money. An operation that buys well and places its horses carefully will show it, and the A/E says whether the market has noticed.

Sample size is the constraint here more than anywhere else. Most owners have a handful of horses and a few dozen runs across nine years, which is not enough for any breakdown to reach significance. Only the largest operations support this endpoint properly, and low_sample will be true for most of the rest.

### Webhooks & account

Being told, instead of asking.

#### `POST /v1/webhooks/subscribe`

Point a URL at an event and we sign every delivery.

Plans: free, live, archive, complete, analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `url` | body | string | yes | Where to deliver. Must be https. We will not post to a plain http endpoint, because the payload identifies your account. |
| `events` | body | array | yes | One or more of result.received, race.abandoned, meeting.abandoned, racecard.published. |
| `region` | body | string | no | Only deliver for one jurisdiction. Most consumers want this; without it you receive twelve countries. |
| `course_id` | body | string | no | Only deliver for one course. |
| `secret` | body | string | no | Your own signing secret. If you do not supply one we generate it and return it once, in this response only. |

Errors: `invalid_param`, `forbidden`.

Point a URL at an event and we tell you when it happens, instead of you asking every thirty seconds whether it has.

Four events. result.received when a race settles, which is the one nearly everybody wants. race.abandoned and meeting.abandoned when racing is called off, which is the event that silently breaks schedulers. And racecard.published when declarations land for a future day.

Deliveries do not count against your quota. Polling for results is the single largest source of wasted requests we see, and charging you for the alternative would be a strange way to discourage it.

Every delivery carries an X-AHR-Signature header: an HMAC-SHA256 of the raw body using your secret. Verify it before trusting the payload, and compare with a constant-time function rather than a string equality. The secret is returned once at subscription and never again; rotate by subscribing afresh.

A delivery that does not return a 2xx is retried with an increasing gap for about a day, then given up on. Deliveries are at-least-once, not exactly-once, so your handler needs to tolerate seeing the same race twice. Key on the race identifier and it will.

#### `GET /v1/webhooks/deliveries`

What we sent, when, and whether it landed.

Plans: free, live, archive, complete, analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `status` | query | string | no | delivered, failed or pending. Omit for all. |
| `event` | query | string | no | Restrict to one event type. |
| `limit` | query | integer | no | 1 to 200. Defaults to 50. |
| `cursor` | query | string | no | Opaque. Pass next_cursor from the previous response. |

Errors: `invalid_param`.

What we sent, when, how long it took, what your endpoint returned and how many attempts it needed.

It exists because a webhook that fails silently is worse than no webhook. If your endpoint was down for an hour, this is where you find out which deliveries were lost, rather than discovering a gap in your data a week later and not knowing why.

The response body from your endpoint is kept, truncated, for failed deliveries. A five hundred with your own stack trace in it is usually the fastest possible answer to why the delivery failed.

#### `GET /v1/account/usage`

Requests, errors and every refusal, by endpoint.

Plans: free, live, archive, complete, analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `days` | query | integer | no | 1 to 90. Defaults to 30. |
| `by` | query | string | no | day or endpoint. Defaults to day. |

What your key has done: requests, errors and every refusal, by day or by endpoint, alongside your plan, your limits and how much of your monthly quota is gone.

The refusals are the part worth reading. Every time your key asked for something its plan could not reach, we recorded which endpoint and which reason, so this endpoint can tell you precisely what a plan change would buy you rather than making you guess. It is the same data behind the prompts on your account page.

It is also the honest answer to "am I being rate limited". The counts here are what we actually served and refused, taken from the same records that enforce the limits, so it cannot disagree with what happened.

### Courses & bias

What a track does to a race.

#### `GET /v1/courses/{course_id}/draw-bias`

Low, middle and high thirds of the field, per distance.

Plans: analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `course_id` | path | string | yes | Opaque course identifier. |
| `distance` | query | string | no | Restrict to one distance as published, e.g. 5f. Strongly recommended; omitting it averages away the effect you are looking for. |
| `min_runners` | query | integer | no | Smallest field to include. Defaults to 8, because a draw cannot matter in a field of four. |
| `going` | query | string | no | Restrict to one going. Draw effects on turf often depend on the ground. |

Errors: `invalid_id`, `not_found`, `upgrade_required`, `invalid_param`.

Whether starting position matters here. The field is split into low, middle and high thirds by stall number, and each third gets a full statistics block including A/E, so the question asked is whether a stall beat its price rather than whether it won.

Thirds rather than individual stalls, deliberately. Individual stall numbers across varying field sizes produce rows with a handful of runners each and an apparently dramatic pattern that is entirely noise. Thirds are comparable across field sizes and have enough behind them to say something.

Ask for a single distance. Draw bias is a property of a course, a distance and often a going, and averaging a five furlong sprint together with a mile and a half will reliably show no effect at a track where a real one exists.

Expect most of these to come back insignificant, and treat that as the endpoint working. Draw bias is genuine at a handful of tracks and folklore at most of the rest, and the interval is what separates the two.

#### `GET /v1/courses/{course_id}/standard-times`

Median winning time per distance, with the implausible ones counted.

Plans: analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `course_id` | path | string | yes | Opaque course identifier. |
| `run_type` | query | string | no | Restrict to one run type. |
| `going` | query | string | no | Restrict to one going. A standard time without a going is close to meaningless on turf. |

Errors: `invalid_id`, `not_found`, `upgrade_required`, `invalid_param`.

The median winning time per distance, with the tenth and ninetieth percentiles, so you can see the spread rather than just the middle.

Median rather than mean, because winning times contain obvious data errors and a mean chases them. A single race recorded at four seconds moves an average and does nothing to a median.

Every row also returns how many times were implausible and therefore excluded. That count is part of the answer: a distance where a fifth of the times were unusable is one whose standard time you should not trust, and hiding that would be the dishonest choice.

Times are only published in some jurisdictions. Britain, Ireland and South Africa carry them on most races; France, America and Germany carry almost none. The coverage page has the measured figures.

#### `GET /v1/courses/{course_id}/favourites`

How the market leader holds up here.

Plans: analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `course_id` | path | string | yes | Opaque course identifier. |
| `run_type` | query | string | no | Restrict to one run type. |
| `field_size` | query | string | no | A field size band. Favourite performance depends heavily on how many it is beating. |

Errors: `invalid_id`, `not_found`, `upgrade_required`, `invalid_param`.

How the market leader holds up at this track: strike rate, place rate, A/E and level stakes, split by field size.

The favourite is the single most useful baseline in racing, because it is the market's own best guess and every model should be measured against it rather against random selection.

Across the whole archive favourites return an A/E close to 1.00 and lose money at a rate near the book margin, which is what an efficient market looks like. A course and field size where that is not true is worth a second look, though most apparent exceptions are sample size.

Joint favourites are all counted as favourites, since they share the shortest price. This inflates the runner count slightly in the races where it happens and is the honest treatment: picking one arbitrarily would be worse.

#### `GET /v1/courses/{course_id}/casualties`

Non-completion rates by run type.

Plans: analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `course_id` | path | string | yes | Opaque course identifier. |
| `run_type` | query | string | no | Restrict to Hurdle, Chase or NH Flat. Casualties in flat racing are rare enough to be uninformative. |
| `going` | query | string | no | Restrict to one going. |

Errors: `invalid_id`, `not_found`, `upgrade_required`, `invalid_param`.

Non-completion rates here, by run type and reason: pulled up, fell, unseated, brought down, refused to race and ran out.

This is a jumps endpoint in practice. Britain records a casualty on about five percent of all runners, and almost all of that is in hurdles and chases, where completing at all is a meaningful part of the outcome.

It only works where the jurisdiction publishes a reason. Britain, Ireland, France, South Africa and Germany do; America largely does not, so an American course will return very little here. That is a limit of the source rather than of the query, and the coverage page has the measured figures.

A word on what this is not. These are non-completions, not injuries or fatalities. We do not hold that data and would not publish an inference about it from this.

#### `GET /v1/courses/{course_id}/stats`

The standard block for everything run there.

Plans: analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `course_id` | path | string | yes | Opaque course identifier. |
| `by` | query | string | no | A breakdown dimension: run_type, distance, going, surface, class, year, month, draw, field_size, layoff, sp_band, trainer, jockey, owner or sire. |
| `min_runs` | query | integer | no | Drop breakdown rows with fewer runners than this. Defaults to 1. |
| `date_from` | query | string | no | YYYY-MM-DD. Clamped to your plan's window. |
| `date_to` | query | string | no | YYYY-MM-DD. Clamped to your plan's window. |

Errors: `invalid_id`, `not_found`, `upgrade_required`, `invalid_param`.

The standard statistics block for everything run at one course, split any of fifteen ways. The base a course study starts from.

A course A/E close to 1.00 overall is expected and is not a failure of the endpoint. The market prices races at each track knowing the track, so the interesting figures are always in the breakdowns rather than in the total.

by=trainer here is the same question as by=course on a trainer, asked from the track's side. Which is often the more useful direction, because a course has far more runners than any one yard and the rows are correspondingly better supported.

#### `GET /v1/courses/{course_id}/going-record`

How the ground changes what wins.

Plans: analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `course_id` | path | string | yes | Opaque course identifier. |
| `run_type` | query | string | no | Restrict to Flat, Hurdle, Chase or NH Flat. |

Errors: `invalid_id`, `not_found`, `upgrade_required`, `invalid_param`.

How the ground changes what wins here: field sizes, favourite performance, casualty rates and the standard statistics block, per going.

Ground is the biggest single variable in British and Irish racing and one of the least consistently described. Goings are returned exactly as published, including their capitalisation, because that is what you have to match on. "Good To Soft" and "Good to Soft" may both appear in the archive, and pretending otherwise would hide a real problem.

The favourite strike rate per going is the most immediately useful row. Softer ground tends to produce more upsets, and how much more varies by track in ways the market does not always fully price.

### Market & analysis

The market itself, across the whole archive.

#### `GET /v1/market/sp-performance`

What each price band has actually returned.

Plans: analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `region` | query | string | no | Restrict to one jurisdiction. |
| `run_type` | query | string | no | Restrict to Flat, Hurdle, Chase or NH Flat. |
| `field_size` | query | string | no | A field size band. |
| `course_id` | query | string | no | Restrict to one course. |
| `date_from` | query | string | no | YYYY-MM-DD. Clamped to your plan's window. |
| `date_to` | query | string | no | YYYY-MM-DD. Clamped to your plan's window. |

Errors: `upgrade_required`, `invalid_param`.

What each price band has actually returned, across every settled race in the archive. Runners, wins, strike rate, A/E with its interval, and level stakes profit, banded from odds-on out to 100/1 and beyond.

This is the single most useful call in the API for calibrating expectations, because it answers a question every other endpoint depends on: is the market right, and where is it least right.

Our own archive says it is close to right in aggregate and clearly wrong at one end. Across all settled races, actual wins and market-implied wins agree to within a fifth of one percent, which is the market working. But the band at 16/1 and longer returns an A/E of 0.72 with an interval of 0.71 to 0.74 over roughly 495,000 runners, and loses about half of every unit staked. The curve is monotonic: the longer the price, the worse the return.

That is the favourite-longshot bias, and it is the most durable finding in racing. It is not an edge, because it tells you what loses rather than what wins. What it is good for is calibration: any model that likes long shots needs to beat a base rate that is considerably worse than the headline book margin.

#### `GET /v1/market/movers`

Whether money moving tells you anything.

Plans: analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `direction` | query | string | no | shortened or drifted. Omit for both. |
| `min_move` | query | number | no | Smallest move to include, as a ratio of the opening price. 0.25 is a quarter shorter or longer. |
| `region` | query | string | no | Restrict to one jurisdiction. |
| `run_type` | query | string | no | Restrict to one run type. |
| `date_from` | query | string | no | YYYY-MM-DD. Clamped to your plan's window. |
| `date_to` | query | string | no | YYYY-MM-DD. Clamped to your plan's window. |

Errors: `upgrade_required`, `invalid_param`.

Whether money moving before the off tells you anything, measured across every runner that carries an opening price. Movers are grouped by how far they moved and in which direction, each band with a full statistics block.

The question is old and the folklore is confident: a horse that shortens is fancied and a drifter is not. This is the endpoint that decides whether that survives a confidence interval.

Two things limit it, and both should be understood before you read a result. Price movement exists only where the source published an opening price, which in this archive means Britain on about 59% of runners and Ireland on about 52%, and essentially nowhere else. And the movement is opening to starting price, not a full series, so a horse that shortened and drifted back looks like it never moved.

Both facts are on every runner in price_path.source, and this endpoint only considers runners where a real opening price exists rather than treating an absent one as no movement.

#### `GET /v1/market/overround`

How fat the books have been, by field size and course.

Plans: analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `by` | query | string | no | field_size, course, region, run_type, class or year. Defaults to field_size. |
| `region` | query | string | no | Restrict to one jurisdiction. |
| `date_from` | query | string | no | YYYY-MM-DD. Clamped to your plan's window. |
| `date_to` | query | string | no | YYYY-MM-DD. Clamped to your plan's window. |

Errors: `upgrade_required`, `invalid_param`.

How much margin the books have taken, sliced by field size, course, region, run type, class or year. The median and the percentiles rather than a mean, so one broken race cannot move a row.

The headline pattern is strong and consistent: margin grows with the size of the field. In our archive small fields run around 117% and fields of sixteen or more around 133%. That difference is the reason a strategy that looks marginally profitable in small fields can be hopeless in big ones without anything else changing.

Races with a book below 100% or above 200% are excluded as bad data rather than reported as remarkable. There are only a few dozen of them in the whole archive, and every one is a source error rather than an arbitrage opportunity.

#### `GET /v1/analysis/angles`

Every filter combined freely, returning the standard block.

Plans: analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `by` | query | string | no | The dimension to group the answer by. Any of the seventeen. |
| `region` | query | string | no | Jurisdiction. |
| `course_id` | query | string | no | One course. |
| `run_type` | query | string | no | Flat, Hurdle, Chase or NH Flat. |
| `distance` | query | string | no | One distance as published. |
| `going` | query | string | no | One going as published. |
| `class` | query | string | no | One race class. |
| `field_size` | query | string | no | A field size band. |
| `sp_band` | query | string | no | A price band. |
| `draw` | query | string | no | low, middle or high third. |
| `layoff` | query | string | no | A days-since-last-run band. |
| `trainer_id` | query | string | no | One trainer. |
| `jockey_id` | query | string | no | One jockey. |
| `min_runs` | query | integer | no | Drop rows with fewer runners than this. Defaults to 1; 100 is sensible for anything you intend to believe. |
| `date_from` | query | string | no | YYYY-MM-DD. |
| `date_to` | query | string | no | YYYY-MM-DD. |

Errors: `upgrade_required`, `invalid_param`.

Ask your own question. Every filter in the statistics engine, combined freely, returning the standard block grouped however you like. Chase handicaps on soft ground at fourteen runners or more, grouped by draw. Whatever you can describe.

This is the endpoint the Analyst tier is really for. The rest are convenient shapes for common questions; this one is the engine underneath them with the filters exposed.

It is also the easiest way in the whole API to fool yourself, and that is worth saying plainly rather than burying. Combining seven filters until something looks significant is not analysis, it is searching for noise, and with a million and a half runner records you will always find some. Every response therefore carries the runner count and the interval, and rows under about a hundred runners are flagged low_sample.

The honest way to use it is to decide the question before you run it, and to treat anything you find by exploring as a hypothesis to test on a period you did not look at.

#### `GET /v1/analysis/layoff`

Performance by days since the last run, across everything.

Plans: analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `by` | query | string | no | A second dimension to split the layoff bands by, e.g. run_type or class. |
| `region` | query | string | no | Jurisdiction. |
| `run_type` | query | string | no | One run type. |
| `min_runs` | query | integer | no | Drop rows with fewer runners than this. |
| `date_from` | query | string | no | YYYY-MM-DD. |
| `date_to` | query | string | no | YYYY-MM-DD. |

Errors: `upgrade_required`, `invalid_param`.

Performance by days since the last run, across the whole archive rather than for one horse. The population answer that makes the individual one readable.

Days off is one of the few variables that is knowable in advance, applies to every runner, and is not obviously priced. Whether the market handles it well is exactly the kind of question worth a large sample.

Read it alongside /v1/horses/{horse_id}/layoff. This endpoint says what a three month break usually means; that one says what it has meant for this horse. Neither is much use without the other.

#### `GET /v1/analysis/precedents`

Races that ran under matching conditions.

Plans: analyst.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `race_id` | query | string | no | Take the conditions from this race. Simpler than describing them by hand. |
| `course_id` | query | string | no | One course. |
| `distance` | query | string | no | One distance. |
| `going` | query | string | no | One going. |
| `run_type` | query | string | no | One run type. |
| `class` | query | string | no | One class. |
| `field_size` | query | string | no | A field size band. |
| `limit` | query | integer | no | 1 to 200. Defaults to 50. |

Errors: `upgrade_required`, `invalid_param`, `invalid_id`.

Races from the archive that ran under matching conditions, with what they produced: the winning prices, the favourite record, the field sizes and the times.

Pass a race_id and it takes the conditions from that race, which is far easier than describing them yourself and removes the risk of describing them slightly wrong.

The question it answers is the one a form book cannot: what usually happens in races like this one. Not what will happen, and not who will win, but the distribution of outcomes this shape of race has produced before.

## Guides

### Getting started

*From nothing to a working request in about two minutes.*

### Two minutes, honestly

There is no application form, no sales call and no card. Sign in with Google, take the key
that appears, and make a request. If the request works, everything else in this API will work
the same way, because there is only one authentication scheme and one response shape.

#### 1. Get a key

[Sign in with Google](/auth/google). Your key appears on your account page
immediately, on the free plan. It reads real races on a delayed window: nothing newer than 24
hours, nothing older than seven days. **It is the same database a paying customer reads,
not a sandbox.**

#### 2. Make a request

Every request needs your key in the `X-API-Key` header. That is the whole of
authentication.

```
curl https://api.apihorseracing.com/v1/meta/coverage \
 -H "X-API-Key: $AHR_KEY"
```

Start with `/v1/meta/coverage` rather than something more interesting. It needs
no parameters, it works on every plan, and it tells you what exists and what your own key can
reach in one response. If it returns, your integration is sound.

#### 3. Read the envelope

Every response has the same two keys. `meta` carries the request id, the
freshness stamp, your plan and your window. `data` carries the answer.

```
{
 "meta": {
 "request_id": "979954038cb35c98",
 "data_as_of": "2026-09-09T08:50:32+00:00",
 "plan": "free",
 "window": "24 hours to 7 days"
 },
 "data": { ... }
}
```

Quote `request_id` if you ever need to ask us about a response. It lets us find
your exact request rather than guess at a reproduction.

#### 4. Fetch a real race

Take a date inside your window and ask for the card, then follow one of the race
identifiers.

```
curl "https://api.apihorseracing.com/v1/racecards/2026-09-05" \
 -H "X-API-Key: $AHR_KEY"

curl "https://api.apihorseracing.com/v1/races/rc_1WNM9X2" \
 -H "X-API-Key: $AHR_KEY"
```

### The three things to read next

In this order, because each saves you a category of mistake.

 | Guide | What it saves you from |

 
 | [Coverage by country](/documentation/coverage) 
 | Building against a field that does not exist in the jurisdiction you care about.
 Nine times in ten a null is the source, not us. |

 | [Errors](/documentation/errors) 
 | Treating a plan refusal as a network failure and retrying it forever. |

 | [Timezones and dates](/documentation/timezones) 
 | Assuming a race time is UTC. It is not; it is local to the course, and everything
 else is UTC. |

 

### What to do when something is empty

An empty array and a refusal mean very different things here, and we never blur them.

 - **An empty array** means we looked and there was nothing. No racing that
 day, no runs in that window, no dividends published. It is an answer.

 - **A 403** means your plan could not look. The body names the window or the
 plans that reach the endpoint. It is not an answer and retrying will not change it.

 - **A null field** means the jurisdiction did not publish it. Data coverage says which fields exist where, counted rather than
 claimed.

### Before you go to production

 - Read `meta.data_as_of` and show it, or at least log it. Racing data has a
 freshness and pretending otherwise is how a stale result gets displayed as live.

 - Handle `429` by respecting `Retry-After` rather than backing off
 blindly. `X-RateLimit-Remaining` is on every successful response too.

 - If you are polling for results, stop, and [take a
 webhook](/documentation/webhooks) instead. It is faster and costs nothing against your quota.

 - Cache. There is no requirement to re-fetch anything, and a settled race never
 changes.

### Authentication

*One key, two ways to send it, and what a refusal looks like.*

### One key

One account, one key, and it never needs replacing. Your entitlement is read from your plan
on every single request, so upgrading widens what the key you already hold can reach. There is
nothing to reissue and nothing to re-integrate.

That is a deliberate choice. Most APIs make you rotate keys to change plan, which means a
plan change is a deployment. Here it is a database read.

### Two ways to send it

#### The header, which you should use

```
curl https://api.apihorseracing.com/v1/countries \
 -H "X-API-Key: ahr_..."
```

#### The query string, which exists for tools that cannot set headers

```
curl "https://api.apihorseracing.com/v1/countries?key=ahr_..."
```

The second one is convenient for a spreadsheet, a browser tab or a quick look. It is also
worse everywhere else: **a key in a URL ends up in server logs, browser history, referer
headers and anything the link is pasted into.** Use the header unless you cannot.

### What a refusal looks like

Three different things can go wrong with a key, and they are three different responses on
purpose.

 | Status | Code | Means |

 
 | 401 | `unauthorized` 
 | No key, or one we do not recognise. Check the header name and that the key has not
 been rotated. |

 | 403 | `upgrade_required` 
 | The key is valid; the plan does not reach this endpoint. The body names every plan
 that does. |

 | 403 | `outside_window` 
 | The key is valid and the endpoint is allowed; the date is outside what your plan
 reads. The body names the window and the date you asked for. |

 

None of those is transient. Retrying will produce the same answer, and a client that
retries a 403 on a backoff is just burning its own quota.

```
{
 "error": {
 "code": "upgrade_required",
 "message": "Your plan does not reach this endpoint.",
 "your_plan": "complete",
 "reaches_on": ["analyst"],
 "doc_url": "https://apihorseracing.com/documentation/trainers-stats"
 }
}
```

### Keeping it safe

 - **Never put it in client-side code.** Anything shipped to a browser is
 public. Proxy through your own server.

 - **Never commit it.** Environment variable, secrets manager, anything but a
 repository.

 - **Do not share it across organisations.** Usage counts against your quota
 whoever made the request.

 - **If it leaks, rotate it** from your account page. The new key works
 immediately and the old one stops immediately, so plan the swap before you press it.

We will never ask you for your key. If you are reporting a problem, send the
`request_id` instead: it identifies the request without identifying the
credential.

### Your first four calls

*What is running, one race, yesterday read back, and a real question.*

Four requests that between them exercise everything this API does. Run them in order on a
free key and you will have seen the envelope, the window, the identifiers and the honesty about
coverage, which is most of what there is to learn.

### 1. What exists, and what you can reach

```
GET /v1/meta/coverage
```

No parameters, works on every plan. It returns the size of the archive, the split between
races with a full finishing order and those with placings only, the list of things that do not
exist here at all, and your own plan and window.

**Read `never_published` before you build anything.** Sectional
times and bookmaker odds are not in this dataset and never will be. Better to learn that in
your first request than your fiftieth.

### 2. What is running

```
GET /v1/racecards/2026-09-05
```

Pick a date inside your window. On a free key that means between one and seven days ago; on
a paid plan, anything.

Races come back grouped into meetings, because that is how a card is read. Note that
`race_time` is **local to the course** while everything else in the
response is UTC. That is deliberate and it is the single most common integration mistake in
racing data.

Take a `race_id` from the response. It looks like `rc_1WNM9X2`. The
prefix tells you what kind of thing it is, which means passing a horse identifier where a race
is expected gets you `invalid_id` rather than the wrong race.

### 3. One race in full

```
GET /v1/races/rc_1WNM9X2
```

Conditions, the market and every runner. Four fields here are worth finding immediately,
because they are the ones a results feed does not have:

 | Field | Why it matters |

 
 | `book_percentage` 
 | What the field's implied probabilities summed to. 100 would be a fair book; the
 excess is the margin taken out. |

 | `price_path` 
 | The opening price and any prices touched, with a `source` field saying
 which kind of history you have. |

 | `casualty` 
 | Why a runner did not finish. Distinguishes a horse that pulled up from one whose
 jurisdiction publishes only placings. |

 | `official_rating` 
 | The handicap mark, where the jurisdiction publishes one. |

 

Several of those will be null depending on where the race was run. That is not a gap in the
fetch. [Data coverage](/data-coverage) has the measured figures per country.

### 4. A real question

```
GET /v1/analysis/angles?run_type=Chase&going=Soft&by=field_size&min_runs=100
```

This one needs Analyst, and it is the call worth understanding even if you never make it,
because it is what the tier is for. Every filter combined freely, returning strike rate, A/E
against the market, level stakes and a confidence interval, grouped however you ask.

Read the interval, not the headline. A row with an A/E of 1.30 and a range spanning 1.00 is
not a finding, and the `significant` flag says so without you having to work it
out.

### Where to go from here

 - Building a site or an app: [Racecards and meetings](/documentation#cards),
 then [webhooks](/documentation/webhooks) so you stop polling.

 - Backfilling for research: [race search](/documentation/races-search) with a
 cursor, or walk dates with [the card endpoint](/documentation/racecards), which is
 simpler.

 - Testing an idea: [angles](/documentation/analysis-angles), and read
 [Understanding A/E](/documentation/understanding-ae) first.

### Coverage by country

*Which jurisdictions publish what, counted rather than claimed.*

The most common support question about any racing feed is "why is this field null", and
nine times in ten the answer is that the jurisdiction never published it. This guide is here so
you can find that out before you write the code rather than after.

### Two different questions

People say "coverage" to mean two things that need separating.

 - **Do you have the races?** How many races we hold from a country, and over
 what span.

 - **Do the races carry the field?** Whether the jurisdiction publishes
 official ratings, or class, or a winning time, at all.

A country can be strong on the first and useless on the second. France is a good example:
tens of thousands of races, and official ratings on about four percent of runners.

### The split that matters most

A full finishing order means every runner carries a position. Placings only means the first
four home are published and nothing below them, so most of the field comes back with
`position: null`.

Just over half of our archive is the second kind, almost entirely because of America. That
is not a gap in our data; it is what was released, and no provider has what was never
published.

If your work counts placings, counts beaten runners, or computes anything per-runner from a
finishing position, **you cannot use American racing for it**. Better to know now.

### Which fields exist where

Measured from the loaded archive at the last data load, as a share of that country's
runners. These move as the backfill advances; [the coverage page](/data-coverage)
always has the current figures and `/v1/meta/coverage` returns them to your code.

 | Field | GB | IRE | FR | USA |

 
 | Finishing position | 82% | 81% | 76% | 45% |

 | Starting price | 96% | 98% | 100% | 100% |

 | Official rating | 76% | 63% | 4% | 77% |

 | Race class | 100% | 6% | 1% | — |

 | Winning time | 94% | 96% | — | — |

 | Opening price | 59% | 52% | — | — |

 | Tote dividends | 96% | 97% | 98% | 98% |

 

Three of those are worth calling out because they contradict what people assume.

 - **Price movement is British and Irish, and substantial there.** Not the
 handful of runners we originally described. It is essentially absent everywhere else, so any
 model using it works on two countries.

 - **America publishes ratings and tote dividends**, on most races. It is the
 finishing order it does not publish.

 - **Race class is a British idea.** Ireland carries it on six percent of
 races and France on one. A filter on class is a filter on Britain.

### Checking it from code

```
GET /v1/meta/coverage # totals, the split, and your own window
GET /v1/countries # per jurisdiction, with the field flags
```

Read `/v1/countries` once at startup and refuse to run on a region you cannot
support, rather than producing quiet nonsense. A `position_pct` below about 0.6
means most runners have no finishing position.

### Things that do not exist at all

 - **Sectional and in-running times.** None, anywhere, at any point in the
 archive. If your model needs fractions, this is the wrong feed and no plan changes that.

 - **Bookmaker odds comparison.** Starting price, an opening price where one
 was published, and the tote. We are not a price aggregator.

 - **Predictions, tips or ratings of our own.** We supply data and take no view
 on any race.

### The archive is still filling

The backfill runs from January 2017 forward and has not reached the present yet. Every
figure grows daily until it does, and nothing already loaded changes. `data_as_of`
on every response and [the status page](/status) both tell you where it has got
to.

### What a race carries

*Forty four fields, in the order they become known.*

A race accumulates information in stages, and knowing which stage a field arrives at
explains most of the nulls you will see. Nothing here is missing; it either has not happened
yet or was never published.

### When it is declared

Known days before the race. The shape of the contest.

 - `course`, `date`, `race_time`, `distance`,
 `run_type`, `surface`

 - `class`, `handicap`, `age_band`, `prize`

 - Per runner: `horse`, `trainer`, `jockey`,
 `owner`, `weight`, `draw`, `headgear`,
 `official_rating`, `form`

`draw` is null in jumps racing **by design**. There is no stall, so
there is no draw, and returning a zero would be worse than a null.

### On the morning

 - `going` and `weather`, which belong to the meeting rather than the
 race and are returned once

 - Non-runners removed from the field

 - `price_path.opening`, where the jurisdiction published an opening price

### At the off

 - `sp` and `sp_decimal` on every runner

 - `book_percentage` on the race, being those prices summed

 - `favourite` and `market_position`, both **derived by us**
 from the prices, because the source leaves them empty on a settled race

 - `price_path.touched` and `price_path.drift`

### Once it has run

 - `position` on each runner, or null where the jurisdiction publishes only
 placings

 - `casualty` where a runner did not complete: PulledUp, Fell, UnseatedRider,
 BroughtDown, RefusedToRace or RanOut

 - `winning_time`, `dividends`, `prizes`

 - `result_available` flips true

### Later still

Two things arrive on a separate pass behind the results crawl, because they live on the
horse profile rather than on the race.

 - `sire`, `dam` and `damsire`

 - `beaten_distance` per runner

A null in either means the pass has not reached that horse yet, not that the value is
unknown. Coverage climbs daily.

### How to tell three kinds of null apart

This is the whole point of the guide.

 | You see | It means | How to confirm |

 
 | `position` null, `casualty` set 
 | The horse did not complete 
 | The casualty reason is the confirmation |

 | `position` null, `casualty` null, race settled 
 | The jurisdiction published placings only 
 | `/v1/countries` position_pct for that region |

 | `position` null, `result_available` false 
 | Not run or not settled yet 
 | The race status |

 | `official_rating` null 
 | Not published there, or outside 0–200 and dropped as a sentinel 
 | [Coverage](/data-coverage) |

 | `price_path.opening` null 
 | No opening price published 
 | `price_path.source` says which kind you have |

 | `sire` null 
 | Pedigree pass has not reached this horse 
 | Re-fetch later; it will fill in |

 

### Fields we derive

Stated so you know what is ours rather than the source's.

 - `sp_decimal`, computed once at load so you are not parsing fractions per
 row

 - `implied`, one over the decimal price

 - `book_percentage`, those summed across the field

 - `favourite` and `market_position`, ranked on price with joint
 favourites sharing rank one

 - `days_since_previous` on a form line

 - `expected` on the statistics endpoints, which is `implied`
 normalised so a field sums to one rather than to the book percentage

### Identifiers

*Why ids look the way they do, and why they are safe to store.*

Every identifier in this API looks like `rc_1WNM9X2`: a two letter prefix, an
underscore, then a short string of letters and digits. They are safe to store, safe to log and
safe to put in a URL.

### The prefixes

 | Prefix | Thing | Example |

 
 | `rc_` | Race | `rc_1WNM9X2` |

 | `mt_` | Meeting | `mt_QBVRV2` |

 | `hs_` | Horse | `hs_1G0HAZH` |

 | `tr_` | Trainer | `tr_3EDFD3A` |

 | `jk_` | Jockey | `jk_P95NF5` |

 | `ow_` | Owner | `ow_K17MTE` |

 | `cr_` | Course | `cr_JSRC8Z` |

 

### Why the prefix earns its place

It is not decoration. Passing a horse identifier where a race is expected returns
`422 invalid_id` rather than `404`, and rather than the wrong race. Two
bare integers would have silently resolved to different things, and that class of bug is
expensive to find because nothing errors.

```
GET /v1/races/hs_1G0HAZH

{
 "error": {
 "code": "invalid_id",
 "message": "That is a horse identifier. This endpoint expects a race."
 }
}
```

### They are opaque, and that is deliberate

The identifier is not a database row number. It is a reversible transform of one, so it
carries no ordering, no volume information and nothing you can iterate through. Two races
created a second apart have identifiers that look nothing alike.

That protects both sides. It stops anyone enumerating the archive by counting upward, and it
stops the identifier accidentally revealing how much data we hold or when a record was
created.

### They are stable

**An identifier for a given thing never changes.** It is derived from the
source's own record, not from a sequence, so it survives a reload, a rebuild of the serving
database and a restore from backup.

Store them. Use them as foreign keys. Cache against them. The only thing that would change
them is a deliberate versioned migration, which would be a new API version and announced on
[the changelog](/documentation/changelog).

### What not to do

 - **Do not construct one.** They come from responses. There is no valid way to
 build one and a guess will not resolve.

 - **Do not parse one.** The characters after the prefix carry no meaning you
 can use.

 - **Do not compare across types.** A race and a horse can never share an
 identifier, so equality between different prefixes is always false and never interesting.

 - **Do not lowercase them.** The alphabet is case-sensitive.

### The one that is different

Owner identifiers are derived from the owner's *name*, because the source publishes
no owner identifier at all. They are stable and they round-trip, and they are exactly as
reliable as the name.

Which means a syndicate written two ways is two owners, and "Mrs J Smith" and "Mrs Jane
Smith" are two owners. We do not merge those, because there is no identifier to check a merge
against and guessing produces confident, wrong answers. If your analysis needs them combined,
only you can decide that they are the same.

### Data freshness

*What data_as_of means and when the archive moves.*

Racing data has a freshness, and pretending otherwise is how a stale result gets shown as
live. Every response tells you how fresh it is; this explains what the number means.

### data_as_of

In `meta` on every single response. It is the moment the serving database was
last written, not the moment you asked.

```
"meta": {
 "data_as_of": "2026-09-09T08:50:32+00:00",
 "plan": "complete",
 "window": "2017 → upcoming"
}
```

Two responses in the same second carry the same `data_as_of`. If it stops
advancing during a racing afternoon, something upstream has stopped, and that is worth alerting
on. [The status page](/status) reads the same value.

### How often it moves

The sync runs every ten minutes. In practice a result appears within minutes of settling in
Britain and Ireland, and can take longer elsewhere, because the delay is mostly the
jurisdiction publishing rather than us fetching.

Declarations for future days arrive when they are published, which is 48 hours out for most
British racing and later elsewhere.

### What never changes

**A settled race is final.** Once a result is in the archive it does not move.
You can store it and never re-fetch it, and the only reason to re-read a past date is to pick
up an upstream correction, which is rare.

What does change is what has not happened yet: declarations before the off, going on the
morning, and non-runners.

### The archive is still loading

The backfill runs from January 2017 forward and has not caught up to the present. This is
the single most important thing to understand about freshness right now.

 - Race counts and date spans grow daily.

 - Today's card may be empty on any plan until the crawl reaches the present.

 - Nothing already loaded changes as it catches up.

`/v1/meta/coverage` returns the newest date held. Check it before concluding a
date is missing; the difference between "not loaded yet" and "no racing that day" matters and
that endpoint is how you tell.

### Caching, sensibly

 | What | How long | Why |

 
 | Courses, countries, reference values | Indefinitely 
 | A few hundred rows that effectively never change |

 | A settled race or result | Indefinitely 
 | Final once published |

 | A past date's card | Indefinitely 
 | Same reason |

 | Today's card | A few minutes 
 | Non-runners and going change |

 | Statistics | Hours 
 | Computed over the archive; one day's racing barely moves them |

 | Coverage totals | An hour 
 | They only move when the sync runs |

 

### Stop polling for results

A process checking every thirty seconds is about ninety thousand requests a month, and it
still finds out later than a webhook would. [Subscribe to
result.received](/documentation/webhooks): deliveries arrive as soon as the race settles and **cost nothing
against your quota**.

If you cannot take a webhook, poll `/v1/results/latest` with a small limit rather
than re-fetching whole dates.

### The envelope

*meta and data, and what is always in meta.*

Every response from every endpoint has the same two keys. Once you have written the code
that reads one, you have written the code that reads all sixty.

```
{
 "meta": { ... },
 "data": { ... }
}
```

### What is always in meta

 | Field | What it is |

 
 | `request_id` 
 | Identifies this exact request. Also sent as the `X-Request-Id` header.
 Quote it when reporting anything and we can find the request rather than guess at a
 reproduction. |

 | `data_as_of` 
 | When the serving database was last written, not when you asked. If it stops
 advancing during a racing afternoon, something upstream has stopped. |

 | `plan` 
 | The plan this request was served under. Read from your account at request time,
 which is why an upgrade takes effect without a new key. |

 | `window` 
 | The date range that plan can read, in words. |

 

### What appears when it applies

 | Field | On | What it is |

 
 | `count` | Anything returning a list 
 | Items in `data`, so you are not calling length on something that might
 be an object. |

 | `next_cursor` | Paginated endpoints 
 | Pass it back to get the next page. Absent on the last one. |

 | `has_more` | Paginated endpoints 
 | The thing to loop on. Do not infer it from the item count. |

 | `searched` | Search endpoints 
 | The date range actually used after clamping to your window.
 **Read this rather than assuming your parameters were honoured verbatim.** |

 | `withheld` | Form endpoints 
 | How many runs your window hid. It is how you tell a short career from a clipped
 one. |

 

### data is an object or an array, never both

A single thing returns an object. A list returns an array. It does not depend on how many
results there happen to be, so a search returning one row still returns an array of one and
your code does not need a special case.

```
# object
GET /v1/races/rc_1WNM9X2 → data: { race_id: ..., runners: [...] }

# array
GET /v1/races/search → data: [ { ... }, { ... } ]
```

### Errors replace data, they do not join it

A failed response has `error` and no `data`. There is never a response
carrying both, so a truthy check on `data` is a safe test for success.

```
{
 "error": {
 "code": "outside_window",
 "message": "That date is outside the 24h – 7d your plan reads.",
 "doc_url": "https://apihorseracing.com/documentation/errors#outside_window",
 "request_id": "17d0af45d676676d",
 "your_plan": "free",
 "window": "24h – 7d",
 "requested": "2021-02-28"
 }
}
```

Branch on `error.code`, never on the message. Codes are part of the contract;
messages are wording and may be improved. Full list in [Errors](/documentation/errors).

### Version one only ever gains fields

We will not remove or rename a documented field that is in use. New fields will appear, so
**parse permissively**: ignore what you do not recognise rather than rejecting the
response. A field can become null where a source stops publishing it, and
[the changelog](/documentation/changelog) will say so.

### Pagination

*Cursors, not offsets, and why that matters mid-sync.*

Every list endpoint that can return more than a couple of hundred rows is paginated with a
cursor. There are no page numbers and no offsets, and that is a deliberate choice rather than a
stylistic one.

### How it works

```
GET /v1/races/search?region=GB&limit=100

{
 "meta": { "count": 100, "has_more": true, "next_cursor": "eyJkIjoiMjAxOS0xMi0xMCIsImkiOjQ0MTJ9" },
 "data": [ ... ]
}

GET /v1/races/search?region=GB&limit=100&cursor=eyJkIjoiMjAxOS0xMi0xMCIsImkiOjQ0MTJ9
```

Keep every other parameter identical and add the cursor. Loop while `has_more` is
true. That is the whole protocol.

### Why not offsets

An offset means "skip the first N rows of the current result set", and the current result set
is changing underneath you. The archive is still backfilling, and races settle throughout the
day.

Walk 160,000 races at 200 an offset at a time and rows inserted between your requests shift
everything down. You will read some races twice and skip others, and nothing will tell you it
happened.

A cursor is a position in the data rather than a count of rows skipped. Ours encodes the race
date and identifier, both fixed once a race exists. **A page cannot shift under you**,
which is the only property that matters when you are backfilling nine years.

### Rules

 - **Do not construct a cursor.** It comes from a response. Anything else
 returns `invalid_param`.

 - **Do not parse one.** The encoding is ours to change.

 - **Do not change filters mid-walk.** A cursor belongs to the query that
 produced it. Changing the region halfway through gives you nonsense.

 - **Loop on `has_more`**, not on whether you got a full page. A
 page can be short and still not be the last.

### A complete walk

```
<?php
$cursor = null;
$all = [];

do {
 $url = 'https://api.apihorseracing.com/v1/races/search?region=GB&limit=200'
 . ($cursor ? '&cursor=' . rawurlencode($cursor) : '');

 $ch = curl_init($url);
 curl_setopt_array($ch, [
 CURLOPT_RETURNTRANSFER => true,
 CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('AHR_KEY')],
 ]);

 $res = json_decode(curl_exec($ch), true);
 curl_close($ch);

 if (isset($res['error'])) {
 break; // see the errors guide before retrying
 }

 $all = array_merge($all, $res['data']);
 $cursor = $res['meta']['next_cursor'] ?? null;

 usleep(400000); // stay inside your per-minute rate

} while (!empty($res['meta']['has_more']));
```

### Resuming later

Cursors do not expire, so you can store the last one and continue tomorrow. Because it is a
position rather than an offset, you will pick up exactly where you stopped even though races
have been added since.

### When not to paginate at all

If you are backfilling whole days, [walk dates](/documentation/racecards) instead.
A day is a natural page: one request returns everything that ran, with no cursor to manage.
Nine years is about three thousand five hundred requests, which fits inside a month on any paid
plan.

Use search with a cursor when you want a *subset* across many dates, which is the
thing walking dates cannot do efficiently.

### Errors

*Every code, what causes it, and what to do about it.*

Every failure returns JSON with an `error` object and no `data`. Branch
on `error.code`, never on the message: codes are part of the contract, messages are
wording and may be improved.

```
{
 "error": {
 "code": "outside_window",
 "message": "That date is outside the 24h – 7d your plan reads.",
 "doc_url": "https://apihorseracing.com/documentation/errors#outside_window",
 "request_id": "17d0af45d676676d",
 "your_plan": "free",
 "window": "24h – 7d",
 "requested": "2021-02-28"
 }
}
```

### The one distinction that matters

Before the table: **most of these are not transient.** A client that retries
everything on a backoff will burn its own quota against a wall. Only 429 and 500 are worth
retrying at all.

### Every code

 | Code | HTTP | Cause | Do |

 
 | `unauthorized` | 401 
 | No key, or one we do not recognise. 
 | Check the header name is `X-API-Key` and that the key has not been
 rotated from your account page. Never retry. |

 | `upgrade_required` | 403 
 | Valid key, but the plan does not reach this endpoint. Always a statistics
 endpoint. 
 | The body carries `your_plan` and `reaches_on`. Show the user
 which plan would work. Never retry. |

 | `outside_window` | 403 
 | The date is outside what your plan reads. 
 | The body names `window` and `requested`. Clamp your own
 request or upgrade. Never retry. |

 | `forbidden` | 403 
 | The account is disabled or the key revoked. 
 | Contact support. This is never a rate limit. |

 | `not_found` | 404 
 | The identifier is well formed but nothing has it, or a race exists with no result
 yet. 
 | Treat as an answer, not a failure. Never retry. |

 | `invalid_id` | 422 
 | The identifier is malformed, or its prefix is wrong for this path. 
 | You have probably passed a horse id where a race is wanted. See
 [Identifiers](/documentation/identifiers). |

 | `invalid_param` | 422 
 | A parameter failed validation. The message names it. 
 | A date that is not YYYY-MM-DD, a `by=` dimension we do not have, or a
 cursor you constructed. |

 | `rate_limited` | 429 
 | Past your per-minute rate. 
 | **Retry after `Retry-After` seconds.** Slow down rather
 than parallelising harder. |

 | `quota_exceeded` | 429 
 | Past your monthly allowance. 
 | Resets on the first. Retrying before then is pointless. Check
 `/v1/account/usage`. |

 | `not_yet_live` | 503 
 | Registered and documented, but not serving yet. 
 | Watch [status](/status). |

 | `server_error` | 500 
 | Ours, not yours. 
 | Retry once with a backoff. If it persists, send us the
 `request_id`. |

 

### Handling it properly

```
switch ($err['code'] ?? '') {

 case 'rate_limited':
 sleep((int) ($headers['retry-after'] ?? 5));
 // retry the same request
 break;

 case 'server_error':
 // retry once, then give up and log request_id
 break;

 case 'outside_window':
 case 'upgrade_required':
 // a plan decision, not a failure. Surface it, do not retry.
 break;

 case 'not_found':
 // an answer. Store the absence.
 break;

 default:
 // your bug. Log it with request_id and stop.
}
```

### The three headers worth reading

 - `X-RateLimit-Remaining` on every successful response. Slow down before you hit
 zero rather than after.

 - `Retry-After` on a 429. Respect it rather than backing off blindly.

 - `X-Request-Id` on everything. Log it. It costs nothing and it is the
 difference between us finding your problem in a minute and not at all.

### What is never an error

An empty array. If we looked and there was nothing, that is a 200 with
`data: []`: no racing that day, no runs in your window, no dividends published.
Treating an empty result as a failure and retrying it is the most common mistake we see.

### Timezones and dates

*Everything is UTC except a race time, which is local to the course.*

One rule, and one exception to it. The exception is the single most common integration
mistake in racing data, so it is worth thirty seconds now.

### The rule

**Every timestamp is UTC, in ISO 8601, with an explicit offset.**

```
"data_as_of": "2026-09-09T08:50:32+00:00"
"published_at": "2026-09-08T19:04:11+00:00"
"delivered_at": "2026-09-09T14:22:07+00:00"
```

### The exception

**`race_time` is local to the course, and carries no offset.**

```
"race_time": "14:35"
```

A 14:35 at Musselburgh and a 14:35 at Meydan are both the time on the racecourse clock. That
is what a racecard prints, what a commentator says and what your users expect to see. Converting
it to UTC would mean showing a British user 13:35 for a race everyone else calls the 2.35, which
is wrong in the way that matters.

So do not parse `race_time` as a timestamp, and do not apply an offset to it. It
is a label, and it belongs next to the course name that gives it meaning.

### Dates are plain

`date` on a race or a meeting is `YYYY-MM-DD` with no time and no zone.
It is the raceday as the jurisdiction defines it. An evening meeting that finishes after
midnight local time still belongs to the day it started.

Every date parameter takes the same format. Anything else returns `invalid_param`
rather than being guessed at, because guessing between 03/04 and 04/03 is how a backfill
silently loads the wrong year.

### Why this combination

Racing spans twelve jurisdictions across most of the world's time zones. Two things follow.

 - **Anything we generate is UTC**, so ordering, comparison and storage all work
 without knowing where anything happened.

 - **Anything the sport prints stays as printed**, because a racecard time is a
 cultural object rather than an instant. Reformatting it helps nobody.

### If you need a real instant for a race

You need three things: the date, the local time, and the course's zone. We give you the first
two and the course. We deliberately do not give you the third, because getting it right requires
per-course zone data including historical daylight saving rules, and a wrong answer is worse than
no answer.

If your product genuinely needs it, map course to zone yourself from a maintained database
and combine. Most products do not: they show the local time beside the course, which is what the
sport does.

### Practical notes

 - **Sort by `date` then `race_time` as strings.** Both
 formats sort lexicographically, so it works without parsing.

 - **Do not compare `race_time` across courses** to decide which race
 is next. Two tracks in different countries at the same clock time are hours apart.

 - **Your window is evaluated in UTC.** Around midnight UTC a free key's
 seven-day boundary moves, which can make a date readable one minute and not the next. That is
 the window doing its job.

### Prices and price movement

*Fractional, decimal, opening, touched, and what source tells you.*

Prices are the most useful thing in this archive and the easiest to misread. Four fields, one
derived summary, and one coverage limit worth knowing before you build on any of it.

### The four fields on a runner

 | Field | Example | What it is |

 
 | `sp` | `"10/3"` 
 | Starting price, fractional, exactly as published. |

 | `sp_decimal` | `4.3333` 
 | The same price as a decimal. Computed once at load so you are not parsing fractions
 per row. |

 | `implied` | `0.2308` 
 | One over the decimal price. |

 | `market_position` | `1` 
 | Rank by price, one being shortest. Ties share a rank. |

 

### implied does not sum to one

This is the thing people get wrong. Add `implied` across a field and you get the
book percentage, not 1.0.

```
0.2308 + 0.1667 + 0.1250 + ... = 1.2185 # a 121.85% book
```

The excess is the margin. It is real and it is the reason backing everything loses money.

If you want probabilities that sum to one, the statistics endpoints already have them:
`expected` is `implied` normalised per race. That is what A/E is computed
against, and it is why A/E of 1.00 means "as often as the market said" rather than "as often as
the book implied".

### book_percentage

On the race, from `/v1/races/{race_id}/market`. A hundred would be a fair book.

In our archive it averages about 117 in fields of two to seven and about 133 in sixteen or
more. **That spread matters more than the average.** A strategy that looks
marginally profitable in small fields can be hopeless in big ones with nothing else changing.
[Overround](/documentation/market-overround) has it sliced by course, class and year.

### Price movement, and its limit

`price_path` carries the opening price, any prices touched, the drift, and a
`source` field naming which kind of history you have.

```
"price_path": {
 "opening": "5/1",
 "opening_decimal": 6,
 "touched": ["4/1"],
 "sp": "10/3",
 "drift": -0.2778,
 "direction": "shortened",
 "source": "movements"
}
```

 | `source` | Means |

 
 | `movements` | An opening price and any touched prices. The common
 case where movement exists. |

 | `full_history` | A complete price series. Rare. |

 | `starting_price_only` | No movement was published. The starting
 price is all there is. |

 

**Read `source` rather than inferring from a null opening price.**
The two are different claims: one says nothing was published, the other says we do not know.

The limit: **price movement is British and Irish.** Britain carries an opening
price on about 59% of runners and Ireland on about 52%. France, America, South Africa and
Germany carry essentially none. Any model using movement works on two countries, and it is
better to design for that than discover it.

And it is opening to starting price, not a series. A horse that shortened and drifted back
looks like it never moved.

### favourite is ours, not the source's

The source leaves both `favourite` and `market_position` empty on a
settled race, so we derive them from the prices: ranked on decimal price, shortest first.
**Joint favourites are all flagged** and share rank one, which slightly inflates
favourite counts in the races where it happens. Picking one arbitrarily would be worse.

### Tote is a separate thing

`/v1/races/{race_id}/dividends` returns tote returns to a one unit stake,
normalised into numbers with a currency alongside. Tote and starting price settle differently
and the gap is occasionally large. Most jurisdictions outside Britain and Ireland publish no
tote return, and the endpoint says so rather than erroring.

### What we do not have

No bookmaker odds comparison, no exchange prices, no in-running. Starting price, an opening
price where one was published, and the tote. We are not a price aggregator and will not
become one.

### Understanding A/E

*The number the Analyst tier exists for, and how to read its interval.*

A/E is the number the Analyst tier exists for. It is also the number most likely to be
misread, so this guide is about how to read it honestly rather than how to be impressed by it.

### What it is

Actual wins divided by expected wins. Expected comes from the market: each runner's implied
probability, normalised so the field sums to one.

```
implied = 1 / sp_decimal # sums to the book percentage
expected = implied / sum(implied for the race) # sums to exactly 1

A/E = sum(actual wins) / sum(expected)
```

Above 1.00 means winning more often than the prices said. Below means less. **1.00 means
the market had it right**, which is the correct null hypothesis for almost everything in
racing.

### Why not strike rate

Strike rate measures the horses, not the skill. A yard that runs good horses in weak races
will have a high one and may still be losing money at every price it takes.

A/E already accounts for the class of opposition, the field size and the ground, because the
market priced all of them before the race. That is the whole point: **you are not
comparing against random selection, you are comparing against the best estimate anybody had at
the time.**

 | Yard | Strike rate | A/E | Reading |

 
 | A | 22% | 0.93 | Wins often because it runs good horses. Losing
 money doing it. |

 | B | 9% | 1.11 | Beats its prices. This is the one the market has
 not fully priced. |

 

### Never read it without the interval

Every A/E comes back with three companions.

```
"a_e": 1.11,
"a_e_range": [0.98, 1.26],
"significant": false,
"low_sample": false
```

 - `a_e_range` is a confidence interval on the win count. It uses a Poisson
 standard error, `sqrt(wins) / expected`, which is the right shape for counting
 rare events.

 - `significant` is true only when that interval **excludes 1.00**.
 In the example above it does not, so the 1.11 is not evidence of anything.

 - `low_sample` is true under about thirty runs, where the interval is so wide it
 is barely worth printing.

An A/E of 5.00 over four runs looks like the strongest signal in the payload and is noise. The
interval is what stops you acting on it.

### What sample sizes actually support it

 | Subject | Typical runs | Can it reach significance? |

 
 | One horse | 10–40 | **Almost never.** Read it as
 description, not evidence. |

 | One horse, one condition | 2–8 | No. |

 | A busy trainer | 2,000–10,000 | Yes, and its breakdowns often
 can too. |

 | A course | 5,000+ | Yes. |

 | A price band across the archive | 100,000+ | Comfortably. |

 

### The check that says the engine is sound

Across every settled race in the archive, actual wins and expected wins agree to within about
a fifth of one percent: roughly 149,400 actual against 149,100 expected, an overall A/E of
1.0018.

That number is not a finding, it is a **calibration test**. If the market is
efficient in aggregate, the whole population must come out at 1.00, and any engine that does not
produce that has a bug. Ours does, which is why the departures from 1.00 in the breakdowns are
worth taking seriously.

### The one large departure

Runners at 16/1 and longer return an A/E of 0.72, interval 0.71 to 0.74, over roughly 495,000
runners. Significant by an enormous margin, and the curve is monotonic: the longer the price, the
worse the return.

That is the favourite-longshot bias, the most durable finding in racing. **It is not an
edge**, because it tells you what loses rather than what wins. What it is good for is
calibration: a model that likes long shots has to beat a base rate far worse than the book
margin.

### What A/E cannot do

 - **It cannot tell you a horse will win.** It describes a population, and any
 single race is one draw from it.

 - **It does not survive selection.** Testing fifty things and reporting the
 best one is not an A/E of 1.4, it is the maximum of fifty noisy numbers. See
 [Breakdown dimensions](/documentation/breakdowns).

 - **It says nothing about your prices.** It is computed at starting price. If
 you take a shorter price than SP, your realised A/E is worse than the one printed.

### Void races are excluded

A race with no recorded winner contributed a full unit of expected and no actual win, dragging
every A/E below 1.00 and quietly penalising whoever ran in more of them. Those races are excluded
from statistics. Pass `include_unsettled=1` to see them anyway.

### Level stakes and return

*Why backing everything loses more than the book margin.*

Level stakes is the plainest number in the statistics block and the one most likely to be
quoted out of context. It is a measure of the prices, not a strategy.

### What it is

One unit staked to win on every qualifying runner, settled at the returned starting price.

```
"runs": 1420,
"wins": 188,
"level_stakes": -142.60, # units
"roi": -0.1004 # -10.04%
```

No commission, no place terms, no staking plan, no ability to take a price other than SP.
Those omissions are deliberate: every one of them is a decision you would make, and baking a
guess at your decisions into our number would make it uncomparable between users.

### Why almost everything loses

Because the book is not fair. Add the implied probabilities across a field and you get more
than one; the excess is the margin, and backing everything pays it every time.

In our archive that margin averages about **117% in fields of two to seven and 133% in
sixteen or more**. So a blind strategy in large fields starts roughly a quarter behind
before anything else happens.

This is why a level stakes figure near zero is remarkable and one at minus five percent is
respectable. Comparing a strategy against zero is comparing it against something nobody can
achieve.

### The two numbers together

A/E and level stakes answer different questions and disagree often.

 | A/E | Level stakes | What happened |

 
 | 1.05 | -8% | Beat the market slightly, but not by enough to cover the
 margin. Common, and not a failure. |

 | 0.95 | +12% | Won less often than priced, but the winners were long
 ones. Usually a handful of results carrying everything. Check the win count. |

 | 1.20 | +18% | Genuinely beat the prices, if the interval excludes 1.00
 and the sample is large. |

 | 0.72 | -51% | The 16/1 and longer band across the whole archive. Both
 numbers agree and the sample is half a million runners. |

 

**A/E is the better signal, level stakes is the better sanity check.** A high
level stakes with a low A/E means variance rather than skill, and the win count usually tells you
that at a glance.

### Where it is fragile

 - **Long prices dominate it.** One 100/1 winner moves a hundred-runner sample by
 a full unit per run. A/E is affected far less.

 - **It is a total, not a rate.** Compare `roi` between samples, never
 `level_stakes`.

 - **It assumes you get SP.** Anyone taking early prices does better; anyone
 taking a shorter price does worse.

### Reading it honestly

 - Check `runs`. Under a few hundred, this number is mostly noise.

 - Check `wins`. Three winners producing a positive return is three results, not a
 finding.

 - Check `a_e_range`. If it spans 1.00, the level stakes figure is spanning zero
 too whatever it says.

 - Compare against the right baseline. Not zero, but what the same population of prices
 returns, which is what [price performance](/documentation/market-sp-performance)
 gives you.

### What it is genuinely good for

Ruling things out. A filter with a large sample and a level stakes at minus twenty percent is
not going to become profitable with better staking, and knowing that in one request is worth
more than most of what a positive number would have told you.

### Breakdown dimensions

*Seventeen ways to slice the same block.*

Every statistics endpoint takes `by=` and returns the same block split that way.
One shape, seventeen dimensions, and one very easy way to fool yourself.

### The dimensions

 | Dimension | Splits by | Notes |

 
 | `course` | Racecourse | The slice most likely to find
 something real. |

 | `region` | Jurisdiction | Coverage differs sharply; see
 [Coverage](/documentation/coverage). |

 | `run_type` | Flat, Hurdle, Chase, NH Flat | Effectively
 different sports. |

 | `distance` | Race distance as published | Use with
 `course` for draw work. |

 | `going` | Ground as published | Exact strings, capitalisation
 included. |

 | `surface` | Turf, Dirt, Polytrack… | |

 | `class` | Race class | British in practice. |

 | `year`, `month` | Time | Margins move; a nine-year
 backtest averages several markets. |

 | `draw` | Low, middle, high third | Flat only. |

 | `field_size` | Runner-count bands | Changes the margin more
 than anything else. |

 | `layoff` | Days since last run | Knowable in advance. |

 | `sp_band` | Price band | Where the strongest population effect
 lives. |

 | `trainer`, `jockey`, `owner`, `sire` 
 | Connections | Not all are valid on every subject. |

 

A dimension that is fixed for the subject is rejected with `invalid_param` rather
than silently ignored: `by=trainer` on a trainer, `by=course` on a course.
Being told is better than getting one row back and wondering.

### min_runs is not optional

It defaults to 1, which is useful for exploring and dangerous for concluding. **Set it
to 30 for anything you intend to believe, and 100 for anything you intend to act on.**

Without it, `by=course` on a trainer returns sixty rows, most with a handful of
runners, and the top of that list sorted by A/E is a list of the smallest samples.

### The trap

This is the important part of the guide.

Test one trainer against sixty courses at the usual threshold and **about three will
look significant by chance alone**. Nothing is wrong with the data or the interval. That
is what a 5% error rate means when you run sixty tests.

The angles endpoint makes it worse, because it lets you combine seven filters freely. With a
million and a half runner records, you will always find something that looks remarkable.
Combining filters until a number pleases you is not analysis, it is searching for noise.

### How to use it honestly

 - **Decide the question before you run it.** A hypothesis formed before seeing
 the data is worth more than a pattern found in it.

 - **Set min_runs first**, not after seeing which rows you like.

 - **Treat anything found by exploring as a hypothesis**, never a finding.

 - **Test it out of sample.** Find it with `date_to` a few years back,
 then rerun on the period after. This is the single most valuable thing this API can do for you
 and it costs two requests.

 - **Expect most things to disappear.** That is the process working. A tool that
 confirms every idea is worthless.

### Out of sample, in two calls

```
# find it
GET /v1/analysis/angles?run_type=Chase&going=Soft&by=field_size
 &min_runs=100&date_to=2023-12-31

# then test it on data you did not look at
GET /v1/analysis/angles?run_type=Chase&going=Soft&by=field_size
 &min_runs=100&date_from=2024-01-01
```

If the interval still excludes 1.00 on the second, you have something. If it does not, you had
noise, and you found that out for the price of one request rather than a season.

### Rows are not independent

Splitting by course and by distance produces overlapping populations: the same races appear in
both. Adding significance across dimensions is not meaningful, and two significant rows that
describe the same underlying races are one finding, not two.

### Rate limits and quotas

*Per minute, per month, and the headers that tell you.*

Two limits, both per key, both visible on every response. Neither is a surprise if you read
the headers.

### The limits

 | Plan | Per minute | Per month |

 
 | Free key | 60 | 15,000 |

 | Live | 180 | 250,000 |

 | Archive | 90 | 150,000 |

 | Complete | 180 | 400,000 |

 | Analyst | 240 | 1,000,000 |

 

Archive's rate is lower than Live's on purpose. It is research at research pace: nine years of
history and no live window, priced for somebody walking the archive rather than serving a page.

### The headers

```
X-RateLimit-Limit: 180
X-RateLimit-Remaining: 174
X-RateLimit-Reset: 1757423460
Retry-After: 12 # only on a 429
```

**Read `X-RateLimit-Remaining` on successful responses and slow down before
you hit zero**, rather than sprinting into a 429 and reacting. It is on every response and
costs nothing to check.

### The two 429s are different

 | Code | Means | Do |

 
 | `rate_limited` | Too fast this minute. 
 | Sleep for `Retry-After` and continue. Genuinely transient. |

 | `quota_exceeded` | Monthly allowance gone. 
 | **Stop.** It resets on the first. Retrying achieves nothing. |

 

Branch on the code rather than the status. A client that retries both on a backoff spends the
rest of the month asking a question it already has the answer to.

### Pacing, rather than backing off

A fixed gap between requests is simpler and better behaved than a retry loop.

```
$perMinute = 180;
$gap = (int) (60_000_000 / $perMinute * 1.1); // 10% headroom

foreach ($dates as $date) {
 fetchDay($date);
 usleep($gap);
}
```

Ten percent of headroom costs you almost nothing and means clock drift between your server and
ours never trips the limit.

### Watching the month

`GET /v1/account/usage` returns requests, errors and every refusal, by day or by
endpoint, and **does not count against your own quota**. Call it daily and warn
yourself at whatever threshold you are comfortable with.

`by=endpoint` is also the fastest way to find a wasteful loop. A single endpoint
dominating a month is usually a poller that should be a webhook.

### Things that do not cost you a request

 - **Webhook deliveries.** Zero, always. Polling is the largest source of wasted
 requests we see and charging for the alternative would be perverse.

 - **Caching.** A settled race never changes. Re-fetching one is a request you
 chose to spend.

 - **4xx responses.** They count toward the rate but we would rather you fixed
 the cause than tuned around it.

### If a limit is genuinely wrong for you

Tell us what you are building. A rate that blocks a legitimate use is a badly chosen number
rather than a policy, and it is easier to change than you might expect.

### Race reports

*A written account of every race, and the one line per runner behind it.*

Every race in the archive from 2020 onwards carries a written report: a headline, how the
race looked beforehand, what happened, and **one line for every runner**.

They are drawn entirely from the records this API already returns. Nothing in a report comes
from anywhere the data does not go.

### What a report contains

 | Field | What it is |

 
 | `headline` | Eight words or fewer. Factual, not a headline in the
 tabloid sense. |

 | `preview` | How the race looked an hour before it ran, using only
 what was knowable then. It never mentions the result. |

 | `report` | What happened, leading with whatever made this race
 different from the others on the card. |

 | `runners` | One sentence per horse, keyed by name. Each stands on
 its own without the report above it. |

 

The per-runner lines are the part most people build with. They are what sits beside a horse
on a results page.

### What they will never say

No report describes how a race was run. There is no phrase about a horse travelling well,
staying on, being outpaced or finding nothing, and none about where a faller fell.

**That is deliberate, and it is a data limitation rather than a stylistic one.**
This archive holds no in-running positions, no sectional times, no video and no rider comment.
A report that described any of those would be inventing them, and a plausible invention is worse
than an omission because you cannot tell it apart from a fact.

Winning margins are absent for the same reason: `beaten_distance` is not published
across this archive, so no report claims one.

What they do instead is tell you what the market thought and whether it was right. Every
starting price implies a probability, and with the book margin stripped out that probability is
a fair one. A report will tell you the winner was a 9.7% chance, or that the favourite was given
48.7% and finished fourth of five. That is checkable, and it is the part worth reading.

### Getting them

Three ways, depending on what you are building.

**With a race you are already fetching.** Add `include=report` to
[the race endpoint](/documentation/races) and the report arrives in the same
response. It is opt-in because it roughly doubles the size of the payload, and most callers want
the data rather than the prose.

```
GET /v1/races/{race_id}?include=report
```

**For a whole day.** The same parameter on
[results by date](/documentation/results) attaches a report to every race on every
card. Every report for the day is fetched in one query, so the cost does not grow with the
number of meetings.

```
GET /v1/results/2024-03-16?include=report
```

**On their own.** [Search reports](/documentation/reports-search) by
date, course or jurisdiction when the text is what you are after rather than the race data.

```
GET /v1/reports?from=2024-03-01&to=2024-03-31&course=Cheltenham
```

### Coverage is partial

Reports begin in 2020 and cover Britain and Ireland. They do not exist for earlier races, and
they do not exist for other jurisdictions, because the market movement that carries most of
their substance is not published outside those two.

[Report coverage](/documentation/meta-reports) returns the counts, the date range
and the split by year, and it works on a free key. Whether the reports are worth paying for
depends on how many there are and which years they cover, and that is not a question you should
have to pay to answer.

A race with no report returns `null` rather than an error. A gap in partial
coverage is not a fault.

### Which plan

Complete and Complete + Analyst. The written pages on this site are public and free to read;
the API delivery is not, because an endpoint hands over a bulk feed of the same text in a way a
page does not.

### OpenAPI spec

*Generate a client, or import the whole API into your tooling.*

The whole API as a machine-readable document, at
[`/openapi.json`](/openapi.json). Generate a client in your language,
import it into an API tool, or point a code generator at it.

**It is generated from the same registry the documentation and the router share**,
so it cannot describe an endpoint that does not exist or miss one that does. When we add an
endpoint, the spec has it the same minute.

### What is in it

 - Every endpoint, with its parameters, their types and which are required.

 - **Response schemas inferred from real captured responses**, not written by
 hand, so a schema and its example cannot disagree.

 - A real example response per endpoint, harvested from the live API.

 - Every error the endpoint can return, with its code and when it fires.

 - The rate limit headers, so a generated client can read them.

### Plan restrictions are in the spec

This is the part most specs get wrong. A generated client is built from this file, so an
endpoint most keys cannot call has to say so, or the first a developer hears about it is a
`403` in production on a method their own library offered them.

Statistics endpoints carry `[Analyst]` in the summary, a note in the description,
and an `x-plans` extension listing exactly which plans reach them.

### Using it

```
curl -O https://apihorseracing.com/openapi.json

# generate a client
npx @openapitools/openapi-generator-cli generate \
 -i openapi.json -g python -o ./racing-client
```

OpenAPI 3.1. Import it into Insomnia, Bruno, Stoplight or anything else that reads a spec.
If you would rather have a collection you can click through,
[the Postman collection](/documentation/postman) is the same surface in that
format.

### One thing to know

The examples are real responses, which means they carry real dates. An example showing a race
from last month is not stale documentation; it is what the endpoint actually returned when the
sample was captured. The `meta.data_as_of` in each one tells you when.

### Postman collection

*Import it, set one variable, and every request is ready to send.*

The whole API as a Postman collection, at
[`/postman-collection.json`](/postman-collection.json). Import it, set one
variable, and every request is ready to send.

Generated from the same registry as the documentation and
[the OpenAPI spec](/documentation/openapi), so all three describe the same surface
and cannot drift apart.

### Getting started

 - In Postman: **Import**, then paste
 `https://apihorseracing.com/postman-collection.json`.

 - Open the collection's **Variables** tab and set `apiKey` to your
 key.

 - Send anything.

The key is wired as collection-level authentication, so it applies to every request without
being pasted into each one. `baseUrl` is already set to production.

### How it is organised

One folder per section, matching the documentation. Every request carries its own description,
its parameters with explanations, and **a saved example response** taken from the
live API, so you can see the shape before you send anything.

Required parameters ship enabled with the example URL filled in. Optional ones are present but
unticked, so you can see what exists without sending a blank value, which is a different request
from leaving the parameter off.

### What the labels mean

Requests that need the Complete + Analyst plan are prefixed `[Analyst]` in the
name. Everything else works on any paid plan, and most of it works on a free key inside its
delayed window.

They are labelled in text rather than with an icon on purpose: a label nobody can search for
in a diff or quote in a support thread is a poor label.

### It also works elsewhere

The file is standard collection format v2.1, so Insomnia and Bruno both import it. If your
tool prefers a spec, use [the OpenAPI file](/documentation/openapi) instead.

### Using it from an assistant

*Connect Claude, ChatGPT or any MCP client and ask in plain English.*

Point Claude, ChatGPT or any Model Context Protocol client at the archive and ask questions
in plain English. No code, no client library, and **the same key you already have**.

It is the fastest way to find out whether this data answers your question, because you can
ask it before writing anything. [The MCP server page](/mcp-server) has the setup
block and the full tool list.

### What it is

MCP is a standard way for an assistant to call an external service. You add one address to
your client's configuration, and from then on the model can reach our tools whenever a question
needs them. It decides which tool to call; you just ask.

The server runs here rather than on your machine. Nothing is installed and nothing is
running locally.

### Connecting

```
"mcpServers": {
 "horseracing": {
 "url": "https://apihorseracing.com/mcp-server",
 "headers": {
 "Authorization": "Bearer ahr_your_key_here"
 }
 }
}
```

Restart the client and ask it something about a race. If your client wants an API key header
rather than a bearer token, send `X-API-Key` instead; both are accepted, because
clients differ and being fussy about which one reads to a user as broken.

### Fourteen tools, not sixty

We publish sixty endpoints and expose fourteen tools. That is deliberate rather than
unfinished.

**A model given forty near-identical options picks the first plausible one**
rather than the right one, and every tool costs context on every turn of every conversation. A
short list it uses well beats a complete one it uses badly.

Four tools work on a free key: `describe_coverage`, `search_racing`,
`get_race` and `get_results`. Eight more need any paid plan, and the two
statistics tools need Complete + Analyst.

### Identifiers come from search

Every tool that names a horse, trainer, jockey or course takes an identifier rather than a
name, and identifiers are opaque. The model is told not to guess them, so a question about a
named horse becomes two calls: `search_racing` to get the identifier, then the tool
that uses it.

You do not have to do anything about this. It is worth knowing because it explains why a
simple question sometimes shows two tool calls.

### Your plan applies exactly as it does elsewhere

Tool calls are forwarded to the same public API with your own key. The window your plan
reads, its rate limit, its monthly quota and its scope all apply here in exactly the same way,
because there is only one set of rules and this is not a second copy of them.

Refusals are reworded for someone reading them inside an assistant rather than in a
response body:

 | What happened | What the assistant is told |

 
 | `rate_limited` | That it is a pace limit rather than an outage,
 it clears within the minute, and to prefer one broad call over several narrow
 ones. |

 | `quota_exceeded` | That retrying will not help until the month
 resets, and where to see the reset date. |

 | `upgrade_required` | Which plan the tool needs, and that an
 upgrade widens the existing key immediately with nothing to reissue. |

 | `outside_window` | The exact window the key reads, and that today
 and older history both fall outside a free one. |

 

The rate limit note matters more here than anywhere else. **An assistant can fire five
tool calls answering a question where a person would make one**, so a limit you would
never trip by hand is reachable in a single conversation.

### What comes back

The whole envelope, not just the data. That means the model can see `data_as_of`
and the plan it is reading on, so it says "as of this morning, on a free key" rather than
implying a figure is live.

### What it will not do

There are no tips, no selections and no predictions in this data, and the server says so to
the model before it sees a single tool. Every figure it can reach describes races that have
already happened.

It is also a conversation rather than a download. For bulk work, use the API directly and
read [pulling a lot of data](/documentation/bulk).

### Questions worth asking it

 - Is there a draw bias at Chester over five furlongs?

 - How has this trainer done at Ascot on soft ground?

 - Have these two horses ever met?

 - What won at Newbury yesterday?

 - Does backing the favourite pay at Southwell?

 - What does this API actually hold?

The last one is worth trying first. It calls `describe_coverage` and tells you in
one answer what is here and what your key reaches.

### Webhooks

*Four events, signed deliveries, and no quota cost.*

Instead of asking every thirty seconds whether a race has settled, be told when it does.
Deliveries arrive sooner than a poll would find them and **cost nothing against your
quota**.

The arithmetic is stark. A process polling every thirty seconds is about ninety thousand
requests a month. A webhook is none, and it still tells you first.

### The four events

 | Event | Fires when |

 
 | `result.received` | A race settles. The one nearly everybody
 wants. |

 | `race.abandoned` | A race is called off. The event that stops a
 scheduler waiting forever for a result that will never come. |

 | `meeting.abandoned` | A whole card is lost. |

 | `racecard.published` | Declarations land for a future day. |

 

### Subscribing

```
POST /v1/webhooks/subscribe
X-API-Key: ahr_...
Content-Type: application/json

{
 "url": "https://yours.example/hooks/racing",
 "events": ["result.received", "race.abandoned"],
 "region": "GB"
}
```

**Set `region` unless you want twelve jurisdictions.** Most consumers
care about one or two, and filtering at our end is free while filtering at yours is not.

The response returns a signing secret **once and never again**. Store it before
you close the tab. Rotating means subscribing afresh.

### Verifying a delivery

Every delivery carries `X-AHR-Signature`: an HMAC-SHA256 of the raw request body
using your secret.

```
$raw = file_get_contents('php://input');
$mine = hash_hmac('sha256', $raw, getenv('AHR_WEBHOOK_SECRET'));

if (!hash_equals($mine, $_SERVER['HTTP_X_AHR_SIGNATURE'] ?? '')) {
 http_response_code(401);
 exit;
}

$event = json_decode($raw, true);
```

Two details that matter. Sign the **raw body**, not a re-encoded version of the
parsed JSON, because key order will differ. And compare with `hash_equals` rather than
`===`, so the comparison is constant time.

### Delivery is at-least-once

Not exactly-once. **Your handler must tolerate seeing the same race twice.** Key
on the race identifier and it will.

A delivery that does not return a 2xx is retried with a widening gap for about a day, then
given up on. Return 200 as soon as you have the payload and do the work afterwards; a handler
that does five seconds of processing before answering will time out and be retried, and you will
process the same race repeatedly.

### When it goes wrong

`GET /v1/webhooks/deliveries?status=failed` shows what we sent, when, how many
attempts, what your endpoint returned and the first part of your own response body. A five
hundred with your stack trace in it is usually the fastest answer to why it failed.

**We do not replay.** After an outage, take the race identifiers from the failed
deliveries and fetch them directly. Deliveries are kept for thirty days.

### Requirements

 - **https only.** We will not post to plain http; the payload identifies your
 account.

 - **Answer quickly.** Under a couple of seconds. Queue the work.

 - **Be idempotent.** See above.

 - **The subscription belongs to the account**, not the key, so rotating your key
 does not stop deliveries.

### Bulk and backfilling

*How to walk nine years without being refused.*

Loading nine years without being refused, and without being clever about it. This is a
smaller job than most people expect.

### Choose the right shape first

 | You want | Use | Why |

 
 | Everything, day by day | [Walk dates](/documentation/racecards) 
 | A day is a natural page. One request returns everything that ran, no cursor to
 manage, no page to shift. |

 | A subset across many dates 
 | [Search with a cursor](/documentation/races-search) 
 | One region or one run type, without fetching the rest. |

 | One entity's whole history | The form endpoints, with a cursor 
 | Already ordered, already clipped to your window. |

 

### The arithmetic

January 2017 to today is about **3,500 days**. One request each is 3,500
requests, which fits inside a single month on any paid plan with room to spare, and at 180 a
minute it takes about twenty minutes of wall time.

People assume a backfill is a big operation. It is one afternoon and a loop.

### A backfill that behaves

```
$gap = (int) (60_000_000 / 180 * 1.1); // 10% headroom under the rate
$date = new DateTimeImmutable('2017-01-01');
$today = new DateTimeImmutable('today');

while ($date <= $today) {
 $day = $date->format('Y-m-d');

 if (!alreadyStored($day)) { // resumable: skip what you have
 $res = fetchDay($day); // GET /v1/racecards/{date}

 if (isset($res['error'])) {
 if ($res['error']['code'] === 'rate_limited') {
 sleep(5);
 continue; // same day again
 }
 if ($res['error']['code'] === 'quota_exceeded') {
 break; // stop. It resets on the first.
 }
 if ($res['error']['code'] === 'outside_window') {
 break; // your plan cannot reach further back
 }
 }

 store($day, $res['data']);
 }

 $date = $date->modify('+1 day');
 usleep($gap);
}
```

### Rules that save you a second run

 - **Make it resumable.** Record each day as you store it. A backfill that cannot
 be stopped and restarted will be stopped and restarted anyway, at the worst moment.

 - **Store the raw response.** Disk is cheaper than requests. If your schema
 changes you can rebuild locally instead of fetching again.

 - **Do not parallelise.** Eight workers against a 180 a minute limit produce
 429s and no extra throughput. One process with a fixed gap is faster in practice.

 - **Stop on `quota_exceeded`.** Continuing wastes the rest of the
 month on a question already answered.

 - **An empty day is a real answer.** No racing on Christmas Day is not a
 failure; record it and move on rather than retrying.

### The archive is still loading

The crawl runs from 2017 forward and has not reached the present. If your backfill catches up
with it, later dates simply return nothing yet.

Check `/v1/meta/coverage` for the newest date held rather than concluding a date is
missing, and re-run the tail of the range in a week. **Nothing already loaded
changes**, so re-running only fills gaps.

### Afterwards

Once you are current, stop polling. [Subscribe to
result.received](/documentation/webhooks) and you will be told as races settle, at no cost against your quota. A
backfill followed by a webhook is the whole integration for most people.

## Errors

Every error is `{"error": {"code": "...", "message": "...", "doc_url": "..."}}` with an HTTP status to match. The `code` is stable and worth switching on; the `message` is written for a person and may change.

See https://apihorseracing.com/documentation/errors for the full list.

---

Generated 2026-09-22T04:46:30+00:00. The current version is always at https://apihorseracing.com/documentation/manual.md
