Pagination
Most list endpoints return a standard pagination envelope. The exceptions are
dashboard/ugc-impact and gsc/trends, which return the whole result set as
data with no limit or offset keys, and checks/answers, which pages by
cursor (see "Cursor pagination" below). The standard envelope:
{
"data": [...],
"total": 1234,
"limit": 50,
"offset": 0
}data- the items for this page.total- the total count of matching rows before pagination. Use this to determine how many pages exist. Ondashboard/ugc-impacttotalis the sum of UGC citations in the window rather than a row count, so do not page against it. That endpoint returns nolimitoroffset, which is how to tell the two apart: where the envelope carrieslimitandoffset,totalis a row count.limit- the page size used for this response.offset- the zero-based starting index used for this response.
Parameters
| Parameter | Type | Default | Max | Notes |
|---|---|---|---|---|
limit |
integer | 50 | 100 | Number of items per page |
offset |
integer | 0 | - | Zero-based starting index |
An out-of-range limit (less than 1 or greater than 100) or a negative
offset returns 422 VALIDATION_ERROR. checks/answers takes no offset
and caps limit at 25.
Example: paging through all prompts
# Page 1
curl -H "Authorization: Bearer zt_live_<token>" \
"https://api.ziptie.ai/v1/projects/PROJECT_ID/prompts?limit=100&offset=0"
# Page 2
curl -H "Authorization: Bearer zt_live_<token>" \
"https://api.ziptie.ai/v1/projects/PROJECT_ID/prompts?limit=100&offset=100"Determining total pages
import math
total = response["total"]
limit = response["limit"]
total_pages = math.ceil(total / limit)Fetching all items
The prompts, tags, and urls lists apply every filter, the sort and the
page in the database, so every row of a project of any size is reachable
through offset, and total counts the rows under every filter. The loop
below walks them to the end. prompts/performance pages the same way over the
prompts its filters keep.
The eight competitor drill-downs - competitors/mentions/{brand}/prompts,
competitors/citations/{domain}/prompts, competitors/citations/{domain}/urls,
competitors/sources/prompts, competitors/serp/{domain}/prompts,
competitors/serp/{domain}/urls, competitors/bing-organic/{domain}/prompts
and competitors/bing-organic/{domain}/urls - also report an exact count with
no cap: total is every prompt or page behind the ranking row, and the counts
summed over every page equal that row's count.
The competitors/citations, competitors/mentions, competitors/sources,
competitors/serp and competitors/bing-organic endpoints behave differently:
they page over a ranking that is already capped
where it is produced, and total counts exactly the rows in that capped
ranking. The loop below terminates on them with no empty pages. What total
does not tell you is how long the tail below the cap was.
GET /checks and GET /brand-aliases also report exact counts with no cap
above them - GET /checks counts (prompt, check) pairs in the window and
paginates in the database, so it is safe to walk a whole project's history
with the loop below.
GET /competitors also reports an exact row count, and with no cap above it:
it pages the project's whole tracked-competitor roster, so total is every
mark the project has and the loop below reaches all of them.
dashboard/ugc-impact/{platform} reports an exact count with no cap, of
URLs rather than citations: every check that cited a URL, by any engine,
merges into one row before the count is taken, so total is smaller than the
citations figure the parent ranking shows for that platform and the two are
not meant to agree. brand_awareness_score and sentiment_overview on that
response are computed over the whole filtered set rather than the page, so they
are identical on every page of one walk and there is no reason to re-read them.
The loop below terminates with no empty pages.
def fetch_all(base_url, headers, limit=100):
offset = 0
while True:
resp = requests.get(
f"{base_url}?limit={limit}&offset={offset}",
headers=headers
).json()
yield from resp["data"]
offset += limit
if offset >= resp["total"]:
breakPaging a changing list
On the offset-paged lists, paging is plain offset arithmetic against live
data. Every request re-reads the rows from scratch, re-applies q, re-sorts
them, and slices the requested page, so there is no snapshot and no cursor to
fall back on. Rows
created or deleted while you walk shift every position after them: an insert
pushes one row over the page boundary and you fetch it twice, a delete pulls
one back and you never see it. total moves under you as well, which changes
where the loop above stops.
Three habits make the walk safe:
- Collect into a dict keyed on
idrather than a list, so a repeated row costs nothing and a re-run is cheap. - Sort on a field the monitoring pipeline does not write. The prompts default,
last_check_date:desc, is rewritten every time the project's checks run, so the order can move even when nobody is editing anything.sort=text:asconprompts,name:ascontags, andurl:asconurlsare stable against check activity. On those three lists ties are broken byidascending, so a stable key gives a stable order. - Read
totalfrom the page you just fetched rather than from the first one, and stop on an empty page as well as onoffset >= total.
Cursor pagination: checks/answers
GET /v1/projects/{project_id}/checks/answers is the one /v1/ endpoint that
pages by cursor. It returns full engine answers, one item per (prompt, check)
pair, in a fixed (prompt_id, check_no) order, so it takes no sort and no
offset, and its envelope carries no total:
{
"data": [...],
"next_cursor": "4407:18",
"limit": 25
}next_cursor- pass it back ascursorto read the next page. It isnullwhen the walk is complete. Treat it as opaque.limit- 1-25 pairs per page, default 5. One pair carries up to seven full answers, which is why the ceiling is lower than elsewhere.
Stop only when next_cursor is null. A page can hold fewer than limit
items, or none at all, while more follow, so a short or empty page is not the
end of the walk.
In window mode, pass explicit date_from and date_to on every page.
Without latest or check_no the endpoint reads a date window, and the default
window (30 days before today through today) is recomputed from today's UTC date
on each request. A walk that crosses midnight UTC without explicit dates
silently gains a day at the new end and loses its oldest. Each page's applied
echoes the window it read, so compare it across pages to catch that. With latest=true or check_no, repeat that
selection on every page instead.
import requests
url = "https://api.ziptie.ai/v1/projects/PROJECT_ID/checks/answers"
headers = {"Authorization": "Bearer zt_live_<token>"}
# The same explicit dates on every page: the default window moves with the UTC date.
params = {"date_from": "2026-09-01", "date_to": "2026-09-21", "limit": 25}
items, cursor = [], None
while True:
page = requests.get(
url,
params={**params, "cursor": cursor} if cursor else params,
headers=headers,
).json()
items.extend(page["data"])
cursor = page["next_cursor"]
if cursor is None:
breakA walk reads each pair once and never repeats one. Checks that land while you
walk can add pairs: in window mode a new check for a prompt the cursor has
already passed is not returned, and one for a later prompt is. A malformed
cursor returns 422 VALIDATION_ERROR.
Empty results
When no items match the current filters, data is an empty list and total
is 0. This is not an error.
{"data": [], "total": 0, "limit": 50, "offset": 0}Combined with filters
Pagination parameters combine with the other query parameters an endpoint accepts. Example: fetch the second page of prompts matching a text search, sorted by prompt text:
curl -H "Authorization: Bearer zt_live_<token>" \
"https://api.ziptie.ai/v1/projects/PROJECT_ID/prompts?q=running&sort=text:asc&limit=50&offset=50"