Every list endpoint in v2 uses cursor pagination with the same response shape. No exceptions, no offset/skip/page parameters anywhere.
Response shape
{
"data": [
/* ... rows ... */
],
"page": {
"cursor": "eyJpZCI6Ii4uLiIsImNyZWF0ZWRBdCI6Ii4uLiJ9",
"has_more": true,
"limit": 50
}
}
data- array of rows. Empty array on the last page if it lined up.page.cursor- opaque token. Pass it as?cursor=...to fetch the next page.nullon the final page.page.has_more-falsewhen there is nothing else to fetch. Use this as your loop condition.page.limit- the effective page size we used.
Parameters
Parameter | Default | Max | Notes |
|---|---|---|---|
| 50 | 200 | Rows per page. |
| none | - | Opaque token from the previous response's |
Cursors are stable across mutations. New rows that arrive mid-pagination won't shift your offset and cause skips or duplicates - the cursor encodes the sort key directly.
Sort
Every list sorts by created_at DESC, id DESC. There's no sort parameter - if you need a different order, fetch and sort client-side.
Fetching a single page
curl -H "Authorization: Bearer $KEY" \
"https://productlane.com/api/v2/threads?limit=50"
Fetching the next page
Take page.cursor from the previous response and pass it as ?cursor=...:
curl -H "Authorization: Bearer $KEY" \
"https://productlane.com/api/v2/threads?limit=50&cursor=eyJpZCI6..."
Looping through all pages
type Page<T> = {
data: T[];
page: { cursor: string | null; has_more: boolean; limit: number };
};
async function fetchAll<T>(path: string, key: string): Promise<T[]> {
const all: T[] = [];
let cursor: string | null = null;
do {
const url = new URL(`https://productlane.com/api/v2/${path}`);
url.searchParams.set("limit", "100");
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, {
headers: { Authorization: `Bearer ${key}` },
});
if (!res.ok) throw new Error(`Failed: ${res.status}`);
const body = (await res.json()) as Page<T>;
all.push(...body.data);
cursor = body.page.has_more ? body.page.cursor : null;
} while (cursor);
return all;
}A few things this snippet does right and that you should keep:
Loops on
has_more, not ondata.length.Reads the cursor from the previous response - never builds one.
Re-uses the same
limiton every page.
Counts
There's no total_count. Counting an unbounded resource is expensive and most callers don't need it - has_more answers "are there more?" cheaply. If you need an exact count for a specific resource, let us know and we'll add a /count endpoint.
Filtering and pagination together
Filters apply before pagination. Pass them on the first request only - every subsequent request only needs cursor (and optionally limit):
# First page
GET /api/v2/threads?status=open&tab=new&limit=50
# Next page - the cursor remembers the filters
GET /api/v2/threads?cursor=eyJpZCI6...You can re-send the filters on every page if you prefer; we ignore them when a cursor is present.
Common pitfalls
Don't build cursors yourself. They're opaque and the format will change. Always read them from a response.
Don't reuse a cursor across filters. A cursor from
?status=openis meaningless against?status=done. If you change filters, start over.Don't loop forever. Always loop on
has_more. If you're polling for new rows, see Webhooks - pull-based polling will hit rate limits before it hits real-time freshness.
