GuidesPagination

Pagination

Two styles, for two genuinely different jobs. Which one an endpoint uses is not arbitrary.

Cursors, for lists that change under you

GET /v1/bookings, GET /v1/rentals/locations, GET /v1/webhooks and GET /v1/webhooks/{id}/deliveries all page by cursor:

curl -sS -G "https://{your-base-url}/v1/bookings" \
  -H "Authorization: Bearer $TOKEN" \
  --data-urlencode "limit=50"
{
  "data": [{ "reference": "DC-8F2K91", "...": "..." }],
  "has_more": true,
  "next_cursor": "eyJ2IjoxLCJjcmVhdGVkX2F0IjoiMjAyNi0xMS0wMVQwOToxMjo0NC4wMDBaIiwiaWQiOjkxfQ"
}

Take next_cursor and send it back verbatim:

curl -sS -G "https://{your-base-url}/v1/bookings" \
  -H "Authorization: Bearer $TOKEN" \
  --data-urlencode "limit=50" \
  --data-urlencode "cursor=eyJ2IjoxLCJjcmVhdGVkX2F0IjoiMjAyNi0xMS0wMVQwOToxMjo0NC4wMDBaIiwiaWQiOjkxfQ"

Keep going while has_more is true. When it is false, next_cursor is null and you have the whole set.

limit runs 1–100 and defaults to 25 on GET /v1/bookings, GET /v1/rentals/locations and GET /v1/webhooks/{id}/deliveries. Send it explicitly on GET /v1/webhooks, which declares no default. Carry your filters on every page — the cursor positions you in the list, it does not remember what you were filtering on.

GET /v1/rentals/locations names its array rows rather than data. That is deliberate: partners were already reading body.rows before cursors were added, so has_more and next_cursor were added alongside the existing key rather than renaming it out from under them.

Why a cursor rather than an offset

An offset skips a fixed number of rows. If a new booking is created while you are paging — which, for a live integration, is exactly what is happening — every subsequent offset shifts by one, and a row that was on page 2 quietly moves to page 3 without you ever seeing it.

A cursor is anchored to the last row’s own position, so the next page is defined as “strictly after this row”. Nothing inserted concurrently can move a row across a boundary you have already crossed. For a booking list you are polling for reconciliation, that is the difference between complete data and a silent gap.

Cursor handling

  • Treat it as opaque. It is base64url and it does decode, but its contents are an implementation detail and the format carries a version that may change.
  • Do not construct or edit one. A cursor we did not issue is 400 invalid_cursor.
  • Do not store one for later. Cursors are for walking a list now, not for bookmarking a position across days. To resume from a point in time, use created_after and created_before on GET /v1/bookings.
  • A borrowed cursor cannot cross accounts. Ownership is filtered before the cursor is applied, so a cursor from another partner can only reposition you within your own rows — it can never reveal theirs.

GET /v1/rentals/availability uses page and limit instead:

curl -sS -G "https://{your-base-url}/v1/rentals/availability" \
  -H "Authorization: Bearer $TOKEN" \
  --data-urlencode "location=casablanca" \
  --data-urlencode "date_from=2026-11-02" \
  --data-urlencode "date_to=2026-11-06" \
  --data-urlencode "page=2" \
  --data-urlencode "limit=24"
{ "vehicles": [], "total": 37, "page": 2, "limit": 24, "...": "..." }

page starts at 1 and defaults to 1; limit runs 1–50 and defaults to 12. total is the count of matching vehicles, not a page count — divide by limit for that.

Page numbers are right here because search results back a UI where someone clicks “page 3”, and because a search is a snapshot of a moment rather than a ledger you must not miss rows from. The stability a cursor buys is worth nothing against a result set you are about to re-run anyway.

Do not confuse total with the total on a vehicle. The top-level one is a result count; the one on each vehicle is the dated price of the stay. Same word, different things, and only one of them is money.

Not paginated at all

GET /v1/coverage and GET /v1/rentals/countries return complete sets in one response. Country lists are small and bounded. Fetch and cache them.