List endpoints, such as GET /v1/leads or GET /v1/calls, return a team's records one page at a time. This page explains how to read every page, how a list is ordered and filtered, and which endpoints are not paged at all.
Pages and cursors
Every page comes in the same list envelope:
{
"object": "list",
"data": [
{
"id": "call_m2kUbFcayHlZ5Lgs9xZNmDS3c3Jvk",
"object": "call",
"livemode": false,
"team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR",
"lead": "lead_r72EfbRNUzuqORFJR0O8HxAwEXd93jGUTYMaMMXKSF0QbLS8CiUKJpEP34LFQG",
"direction": "inbound",
"started_at": "2026-09-28T10:17:00.000Z",
"duration_seconds": 184,
"outcome": "answered_ai",
"outcome_basis": "inbound_verdict",
"version": 2,
"updated_at": "2026-09-28T10:21:04.010Z"
}
],
"has_more": true,
"next_cursor": "eyJ2IjoxLCJrIjoiY2FsbCIsImEiOiJjYWxsX20ya1ViRmNheUhsWjVMZ3M5eFpObURTM2MzSnZrIiwicyI6ImM0OTk4YTdlNDJkYiJ9357H5Y"
}limitsets the page size, from 1 to 100. The default is 25. You can change it from one page to the next.- While
has_moreistrue, sendnext_cursorback ascursorto get the next page. On the last page,has_moreisfalseandnext_cursorisnull. - A page can hold fewer items than
limit, or none at all, and still sayhas_more: true. Totem skips records it has not prepared yet, and ends a page early rather than make you wait. Keep going untilhas_moreisfalse.
A cursor is opaque: store it exactly as you received it, and never build or edit one. It works only with the endpoint, the filters and the team it came from. Any other use gets 400 invalid_request, with param set to cursor. A conversations cursor also stops working if the conversation it points at is erased: if a cursor you did not change gets 400 invalid_request with param set to cursor, start that list again from the first page.
Read every page
To read a whole list, keep the same filters and the same Totem-Team on every request, and stop only when has_more is false. These loops list a team's calls with cURL and jq, with TypeScript on Node.js 22.18 or later (an ES module, so await works at the top level), and with Python and requests:
cursor=""
while :; do
page=$(curl -sS --fail-with-body \
"https://api.totemstudio.ai/v1/calls?limit=100${cursor:+&cursor=$cursor}" \
-H "Authorization: Bearer $TOTEM_API_KEY" \
-H "Totem-Team: $TOTEM_TEAM_ID") || { printf '%s\n' "$page" >&2; break; }
printf '%s\n' "$page" | jq -r '.data[].id'
[ "$(printf '%s' "$page" | jq -r '.has_more')" = true ] || break
next=$(printf '%s' "$page" | jq -er '.next_cursor') || { echo 'The page has no next_cursor' >&2; break; }
[ "$next" != "$cursor" ] || { echo 'The list repeated a cursor' >&2; break; }
cursor=$next
doneinterface CallPage {
data: Array<{ id: string }>
has_more: boolean
next_cursor: string | null
}
const apiKey = process.env.TOTEM_API_KEY
if (!apiKey) throw new Error('Set TOTEM_API_KEY on your server')
const headers: Record<string, string> = { Authorization: `Bearer ${apiKey}` }
if (process.env.TOTEM_TEAM_ID) headers['Totem-Team'] = process.env.TOTEM_TEAM_ID
let cursor: string | null = null
while (true) {
const url = new URL('https://api.totemstudio.ai/v1/calls')
url.searchParams.set('limit', '100')
if (cursor) url.searchParams.set('cursor', cursor)
const response = await fetch(url, { headers, signal: AbortSignal.timeout(30_000) })
if (!response.ok) {
const problem = (await response.json().catch(() => ({}))) as { code?: string }
throw new Error(`Totem API ${response.status} ${problem.code ?? response.statusText}, request ${response.headers.get('Totem-Request-Id') ?? 'n/a'}`)
}
const page = (await response.json()) as CallPage
// Your code goes here. Print ids, never whole records: they hold personal data.
for (const call of page.data) console.log(call.id)
if (!page.has_more) break
if (!page.next_cursor || page.next_cursor === cursor) throw new Error('The list did not move forward')
cursor = page.next_cursor
}# pip install requests
import os
import requests
headers = {"Authorization": f"Bearer {os.environ['TOTEM_API_KEY']}"}
if os.environ.get("TOTEM_TEAM_ID"):
headers["Totem-Team"] = os.environ["TOTEM_TEAM_ID"]
params = {"limit": 100}
while True:
response = requests.get("https://api.totemstudio.ai/v1/calls", headers=headers, params=params, timeout=30)
if not response.ok:
try:
code = response.json().get("code")
except ValueError:
code = None
raise RuntimeError(
f"Totem API {response.status_code} {code or response.reason}, "
f"request {response.headers.get('Totem-Request-Id', 'n/a')}"
)
page = response.json()
# Your code goes here. Print ids, never whole records: they hold personal data.
for call in page["data"]:
print(call["id"])
if not page["has_more"]:
break
if not page["next_cursor"] or page["next_cursor"] == params.get("cursor"):
raise RuntimeError("The list did not move forward")
params["cursor"] = page["next_cursor"]Each loop also stops when the API answers an error, or when a page repeats the cursor it was asked for, rather than asking for the same page forever. To keep a copy of a team, use the changes feed instead, as Keeping a copy explains.
Order
A list comes in a stable order that means nothing: it is not by date, and not by name. If you need an order, sort on your side, for example by created_at.
Filters
GET /v1/leads is the only list with filters. It takes at most one of them, with one value:
board: the leads on one board (brd_…);stage: the leads in one stage (stg_…);status: the leads with one status, such asqualified.
curl "https://api.totemstudio.ai/v1/leads?status=qualified" \
-H "Authorization: Bearer $TOTEM_API_KEY" \
-H "Totem-Team: $TOTEM_TEAM_ID"Two filters in one request get 400 invalid_request, with param naming one of them. A board or stage the team does not have returns an empty page, not an error.
There is no updated_since filter. To find out what changed, use the changes feed: Keep a copy in sync explains it.
Lists that come whole
Three lists are short, so they come whole, in one response, and take no limit or cursor:
GET /v1/teams, the teams your key reads;GET /v1/members, a team's members;GET /v1/booking_services, a team's bookable services.
They use the same envelope, with has_more: false and next_cursor: null. Sending limit or cursor to one of them gets 400 invalid_request.
Search is not a list
POST /v1/leads/search finds leads by exact phone or email, sent in the JSON body. It returns at most 10 leads, in one response that never pages: has_more is always false and next_cursor is always null. When many records share a phone number or an email address, search may not return them all.
Records that are not ready yet
Totem prepares each record for the API shortly after it changes. Until then, lists and search skip a new record, and a request for it by id, such as GET /v1/leads/{id}, answers 404 resource_missing. Do not retry in a loop: the changes feed delivers the record as soon as it is ready.
Keeping a copy
A list shows the records as they are now. It is the right tool to look something up, not to keep a copy: re-reading every list to find changes costs far more of your daily quota and still misses removals. To keep a copy of a team, follow the changes feed, as Keep a copy in sync explains.