> ## Documentation Index
> Fetch the complete documentation index at: https://productlane.mintlify.site/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Pagination

> Every list endpoint uses cursor pagination with the same shape.

Every list endpoint in v2 uses **cursor pagination** with the same response shape. No exceptions, no `offset`/`skip`/`page` parameters anywhere.

## Response shape

```json theme={null}
{
  "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

| Parameter | Default | Max | Notes                                                    |
| --------- | ------- | --- | -------------------------------------------------------- |
| `limit`   | 50      | 200 | Rows per page.                                           |
| `cursor`  | none    | -   | 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

```bash theme={null}
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=...`:

```bash theme={null}
curl -H "Authorization: Bearer $KEY" \
  "https://productlane.com/api/v2/threads?limit=50&cursor=eyJpZCI6..."
```

## Looping through all pages

```typescript theme={null}
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`):

```bash theme={null}
# 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](/api-v2/webhooks) - pull-based polling will hit rate limits before it hits real-time freshness.
