Skip to content

List work items

GET/api/v2/workspaces/{slug}/projects/{project_id}/work-items/

List the work items in a project. This is the endpoint you build reports, syncs, and board views on: it takes a wide set of filters, orders by stored columns or by meaning, and paginates by offset or by cursor.

Results are always scoped to the project in the path and to what your token is allowed to see. Filters can only narrow that set — they never widen it.

Path Parameters

slug:requiredstring

The workspace slug. It appears in your Plane URLs — in https://app.plane.so/my-team/projects/, the slug is my-team.

project_id:requiredstring (uuid)

The project to list work items from.

Filters

Every filter is optional, and filters combine with AND. Each concept below has a base parameter plus the __in and __isnull variants noted in its description.

state_id:optionalstring (uuid)

Match work items in a specific state. Use state_id__in with a comma-separated list to match any of several states.

state_group:optionalstring

Match by the state's workflow group instead of a specific state — stable across projects that name their states differently. One of backlog, unstarted, started, completed, cancelled, triage.

Use state_group__in for several groups, for example ?state_group__in=started,completed.

priority:optionalstring

One of urgent, high, medium, low, none. Use priority__in for several, for example ?priority__in=urgent,high.

assignee_id:optionalstring (uuid)

Match work items assigned to a user. assignee_id__in matches any of several users; assignee_id__isnull=true returns only unassigned work items.

label_id:optionalstring (uuid)

Match work items carrying a label. label_id__in matches any of several labels; label_id__isnull=true returns only unlabeled work items.

type_id:optionalstring (uuid)

Match work items of a given work item type. type_id__in accepts a comma-separated list.

parent_id:optionalstring (uuid)

Match the children of a work item. parent_id__in accepts several parents; parent_id__isnull=true returns only top-level work items.

cycle_id:optionalstring (uuid)

Match work items in a cycle. cycle_id__in accepts several cycles; cycle_id__isnull=true returns the backlog of work items in no cycle at all.

module_id:optionalstring (uuid)

Match work items in a module. module_id__in accepts several modules; module_id__isnull=true returns work items in no module.

sequence_id:optionalinteger

Match the single work item with this number within the project — the 142 of PROJ-142.

is_draft:optionalboolean

Filter drafts in or out. Drafts are work items still being composed in the Plane app.

external_id, external_source:optionalstring

Correlation filters for sync and import. external_source is the system a record came from and external_id is its key there, so the pair is how you find the Plane work item that mirrors a row in your own database.

These values are not returned on reads — the lookup is one-way.

created_at__gte, created_at__lte:optionalstring (date-time)

Bound the creation timestamp. Pass an ISO 8601 datetime, for example 2026-01-01T00:00:00Z. Use both for a window.

updated_at__gte, updated_at__lte:optionalstring (date-time)

Bound the last-modified timestamp. This is the pair to use for incremental sync — poll with ?updated_at__gte=<your last run>&order_by=updated_at.

updated_at is filterable and orderable but is not part of the read shape, so it does not come back in the response body.

start_date__gte, start_date__lte:optionalstring (date)

Bound the planned start date, for example 2026-01-01.

target_date__gte, target_date__lte:optionalstring (date)

Bound the planned due date. ?target_date__lte=2026-01-31&state_group__in=backlog,unstarted,started is the "what is about to slip" query.

Search and ordering

search:optionalstring

Free-text search over the work item name.

order_by:optionalstring

Field to sort by. Prefix with - for descending. Defaults to -created_at.

  • created_at, -created_at
  • updated_at, -updated_at
  • sequence_id, -sequence_id
  • id, -id
  • sort_order, -sort_order — the manual board ordering
  • priority, -priority — semantic: urgenthighmediumlownone, not alphabetical
  • state_group, -state_group — semantic: workflow order, not alphabetical

priority and state_group sort by meaning, which is what you want for a board but is not cursor-eligible. Pair either with ?paginate=cursor and you get 400 ordering_not_cursor_eligible; use the default offset pages instead.

Pagination

Offset is the default. Cursor is opt-in per request.

per_page:optionalinteger

Page size. Defaults to 50, maximum 200.

offset:optionalinteger

Rows to skip from the start of the result set. Maximum 10000 — past that, switch to cursor pagination.

count:optionalboolean

Defaults to true. Pass ?count=false to skip the COUNT(*) and omit total_count from the envelope. Worth doing on large projects when you only need the rows.

paginate:optionalstring

Set to cursor to opt into keyset pagination. The envelope changes to next_cursor / has_more and drops total_count. Use it for deep or long-running traversals where offset pages would drift as rows are inserted. Follow the returned next_cursor as described in Pagination.

Expansion

expand:optionalstring

Comma-separated relations to embed alongside the ids: state, type, parent, assignees, labels.

Expansion is separate-key — ?expand=state keeps state_id and adds a state object next to it. An unknown value is a 400.

Bad filter values fail loudly, bad order_by does not

