APIHorseRacing

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.