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:

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

Response:

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

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 datetimenoneReturns items strictly older than this created_at.

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

1{
2 "error": {
3 "type": "invalid_request_error",
4 "code": "invalid_string",
5 "message": "Invalid datetime",
6 "field": "cursor"
7 }
8}

Walking a list end to end

1async function* walkCustomers() {
2 let cursor: string | undefined;
3 while (true) {
4 const url = new URL("https://my.pivotal.app/api/v1/customers");
5 url.searchParams.set("limit", "100");
6 if (cursor) url.searchParams.set("cursor", cursor);
7
8 const res = await fetch(url, {
9 headers: { Authorization: `Bearer ${process.env.PIVOTAL_API_KEY}` },
10 });
11 const page = await res.json();
12 for (const c of page.data) yield c;
13
14 if (!page.has_more) return;
15 cursor = page.next_cursor;
16 }
17}
18
19for await (const customer of walkCustomers()) {
20 console.log(customer.display_id, customer.name);
21}

Python:

1def walk_customers():
2 cursor = None
3 while True:
4 params = {"limit": 100}
5 if cursor:
6 params["cursor"] = cursor
7 res = requests.get(
8 "https://my.pivotal.app/api/v1/customers",
9 params=params,
10 headers={"Authorization": f"Bearer {os.environ['PIVOTAL_API_KEY']}"},
11 )
12 page = res.json()
13 for c in page["data"]:
14 yield c
15 if not page["has_more"]:
16 return
17 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.