Pagination

Cursor-based, by created_at, descending.

View as Markdown

Every list endpoint in the Pivotal API uses cursor pagination. The pattern is the same shape across customers, contacts, and onboardings:

GET /api/v1/customers?limit=20&cursor=2026-05-20T14:08:11.000Z

Response:

{
"object": "list",
"data": [ /* 0–20 items */ ],
"has_more": true,
"next_cursor": "2026-05-19T22:41:03.000Z"
}

Pass response.next_cursor straight back as the next request’s cursor. Stop when has_more is false.

Parameters

ParamTypeDefaultMaxNotes
limitinteger20100Page size. Larger = fewer round trips, more work per response.
cursorISO 8601 datetimenone—Returns items strictly older than this created_at.

cursor must validate as an ISO 8601 datetime. Garbage in returns a 400:

{
"error": {
"type": "invalid_request_error",
"code": "invalid_string",
"message": "Invalid datetime",
"field": "cursor"
}
}

Walking a list end to end

async function* walkCustomers() {
let cursor: string | undefined;
while (true) {
const url = new URL("https://my.pivotal.app/api/v1/customers");
url.searchParams.set("limit", "100");
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.PIVOTAL_API_KEY}` },
});
const page = await res.json();
for (const c of page.data) yield c;
if (!page.has_more) return;
cursor = page.next_cursor;
}
}
for await (const customer of walkCustomers()) {
console.log(customer.display_id, customer.name);
}

Python:

def walk_customers():
cursor = None
while True:
params = {"limit": 100}
if cursor:
params["cursor"] = cursor
res = requests.get(
"https://my.pivotal.app/api/v1/customers",
params=params,
headers={"Authorization": f"Bearer {os.environ['PIVOTAL_API_KEY']}"},
)
page = res.json()
for c in page["data"]:
yield c
if not page["has_more"]:
return
cursor = page["next_cursor"]

Filters and pagination interact

Filters like ?status=active apply on the server, then pagination cuts the filtered result. The cursor still walks created_at descending — you don’t get drift from cursoring through a moving filter as long as the filter is stable for the duration of the walk.

If you mutate records mid-walk (e.g. flipping status from onboarding to active while paginating ?status=onboarding), expect to either skip or duplicate the affected rows depending on the timing. Snapshot in your client if that’s a problem.

What we deliberately don’t do

  • No total count. Counting at scale gets expensive. If you need a UI element that says “245 customers”, fetch the count separately (a future /customers/count endpoint will land — for now, walk the list once and cache).
  • No offset/limit. Offsets get slower as the offset grows and behave badly with concurrent inserts. Cursors are stable and constant-cost.
  • No before cursor. Lists return newest first; if you need older-first, reverse the page in your client.

Edge cases

  • An empty page comes back as { object: "list", data: [], has_more: false, next_cursor: null }.
  • The last page has has_more: false even if it returned limit items — trust the flag, not the count.
  • Soft-deleted rows never appear in lists. They’re still gettable by id if you know it (GET /customers/{id} will 404 — soft-deleted is treated as gone). If you need to read deletes, surface them through the customer’s timeline.