# Pagination

Cursor paging by default, page numbers on collections that count their rows, page sizes, totals, and how to walk a whole collection.

Every collection read returns one page of rows at a time. This page shows how to read the page state, move between pages, and walk a whole collection.

Paths are relative to `https://<your-url>/api/v2`, where `<your-url>` is the address you sign in to plnnrly on, such as `acme.plnnrly.com`, or your organisation's own domain.

## The envelope

A collection answers with `data`, `meta.page` and `links`. The first page of `GET /projects`:

```jsonc
{
  "data": [
    // 25 projects in the summary view
  ],
  "meta": {
    "page": {
      "per_page": 25,
      "next_cursor": "<opaque cursor>",
      "prev_cursor": null,
      "total": 184,
      "current_page": null,
      "last_page": null
    }
  },
  "links": {
    "self": "https://<your-url>/api/v2/projects?sort=title&trashed=without&per_page=25&view=summary",
    "next": "https://<your-url>/api/v2/projects?sort=title&trashed=without&per_page=25&cursor=<opaque cursor>&view=summary",
    "prev": null
  }
}
```

- `data` holds the rows.
- `meta.page` holds the page state. `current_page` and `last_page` are present on collections that support page numbers, and `null` until you send `page`.
- `links` holds ready-made URLs. `next` and `prev` repeat every parameter that shaped the page, including the defaults, so following them keeps your query intact.

