Skip to content

Pagination ​

Every v2 list endpoint is paginated. A list response is always an envelope object, never a bare array, and the envelope tells you what you got and how to get the rest.

v2 has two pagination styles:

StyleHow you get itEnvelope keysCostBest for
OffsetDefault (no parameter)data, next, previous, total_count, paginationRuns a COUNT per requestUI paging, jumping to a page, showing a total
Cursor?paginate=cursordata, next_cursor, has_more, paginationNo COUNTFull exports, deep traversal, changing data sets

Both envelopes carry a pagination.style discriminator. Branch your client on that key instead of on which fields are present. See Write one loop for both styles.

Offset pagination (default) ​

Offset is the default when you send no pagination parameters. You control the window with per_page and offset.

bash
curl "https://api.plane.so/api/v2/workspaces/my-team/projects/4af68566-94a4-4eb3-94aa-50dc9427067b/work-items/?per_page=50&offset=0" \
  -H "X-Api-Key: $PLANE_API_KEY"
Response200
json
{
  "data": [
    {
      "id": "8f4c2b1e-0d3a-4f7b-9c21-6e5a8b7d4f13",
      "name": "Fix login redirect",
      "identifier": "PROJ-118",
      "sequence_id": 118,
      "priority": "high",
      "state_id": "f960d3c2-8524-4a41-b8eb-055ce4be2a7f",
      "type_id": null,
      "assignee_ids": ["16c61a3a-512a-48ac-b0be-b6b46fe6f430"],
      "label_ids": [],
      "parent_id": null,
      "start_date": null,
      "target_date": "2026-02-02",
      "is_draft": false,
      "archived_at": null,
      "created_at": "2026-01-14T09:22:41.478363Z",
      "created_by_id": "16c61a3a-512a-48ac-b0be-b6b46fe6f430"
    }
  ],
  "next": 50,
  "previous": null,
  "total_count": 327,
  "pagination": { "style": "offset" }
}

Reading the envelope ​

  • data: the page of results.
  • next: an integer offset, not a URL or a token. Send it back as ?offset= to get the following page. It is null on the last page.
  • previous: the integer offset of the preceding page, or null when you are already at the start.
  • total_count: how many rows match your filters across all pages. Omitted when you pass ?count=false.
  • pagination.style: always "offset" here.

Because next and previous are plain integers, you can also compute them yourself and jump to any page. For example, ?offset=200&per_page=50 is the fifth page, and you don't need to fetch the pages before it.

Query parameters ​

  • per_page: page size. Defaults to 50, maximum 200. Values above the maximum are reduced to 200 instead of rejected. A non-integer value returns 400 invalid_request.
  • offset: rows to skip from the start of the result set. Defaults to 0, maximum 10000. A negative, non-integer, or over-maximum offset returns 400 invalid_request, and the error message for an over-maximum value points you to cursor pagination.
  • count: set ?count=false to skip the COUNT query. total_count is then omitted from the envelope; next and previous still work.

Turn off the count on hot paths

total_count costs a second database query on every request. If you are streaming a list into a job instead of showing "327 results" in a UI, ?count=false makes each page cheaper without changing how you page through the list.

Offset stops at 10000

offset has a hard maximum of 10000 rows. If your result set is larger, for example in a full export, a backfill, or a nightly sync, switch to cursor pagination.

Cursor pagination (opt-in) ​

Cursor pagination uses a keyset. Instead of counting rows to skip, each page returns an opaque token that records where that page stopped.

Opt in with ?paginate=cursor, then follow next_cursor until has_more is false.

bash
# first page
curl "https://api.plane.so/api/v2/workspaces/my-team/projects/4af68566-94a4-4eb3-94aa-50dc9427067b/work-items/?paginate=cursor&per_page=100" \
  -H "X-Api-Key: $PLANE_API_KEY"

# every page after that — same parameters, plus the token
curl "https://api.plane.so/api/v2/workspaces/my-team/projects/4af68566-94a4-4eb3-94aa-50dc9427067b/work-items/?per_page=100&cursor=b3A9MTcx" \
  -H "X-Api-Key: $PLANE_API_KEY"
Response200
json
{
  "data": [
    {
      "id": "8f4c2b1e-0d3a-4f7b-9c21-6e5a8b7d4f13",
      "name": "Fix login redirect",
      "identifier": "PROJ-118",
      "sequence_id": 118,
      "priority": "high",
      "state_id": "f960d3c2-8524-4a41-b8eb-055ce4be2a7f",
      "type_id": null,
      "assignee_ids": ["16c61a3a-512a-48ac-b0be-b6b46fe6f430"],
      "label_ids": [],
      "parent_id": null,
      "start_date": null,
      "target_date": "2026-02-02",
      "is_draft": false,
      "archived_at": null,
      "created_at": "2026-01-14T09:22:41.478363Z",
      "created_by_id": "16c61a3a-512a-48ac-b0be-b6b46fe6f430"
    }
  ],
  "next_cursor": "b3A9MTcx",
  "has_more": true,
  "pagination": { "style": "cursor" }
}