The enum-backed filters (priority, priority__in, state_group, state_group__in) are validated against their allowed values. A typo returns 400 validation_error naming the parameter — it does not silently return an empty page. Treat an empty data array as a genuine "no matches".

order_by and paginate are the exception: neither is validated. An unrecognized order_by silently falls back to the default ordering, and anything other than paginate=cursor silently uses offset pagination — so check your spelling there, because a typo shows up as an unexpected sort order or envelope rather than an error.

Archived work items are excluded

The list returns active work items only — anything with archived_at set is filtered out, so archiving a work item takes it off every page of this list. See Archive a work item.

Scopes

projects.work_items:read

Errors

StatusCodeCause
401unauthorizedMissing or invalid credentials.
403forbiddenYour role or token scope can't read work items in this project.
404resource_not_foundNo such workspace or project, or it's outside your tenant.
429rate_limitedThrottled. Honor the Retry-After header before retrying.
List work items
bash
curl -X GET \
  "https://api.plane.so/api/v2/workspaces/my-team/projects/4af68566-94a4-4eb3-94aa-50dc9427067b/work-items/?state_group__in=started,unstarted&priority=high&per_page=50&order_by=-created_at" \
  -H "X-Api-Key: $PLANE_API_KEY"
Response200
json
{
  "data": [
    {
      "id": "8f4c2b1e-0d3a-4f7b-9c21-6e5a8b7d4f13",
      "name": "Fix login redirect loop",
      "identifier": "PROJ-142",
      "sequence_id": 142,
      "priority": "high",
      "state_id": "f960d3c2-8524-4a41-b8eb-055ce4be2a7f",
      "type_id": "2d9d1a97-5c6f-4a1e-9d5b-8c2f7e30b6a4",
      "assignee_ids": ["16c61a3a-512a-48ac-b0be-b6b46fe6f430"],
      "label_ids": ["c1b8f3d6-9a44-4e12-8f7a-2b6d5c9e1a03"],
      "parent_id": null,
      "start_date": "2026-01-12",
      "target_date": "2026-01-20",
      "is_draft": false,
      "archived_at": null,
      "created_at": "2026-01-14T09:22:41.478363Z",
      "created_by_id": "16c61a3a-512a-48ac-b0be-b6b46fe6f430",
      "custom_fields": null
    },
    {
      "id": "3b7d9e40-1c62-4a85-b0f3-9d5c2e6a8471",
      "name": "Rate limit the invite endpoint",
      "identifier": "PROJ-141",
      "sequence_id": 141,
      "priority": "high",
      "state_id": "f960d3c2-8524-4a41-b8eb-055ce4be2a7f",
      "type_id": null,
      "assignee_ids": [],
      "label_ids": [],
      "parent_id": "a7e51d24-3b98-4c6d-9f10-7d2c8e4b5a61",
      "start_date": null,
      "target_date": null,
      "is_draft": false,
      "archived_at": null,
      "created_at": "2026-01-13T16:04:02.911204Z",
      "created_by_id": "16c61a3a-512a-48ac-b0be-b6b46fe6f430",
      "custom_fields": null
    }
  ],
  "next": 50,
  "previous": null,
  "total_count": 327,
  "pagination": { "style": "offset" }
}
Response200
json
{
  "data": [
    {
      "id": "8f4c2b1e-0d3a-4f7b-9c21-6e5a8b7d4f13",
      "name": "Fix login redirect loop",
      "identifier": "PROJ-142",
      "sequence_id": 142,
      "priority": "high",
      "state_id": "f960d3c2-8524-4a41-b8eb-055ce4be2a7f",
      "type_id": "2d9d1a97-5c6f-4a1e-9d5b-8c2f7e30b6a4",
      "assignee_ids": ["16c61a3a-512a-48ac-b0be-b6b46fe6f430"],
      "label_ids": ["c1b8f3d6-9a44-4e12-8f7a-2b6d5c9e1a03"],
      "parent_id": null,
      "start_date": "2026-01-12",
      "target_date": "2026-01-20",
      "is_draft": false,
      "archived_at": null,
      "created_at": "2026-01-14T09:22:41.478363Z",
      "created_by_id": "16c61a3a-512a-48ac-b0be-b6b46fe6f430",
      "custom_fields": null
    }
  ],
  "next_cursor": "b3A9MTcxJmxpbWl0PTUw",
  "has_more": true,
  "pagination": { "style": "cursor" }
}

Why custom_fields is null here

Custom property values are resolved per work item. Doing that for a full page would mean an extra lookup pass for every row, so the list endpoint skips it and returns custom_fields: null on every item — deliberately, not because the values are missing.

To read custom property values, fetch the work item on its own: Get a work item or Get by identifier. Both populate custom_fields.

Paging through everything

Branch your client on pagination.style rather than guessing from the keys present.

  • Offset — follow the integer in next until it is null. Fine for the first few thousand rows; offset caps at 10000.
  • Cursor — send ?paginate=cursor, then follow next_cursor while has_more is true. There is no total_count, and rows inserted mid-traversal won't shift your position.

Full details in Pagination.