Pagination
Every list endpoint is cursor-paged, newest first.
{
"data": [ … ],
"has_more": true,
"next_cursor": "MjAyNi0wOC0xMlQwOTo0MTowNy4yMjFafDhmMmI"
}The loop
Pass the next_cursor from one response as ?cursor= on the next, and stop
when has_more is false.
const SAILO = "https://api.sailo.store/api/v1";
async function everyOrder(key: string) {
const orders = [];
let cursor: string | null = null;
do {
const url = new URL(`${SAILO}/orders`);
url.searchParams.set("limit", "100");
if (cursor) url.searchParams.set("cursor", cursor);
const response = await fetch(url, {
headers: { authorization: `Bearer ${key}` },
});
if (!response.ok) throw new Error(`sailo: ${response.status}`);
const page = await response.json();
orders.push(...page.data);
cursor = page.next_cursor;
// `has_more`, not `cursor` — see below.
if (!page.has_more) break;
} while (true);
return orders;
}Loop on has_more, not on whether the cursor is null. They answer different
questions, and a consumer branching on the cursor gets the wrong one on the last
full page of a keyset scan: the cursor is present, the next request returns
nothing, and a naive loop either makes one wasted call or — if it treats an
empty page as an error — fails.
Parameters
| Parameter | Meaning |
|---|---|
limit | How many to return. Defaults to 25, capped at 100. |
cursor | The next_cursor from the previous response. Omit for the first page. |
Asking for more than 100 is clamped rather than refused, so a
caller who sends limit=1000 gets 100 and a working integration
instead of a 400 and a support ticket.
Why cursors rather than offsets
A cursor names a position in the ordering rather than a count of rows skipped.
That distinction is the whole reason for it. With ?page=3, an order placed
while you are scanning shifts every subsequent row down by one, and the record
that was at the top of page 4 moves to the bottom of page 3 — which you have
already read. You silently skip it. On a busy shop, a nightly offset-paged
export misses a handful of orders every night and there is nothing in the data
to say so.
Keyset paging cannot do that. New rows arrive ahead of where you are reading and you simply do not see them on this pass, which is the correct behaviour for a snapshot.
What a cursor is not
Opaque. It is base64 today and that is an implementation detail; building anything on its internals is building on something that will change. Do not parse one, do not construct one, do not assume two cursors can be compared.
Not durable. Treat a cursor as valid for the duration of one scan. It is fine to hold one across a retry seconds later; it is not a bookmark to store for a week.
Not portable. A cursor issued for /orders means nothing to /products,
and one issued under one set of filters means nothing under another. Change a
filter and start the scan again.
A cursor Sailo did not issue is an invalid_request, not an empty page — a
silent empty page would make a typo look like “there is no more data”, which is
exactly the failure that gets deployed.
Ordering
Newest first, by creation time, on every list endpoint. Ties are broken by id so the order is total — two orders created in the same millisecond have a stable relative position, which is what makes the cursor unambiguous.
Filters and paging together
Filters are applied before paging, so has_more and next_cursor describe the
filtered set. ?payment_status=paid&limit=100 walks paid orders a hundred at
a time; it does not walk all orders and hand you the paid ones out of each
hundred.
Keep every filter identical across a scan. Changing one mid-loop invalidates the cursor’s meaning even where it does not produce an error.
Doing an incremental sync
There is no updated_since filter, so the pattern is:
- Subscribe to the webhook for the thing you care about, and process events as they arrive. This is the live path.
- Page the list endpoint on a schedule as a backstop, stopping early once you reach records you already have — the ordering is newest-first, so you can break out of the loop rather than reading to the end.
Webhooks are the primary mechanism and polling is the safety net, not the other way round. A poll frequent enough to feel live is a poll that will meet the rate limit on a shop with real volume.