Every list endpoint returns the same envelope:
{
"data": [ { "id": "..." } ],
"hasMore": true,
"nextCursor": "eyJpZCI6IjljYjEuLi4iLCJ0IjoiMjAyNi0wOC0wMVQwOTozMDowMFoifQ"
}
Two query parameters control it:
| Parameter | Default | Notes |
|---|---|---|
limit | 50 | 1 to 100. Asking for more is a 400, not a silent clamp |
cursor | none | The nextCursor from the previous page. Omit it for the first page |
Walking a list
# First page
curl "https://stippa.nl/api/v1/appointments?limit=100" \
-H "Authorization: Bearer $STIPPA_KEY"
# Next page
curl "https://stippa.nl/api/v1/appointments?limit=100&cursor=eyJpZCI6..." \
-H "Authorization: Bearer $STIPPA_KEY"
async function* everyAppointment(key, params = {}) {
let cursor = null;
do {
const query = new URLSearchParams({ ...params, limit: '100' });
if (cursor) query.set('cursor', cursor);
const response = await fetch(`https://stippa.nl/api/v1/appointments?${query}`, {
headers: { Authorization: `Bearer ${key}` },
});
if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
const page = await response.json();
yield* page.data;
cursor = page.nextCursor;
} while (cursor);
}
Loop on nextCursor rather than on hasMore. They agree, but nextCursor is the value you
need anyway, and a loop that reads one field cannot get the two out of step.
The last page has hasMore: false and nextCursor: null. An empty result set is a normal 200
with data: [], never a 404.
Cursors are opaque
A cursor is a string we minted. Do not construct one, parse one, or store one as anything other than the exact string you received.
The current implementation happens to be base64, and that is not a licence to decode it. It is opaque by contract precisely so the mechanism underneath can change without a version bump, and a client that decodes it will break on a change we are not obliged to announce.
A cursor we cannot read is a 400 VALIDATION_ERROR naming cursor in fields. The only
recovery is to start the walk again without one.
Why there is no page number
There is no ?page=2, and there will not be. Offset pagination is specifically wrong for this
data.
An appointment list changes continuously while you are reading it: bookings arrive, cancellations land, a business reschedules somebody. With an offset, a row inserted before your current position shifts everything down by one, so page 2 skips a record and page 3 shows one twice. You get a corrupt export and no error.
A cursor names the last row you saw rather than a position in a list, so the next page is "everything after this row" no matter what happened in front of it. Rows added before your cursor are simply not in your walk, which is the correct answer for a snapshot.
Ordering and ties
Appointments are ordered by start time, most other resources by creation time. The cursor carries the row's id alongside its sort value, and the ordering is on the pair.
That matters more than it sounds. Timestamps collide constantly here: a recurring series is written in one statement, an import creates a hundred customers inside the same millisecond, and two staff members have appointments starting at the same time by design. On the timestamp alone, rows sharing a value have no defined order between pages and can be served twice or skipped depending on the query plan. On the pair there is exactly one row after any given row.
You do not have to do anything about this. It is here so you know the walk is complete.
Filtering while you page
Filters are ordinary query parameters and must be repeated on every request in the walk,
including the ones carrying a cursor. The loop above does that by keeping params outside the
iteration.
updatedSince is the one to reach for when you are syncing rather than exporting: pass the
timestamp of your last successful sync and page through only what changed.