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_afterandcreated_beforeonGET /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.
Page numbers, for search
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.