Cursors, not pages
Every list endpoint returns:
{
"data": [ … ],
"next_cursor": "djE6Y2x4…",
"has_more": true
}
Pass next_cursor back as ?cursor= to get the next page. When has_more is false, next_cursor is null and you are done.
cursor=""
while :; do
page=$(curl -sG https://app.zellerai.co/api/v1/contacts \
-H "Authorization: token $KEY" \
--data-urlencode "limit=100" \
${cursor:+--data-urlencode "cursor=$cursor"})
echo "$page" | jq -c '.data[]'
cursor=$(echo "$page" | jq -r '.next_cursor // empty')
[ -z "$cursor" ] && break
done
Why not ?page=2? Because rows are being created while you walk. An offset pager shifts under you: insert a row between page 1 and page 2 and you will see one row twice and miss another entirely. A cursor is a position in a total order, so it cannot skip or repeat. Treat it as opaque — it encodes a version and an id today, and that is not a promise.
A malformed or stale cursor restarts the walk rather than erroring.
limit defaults to 50 and is capped at 200.
Field masks
Ask for less:
GET /api/v1/contacts?fields=id,first_name,stage
id is always included — a response you cannot re-fetch is not smaller, it is broken. This is bandwidth, not security: the scope check already happened, and asking for fewer fields never grants more.
Rate limits
Every response carries:
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 118
X-RateLimit-Reset: 1786000260
A 429 adds Retry-After in seconds. Limits are 120/min for an API key and 300/min for an OAuth application, counted per app per workspace — so one noisy customer of your integration cannot exhaust the budget for the others.
The limiter is durable (Postgres-backed), not per-process, so the limit is the limit no matter how many instances answer.