Documentation / Guides
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 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.