`meta.warnings` appears next to `meta.page` when there is something to report. See [Views and includes](https://developers.plnnrly.com/en/docs/api/v2/guides/views-and-includes/#warnings). Every member of the envelope is described on the [pagination schema](https://developers.plnnrly.com/en/docs/api/v2/schemas/pagination/) page.

## Page size

`per_page` sets the number of rows. It defaults to 25 and goes up to 100.

When `include` names a relation that holds several records, such as `labels` on a work item, the maximum is 25. Above that you get `400` [invalid-query-parameter](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#invalid-query-parameter) with code `out_of_range`. See [Views and includes](https://developers.plnnrly.com/en/docs/api/v2/guides/views-and-includes/).

All limits are listed together in [Limits at a glance](https://developers.plnnrly.com/en/docs/api/v2/guides/limits/).

## Cursor paging

Cursor paging is the default. Send no paging parameter and you get the first page.

To get the next page, request `links.next` as it is. It is already URL-encoded. You can also send the cursor yourself, as `cursor`; it is the same value as `meta.page.next_cursor`:

```bash
curl -G "https://<your-url>/api/v2/projects" \
  -H "Authorization: Bearer <token>" \
  --data-urlencode "cursor=<cursor from links.next>"
```

On the last page, `links.next` and `next_cursor` are `null`.

A cursor is tied to the query that produced it: its `sort`, its filters, `q`, `trashed` and `view`. Change any of those and the cursor no longer fits, which is `400` [invalid-cursor](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#invalid-cursor). Start again from the first page when you change the query. You may change `per_page` between pages.

Never build or edit a cursor yourself. Use a cursor to fetch the next page, not to resume a job days later. To pick up where a sync left off, filter on `updated_at` instead; see [Keeping a copy in sync](https://developers.plnnrly.com/en/docs/api/v2/guides/syncing-data/).

With page numbers, a row added to an earlier page shifts every later row by one, so you read one row twice. A cursor is not thrown off by rows added or deleted elsewhere: it continues after the last row you read. A row whose sort value changes while you page can still move past the cursor, so sort by `id` when you must not miss a row.

## Page numbers

Some collections also accept `page`, counted from 1 up to 1000. They are the collections that count their rows on every request, because a page number is only useful when you know the last page:

- **Projects:** [`/budget-groups`](https://developers.plnnrly.com/en/docs/api/v2/budget-groups/list/), [`/budgets`](https://developers.plnnrly.com/en/docs/api/v2/budgets/list/), [`/project-links`](https://developers.plnnrly.com/en/docs/api/v2/project-links/list/), [`/projects`](https://developers.plnnrly.com/en/docs/api/v2/projects/list/)
- **Work items:** [`/work-items/{work_item}/attachments`](https://developers.plnnrly.com/en/docs/api/v2/work-items/attachments/list/), [`/work-items/{work_item}/links`](https://developers.plnnrly.com/en/docs/api/v2/work-items/links/list/), [`/work-items/{work_item}/subtasks`](https://developers.plnnrly.com/en/docs/api/v2/work-items/subtasks/list/)
- **Sprints:** [`/sprints`](https://developers.plnnrly.com/en/docs/api/v2/sprints/list/)
- **Time:** [`/timers`](https://developers.plnnrly.com/en/docs/api/v2/timers/list/)
- **Drive:** [`/drive-items/{drive_item}/files`](https://developers.plnnrly.com/en/docs/api/v2/drive-items/files/list/)
- **Customers:** [`/contact-roles`](https://developers.plnnrly.com/en/docs/api/v2/contact-roles/list/), [`/contacts`](https://developers.plnnrly.com/en/docs/api/v2/contacts/list/), [`/customer-categories`](https://developers.plnnrly.com/en/docs/api/v2/customer-categories/list/), [`/customers`](https://developers.plnnrly.com/en/docs/api/v2/customers/list/)
- **Billing:** [`/customer-subscriptions`](https://developers.plnnrly.com/en/docs/api/v2/customer-subscriptions/list/), [`/customer-subscriptions/{customer_subscription}/items`](https://developers.plnnrly.com/en/docs/api/v2/customer-subscriptions/items/list/), [`/invoices`](https://developers.plnnrly.com/en/docs/api/v2/invoices/list/), [`/sales-invoices`](https://developers.plnnrly.com/en/docs/api/v2/sales-invoices/list/), [`/sales-invoices/{sales_invoice}/rows`](https://developers.plnnrly.com/en/docs/api/v2/sales-invoices/rows/list/), [`/subscription-products`](https://developers.plnnrly.com/en/docs/api/v2/subscription-products/list/), [`/subscription-types`](https://developers.plnnrly.com/en/docs/api/v2/subscription-types/list/), [`/subscription-types/{subscription_type}/items`](https://developers.plnnrly.com/en/docs/api/v2/subscription-types/items/list/)
- **Leave:** [`/absences`](https://developers.plnnrly.com/en/docs/api/v2/absences/list/), [`/free-days`](https://developers.plnnrly.com/en/docs/api/v2/free-days/list/), [`/holidays`](https://developers.plnnrly.com/en/docs/api/v2/holidays/list/)
- **Organization:** [`/company-branches`](https://developers.plnnrly.com/en/docs/api/v2/company-branches/list/), [`/users`](https://developers.plnnrly.com/en/docs/api/v2/users/list/)
- **Configuration:** [`/labels`](https://developers.plnnrly.com/en/docs/api/v2/labels/list/), [`/work-types`](https://developers.plnnrly.com/en/docs/api/v2/work-types/list/), [`/workflow-statuses`](https://developers.plnnrly.com/en/docs/api/v2/workflow-statuses/list/), [`/workflow-transitions`](https://developers.plnnrly.com/en/docs/api/v2/workflow-transitions/list/), [`/workflows`](https://developers.plnnrly.com/en/docs/api/v2/workflows/list/)

On any other collection, `page` is `400` [unsupported-parameter](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#unsupported-parameter). The operation page lists `page` when a collection accepts it.

Send `page` and the response switches to page mode. `GET /projects?page=2`:

```jsonc
{
  "data": [
    // 25 projects in the summary view
  ],
  "meta": {
    "page": {
      "per_page": 25,
      "next_cursor": null,
      "prev_cursor": null,
      "total": 184,
      "current_page": 2,
      "last_page": 8
    }
  },
  "links": {
    "self": "https://<your-url>/api/v2/projects?sort=title&trashed=without&per_page=25&page=2&view=summary",
    "next": "https://<your-url>/api/v2/projects?sort=title&trashed=without&per_page=25&page=3&view=summary",
    "prev": "https://<your-url>/api/v2/projects?sort=title&trashed=without&per_page=25&page=1&view=summary"
  }
}
```

In page mode, `links.next` and `links.prev` are page-number links, and both cursors are `null`. `page` and `cursor` cannot be combined: sending both is `400` with code `mutually_exclusive`.

### Paging past the end

`links.next` is `null` on the last page. If you ask for a page beyond `last_page`, you get an empty `data` list, not an error, and `current_page` repeats the number you sent. A page above 1000 is `400` with code `out_of_range`; use cursors to go further.

### Lists people reorder

Some numbered collections are sorted by default on a position that changes whenever someone drags a row in plnnrly, such as labels, sprints, subtasks and work types. If someone reorders the list between two of your requests, page numbers can skip a row or show one twice. The `page` parameter on those operation pages says so.

For a walk that cannot skip rows, send `sort=id` and follow `links.next` without `page`.

## Totals

`meta.page.total` is the number of rows that match your query.

- On collections that accept `page`, `total` is always a number. They do not accept `with_total`: sending it is `400` [unsupported-parameter](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#unsupported-parameter).
- On every other collection, `total` is `null` unless you send `with_total=1`. Counting is the expensive part of those queries, so `with_total=1` costs 2 more rate units: 3 in place of 1, or 5 together with `view=detail`. See [Rate limits](https://developers.plnnrly.com/en/docs/api/v2/guides/rate-limits/).

The operation page lists `page` or `with_total`, so you can see which kind a collection is before you call it.

```bash
curl -G "https://<your-url>/api/v2/work-items" \
  -H "Authorization: Bearer <token>" \
  --data-urlencode "with_total=1"
```

## Walking a whole collection

Follow `links.next` until it is `null`. This exports every work item you can read, 100 at a time. It uses `jq` to read the JSON:

```bash
set -euo pipefail
url="https://<your-url>/api/v2/work-items?per_page=100&sort=id"
while [ -n "$url" ]; do
  body=$(curl -sS --fail-with-body "$url" -H "Authorization: Bearer <token>")
  echo "$body" | jq -c '.data[]'
  url=$(echo "$body" | jq -r '.links.next // empty')
done
```

The script stops on an error response. On a `429`, wait as `Retry-After` says and request the same URL again; see [Rate limits](https://developers.plnnrly.com/en/docs/api/v2/guides/rate-limits/).

Sort by `id` for an export. It never changes, so a row cannot move between pages while you read. Sorting by `updated_at` moves a row every time someone saves it, which is what you want for a sync and not for an export.

## Related

- [Filtering and sorting](https://developers.plnnrly.com/en/docs/api/v2/guides/filtering-and-sorting/): narrow and order the rows before you page through them.
- [Keeping a copy in sync](https://developers.plnnrly.com/en/docs/api/v2/guides/syncing-data/): paging by `updated_at` with a high-water mark.
- [Concurrency and ETags](https://developers.plnnrly.com/en/docs/api/v2/guides/concurrency-and-etags/#etag): re-read an unchanged page as a `304`.
