List states
Return the states in a project as a paginated list. This is how you resolve a state name to the state_id you need when creating or moving a work item, and how you build a state picker that stays in sync with the project's workflow.
Results are scoped to a single project — states are never shared across projects, so there is no workspace-wide state list.
Path Parameters
slug:requiredstringThe 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 whose states you want to list.
Query Parameters
Filters combine with AND. The enum-backed filters group and group__in are validated: an unrecognized value is rejected with a 400 validation_error instead of quietly returning an empty list, so a typo surfaces immediately rather than looking like "no results". order_by and paginate are not 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.
group:optionalstringReturn only states in this workflow group. One of backlog, unstarted, started, completed, cancelled, or triage.
Use the group__in variant to match several groups at once, passing them comma-separated — ?group__in=started,completed. Filtering by group is the portable way to ask "what counts as in progress here", because every project names its states differently but the groups are fixed.
is_default:optionalbooleanReturn only the project's default state (true) or only the non-default states (false). Pairing ?is_default=true with ?per_page=1 is the cheapest way to find where new work items will land.
external_id:optionalstringReturn states whose external_id matches exactly. Use it to find the state you previously created for a record in another system.
external_source:optionalstringReturn states that came from a particular system, for example github or jira. Combine it with external_id — an external_id is only unique within its source.
search:optionalstringA search term matched against the state name.
Ordering
order_by:optionalstringField to sort by. Prefix with - for descending.
sequence,-sequence— the project's own workflow ordercreated_at,-created_at— when each state was addedid,-id
Order by sequence when you are rendering the workflow to a user; it is the order the project itself uses.
Pagination
per_page:optionalintegerPage size. Defaults to 50, maximum 200. Most projects have fewer than 20 states, so one page is usually the whole workflow.
offset:optionalintegerNumber of rows to skip from the start of the result set. Maximum 10000. Read the next value from the response rather than computing offsets yourself.
paginate:optionalstringSet to cursor to opt into the COUNT-free keyset envelope, which returns next_cursor and has_more instead of next and total_count. Omit it for the default offset envelope.
count:optionalbooleanDefaults to true. Set to false to skip the COUNT(*) behind total_count; the field is then omitted from the response.
Scopes
projects.states:read
Errors
| Status | Code | Cause |
|---|---|---|
401 | unauthorized | Missing or invalid credentials. |
403 | forbidden | Your role or token scope can't read this project's states. |
404 | resource_not_found | No such workspace or project, or it's outside your tenant. |
429 | rate_limited | Throttled. Honor the Retry-After header before retrying. |
curl -X GET \
"https://api.plane.so/api/v2/workspaces/my-team/projects/4af68566-94a4-4eb3-94aa-50dc9427067b/states/?order_by=sequence&per_page=50" \
-H "X-Api-Key: $PLANE_API_KEY"import requests
response = requests.get(
"https://api.plane.so/api/v2/workspaces/my-team/projects/4af68566-94a4-4eb3-94aa-50dc9427067b/states/",
headers={"X-Api-Key": "your-api-key"},
params={"order_by": "sequence", "per_page": 50},
)
print(response.json())const params = new URLSearchParams({ order_by: "sequence", per_page: "50" });
const response = await fetch(
`https://api.plane.so/api/v2/workspaces/my-team/projects/4af68566-94a4-4eb3-94aa-50dc9427067b/states/?${params}`,
{
headers: {
"X-Api-Key": "your-api-key",
},
}
);
const data = await response.json();{
"data": [
{
"id": "2c4d16f8-9b3e-4a52-8d71-1f0e6c9a5b48",
"name": "Backlog",
"description": "Not yet scheduled",
"color": "#8b8d98",
"group": "backlog",
"sequence": 15000,
"is_default": true,
"is_triage": false,
"external_id": null,
"external_source": null,
"created_at": "2026-01-14T09:22:41.478363Z",
"created_by_id": "16c61a3a-512a-48ac-b0be-b6b46fe6f430"
},
{
"id": "f960d3c2-8524-4a41-b8eb-055ce4be2a7f",
"name": "In Progress",
"description": "Actively being worked on",
"color": "#3f76ff",
"group": "started",
"sequence": 25000,
"is_default": false,
"is_triage": false,
"external_id": null,
"external_source": null,
"created_at": "2026-01-14T09:22:41.512907Z",
"created_by_id": "16c61a3a-512a-48ac-b0be-b6b46fe6f430"
},
{
"id": "7b1e9d40-3c86-4f2a-9a5d-8e2b0c47d613",
"name": "Done",
"description": "Shipped",
"color": "#26a05f",
"group": "completed",
"sequence": 35000,
"is_default": false,
"is_triage": false,
"external_id": null,
"external_source": null,
"created_at": "2026-01-14T09:22:41.548221Z",
"created_by_id": "16c61a3a-512a-48ac-b0be-b6b46fe6f430"
}
],
"next": null,
"previous": null,
"total_count": 3,
"pagination": {
"style": "offset"
}
}{
"data": [
{
"id": "2c4d16f8-9b3e-4a52-8d71-1f0e6c9a5b48",
"name": "Backlog",
"description": "Not yet scheduled",
"color": "#8b8d98",
"group": "backlog",
"sequence": 15000,
"is_default": true,
"is_triage": false,
"external_id": null,
"external_source": 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"
}
}Read the group, not the name
A project can rename In Progress to Building at any time, and reporting keeps working because boards and burndowns read group. Write integrations the same way: match on group, and treat name as a label for humans.

