Skip to main content

Documentation Index

Fetch the complete documentation index at: https://productlane.mintlify.dev/docs/llms.txt

Use this file to discover all available pages before exploring further.

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. null on the final page.
  • page.has_more - false when there is nothing else to fetch. Use this as your loop condition.
  • page.limit - the effective page size we used.

Parameters

ParameterDefaultMaxNotes
limit50200Rows per page.
cursornone-Opaque token from the previous response’s page.cursor.
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 on data.length.
  • Reads the cursor from the previous response - never builds one.
  • Re-uses the same limit on 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=open is 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.