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: urgent → high → medium → low → none, 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: assignees (the assigned users), cycle (the cycle it belongs to), labels (the applied labels), modules (the modules it belongs to), parent (its parent work item), state (the work item's state object), type (its work item type).

Expansion is separate-key: ?expand=state keeps state_id and adds a state object next to it, so an id is never replaced by an object. An unknown value is a 400.

?fields= and ?expand= are independent namespaces. Relation names are not valid ?fields= tokens (and vice versa), and an expanded object survives field filtering — ?fields=id,name&expand=state returns id, name and state. See Expanding relations.

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 invalid_request 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.

Response shaping ​

fields:optionalstring

Comma-separated list of fields to return on each row. Unrequested keys are omitted from the response, not returned as null, so absent means "not requested" and null means "actually null". id always comes back whether or not you name it.

Pass all for every requestable field. An unknown name is a 400 that lists the valid set and suggests the closest match, so a typo can't silently cost you the saving.

Requestable here: archived_at, assignee_ids, created_at, created_by_id, cycle_id, id, identifier, is_draft, label_ids, module_ids, name, parent_id, priority, project_id, sequence_id, start_date, state_id, target_date, type_id.

Naming custom_fields here is a 400 — it is only available on single-object responses.

See Sparse fields.

Scopes ​

projects.work_items:read

Errors ​

StatusCodeCause
401unauthorizedMissing or invalid credentials.
402payment_requiredThe feature this endpoint belongs to isn't enabled on your plan, or is switched off.
403forbiddenYour role or token scope can't read work items in this project.
404not_foundNo such workspace or project, or it's outside your tenant.
406not_acceptableThe Accept header asks for a representation the API can't produce.
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"
    },
    {
      "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"
    }
  ],
  "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"
    }
  ],
  "next_cursor": "b3A9MTcxJmxpbWl0PTUw",
  "has_more": true,
  "pagination": { "style": "cursor" }
}

Why custom_fields is absent here ​

Custom property values are resolved per work item, and doing that for a full page would mean an extra lookup pass for every row. So custom_fields is detail-only: the key is simply not present on list rows.

Absent, not null. A null would be ambiguous — it can't distinguish "this work item has no property values" from "this endpoint didn't look". Omitting the key says the second thing honestly. Asking for it anyway is a 400 rather than a silent empty answer:

json
{
  "type": "invalid_request",
  "code": "invalid_request",
  "detail": "One or more fields failed validation.",
  "errors": [
    {
      "field": "fields",
      "code": "invalid",
      "message": "custom_fields is not available on collection responses; retrieve the work item."
    }
  ]
}

To read custom property values, fetch the work item on its own: Get a work item or Get by identifier. Both populate custom_fields, as do create, update, upsert and the archive verbs — every single-object response.

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.