What is different ​

  • There is no total_count and no count parameter, because cursor pagination never runs a COUNT. Skipping the count is the main reason to use it.
  • There is no previous. Cursor traversal is forward-only.
  • next_cursor is null and has_more is false on the last page.
  • per_page behaves exactly as it does on the offset path: default 50, max 200.

Passing ?cursor=<token> keeps you on the cursor path, so you do not need to repeat paginate=cursor on follow-up requests. You do need to repeat everything else. The token encodes only the position; filters, order_by, and per_page are read from each request, so if you drop them partway through, the result set changes without any error.

The token is opaque. Pass it back to the API unchanged: do not parse it, build one yourself, or store it as a long-lived bookmark. A malformed or truncated token returns 400 invalid_request.

Why cursor is stable

Offset pages are computed by skipping rows. If someone creates a work item while you are on page 3, everything shifts by one and you can see a row twice or miss it entirely. A cursor encodes the last row's sort key, so inserts and deletes elsewhere in the list cannot shift your window.

Not every ordering can use a cursor ​

Keyset pagination needs an ordering whose key is unique and monotonic, so each resource declares an allowlist of cursor-eligible order_by values instead of accepting any stored column. On work items the eligible values are created_at, updated_at, sequence_id, and id.

The cursor path rejects every other ordering. sort_order is a stored column but is not unique, so it cannot anchor a keyset. The semantic orderings cannot either: order_by=priority sorts urgent before none and order_by=state_group sorts by workflow order, and neither produces a key a cursor can resume from.

Pairing one of those with cursor pagination returns a 400 with the stable code ordering_not_cursor_eligible:

Response400
json
{
  "type": "invalid_request",
  "code": "ordering_not_cursor_eligible",
  "detail": "This ordering can't be used with cursor pagination. Pass a cursor-safe ordering (e.g. order_by=created_at), or use the default offset pagination."
}

You have two options:

  • If you need the semantic sort, drop paginate=cursor and use offset.
  • If you need to page through a large result set, sort by a cursor-eligible column such as created_at, and sort the results yourself once you have them all.

The check applies to the default ordering too

Eligibility is checked against the ordering that will run. If you omit order_by, that is the resource's default, which is not always a cursor-eligible column. If a cursor request returns 400 even though you passed no order_by, pass a cursor-eligible one such as ?order_by=created_at.

See Filtering and ordering for how ordering works and each resource's allowed order_by values.

Write one loop for both styles ​

The two envelopes share the data key and the pagination.style discriminator, so one auto-paginator can handle every list endpoint. Branch on pagination.style, not on "next" in body, which breaks as soon as someone switches a call to cursor.

Page through every result
bash
# Cursor traversal in a shell loop: follow next_cursor until has_more is false.
URL="https://api.plane.so/api/v2/workspaces/my-team/projects/4af68566-94a4-4eb3-94aa-50dc9427067b/work-items/"
CURSOR=""

while :; do
  BODY=$(curl -s -G "$URL" \
    -H "X-Api-Key: $PLANE_API_KEY" \
    --data-urlencode "paginate=cursor" \
    --data-urlencode "per_page=100" \
    ${CURSOR:+--data-urlencode "cursor=$CURSOR"})

  echo "$BODY" | jq -r '.data[] | "\(.identifier) \(.name)"'

  [ "$(echo "$BODY" | jq -r '.has_more')" = "true" ] || break
  CURSOR=$(echo "$BODY" | jq -r '.next_cursor')
done

Choosing a style ​

Use offset when a person is looking at the result: you want a total to display, you want to jump to page 7, and the list is small enough that the 10000-row limit does not matter.

Use cursor when a program is reading the result: exports, backfills, incremental syncs, or anything that reads all of a project's work items. It skips the COUNT, has no depth limit, and does not double-count rows if the data changes while you page through it.

Making pages cheaper with ?fields= ​

Both envelopes work with ?fields=, which is usually the biggest saving available when you page through a list, because you pay for every key you request on every row of every page:

bash
curl "https://api.plane.so/api/v2/workspaces/my-team/projects/ENG/work-items/?fields=id,updated_at&paginate=cursor&per_page=200" \
  -H "X-Api-Key: $PLANE_API_KEY"

When you combine them:

  • ?fields= never filters the envelope keys. It shapes the objects inside data; next, total_count, next_cursor, has_more and pagination always come back.
  • Some resources leave large fields off list rows by default. Pages, releases, customers, initiatives, teamspaces, stickies, intake work items and templates omit description_html from collection rows unless you name it. A plain list is already cheaper than the object shape suggests, and ?fields=all returns the full row.

Pair ?count=false with ?fields= when you are reading an entire list and need neither the total nor most of the columns.