Pagination
Cursor-based lists with limit, cursor, has_more and next_cursor.
1 min read
Every endpoint that returns many items uses the same list envelope:
JSON
{
"object": "list",
"url": "/v1/campaigns",
"data": [ { "object": "campaign", "id": "cmpA7Hq2LmX9vRt4BzKe" } ],
"has_more": true,
"next_cursor": "eyJvIjoyNX0",
"meta": { "computed_at": "2026-10-14T09:30:00Z", "cache_age_seconds": 12 }
}| Field | |
|---|---|
data | This page of results. |
has_more | true when another page exists. |
next_cursor | Pass it as cursor to get the next page. null on the last page. |
meta | Everything about the list that is not an item: totals, the resolved window, coverage, freshness, or a note explaining an empty list. |
Parameters#
| Parameter | |
|---|---|
limit | Items per page, 1 to 100. Default 25, or less where an endpoint says so (sponsor matches default to 5). |
cursor | The next_cursor from the previous page. |
A few lists are ranked results rather than pages: search, suggestions and candidate lists take their own limit and never return a next_cursor.
Cursors are opaque. Do not build or edit them; they may change format at any time. Keep the other query parameters the same across pages, or the cursor may point somewhere that no longer makes sense.
Reading every page#
async function* listAll(path, params = {}) {
let cursor;
do {
const qs = new URLSearchParams({ ...params, limit: '100', ...(cursor ? { cursor } : {}) });
const res = await fetch(`https://api.listener.com${path}?${qs}`, {
headers: { Authorization: `Bearer ${process.env.LISTENER_API_KEY}` },
});
const page = await res.json();
yield* page.data;
cursor = page.has_more ? page.next_cursor : null;
} while (cursor);
}
for await (const campaign of listAll('/v1/campaigns', { status: 'active' })) {
console.log(campaign.id, campaign.title);
}Paging through a large workspace counts every page against your rate limit. Use limit=100 and filters to keep the number of requests down.