Expanding relations
v2 reads are sparse by default. A response contains scalar values plus identifiers for everything related to it, not an embedded object tree:
- A to-one relation comes back as
<name>_id:state_id,type_id,parent_id,created_by_id. - A to-many relation comes back as
<name>_ids, an array:assignee_ids,label_ids.
?expand= adds full objects for specific relations to a single request. It takes a comma-separated list:
GET .../work-items/?expand=state,assigneesExpansion is separate-key
?expand= adds a key. It never replaces one.
?expand=state keeps state_id where it was and adds a separate state object beside it. The identifier is not replaced by an object, and it does not disappear. Both are present in the same response.
Most APIs that offer expansion do the opposite: the state key holds a UUID string when you do not expand and an object when you do. That design forces every consumer to write typeof x === "string" ? x : x.id wherever the field is used, and the shape of the response depends on a query parameter that another part of your codebase may have set.
v2 uses two keys, each with a fixed type:
| Key | Type | Present when |
|---|---|---|
state_id | string (uuid) | Always |
state | object | Only with ?expand=state |
Why this matters for your client
- Each field has one type.
state_idis astringin every response, so you never need a union of a string and an object, or a type guard to tell them apart. - Expansion only adds keys, so changing it is safe. A caller can add or drop
?expand=without breaking code that readsstate_id. Caches, join tables, and foreign keys keyed by identifier keep working. - In a typed client, the expanded key is optional (
state?: State). The alternative is a discriminated union ofstring | State, which every consumer has to narrow. - Both keys are present, so
item.state.idanditem.state_idalways match.
Read identifiers from *_id / *_ids, and use expanded objects only for display, to save a round trip.
Where ?expand= is supported
Each resource declares its own allowlist. This is the complete list.
| Resource | Allowed expand values |
|---|---|
| Work items | state, type, parent, assignees, labels, cycle, modules, estimate_point |
| Projects | project_lead, default_assignee |
| Cycles | owned_by |
| Modules | lead, members |
| Initiatives | lead |
| Teamspaces | lead |
| Releases and project releases | lead, tag |
| Estimates | points |
| Comments, work item activities, and work item votes | actor |
| Worklogs | logged_by |
| Project pages, workspace pages, collection pages, and published pages | owned_by, parent |
| Work item pages | page |
| Collections | owned_by |
| Project views and workspace views | owned_by |
| Workspace members, project members, collection members, teamspace members | member |
| Roles | permission_schemes |
| Notifications | triggered_by |
A child list accepts the expand values of the resource whose rows it returns. The work item lists under initiatives, releases, and customers accept the work item values. The project lists under initiatives and teamspaces accept the project values.
A resource with no expandable relations rejects every value
States, labels, milestones, work item types, properties, property options, property contexts, webhooks, workspace features and audit logs declare no expandable relations. These resources do not ignore ?expand=: any value you pass is unknown to them, and unknown values are rejected.
Passing a value the resource does not declare returns a 400:
{
"type": "invalid_request",
"code": "invalid_request",
"detail": "One or more fields failed validation.",
"errors": [
{
"field": "expand",
"code": "invalid",
"message": "Unknown expand value(s): project."
}
]
}?expand= is in the published OpenAPI schema
The parameter is declared per operation in the served OpenAPI document at /api/v2/schema/, with its allowlist as an enum. Generated SDKs and MCP tool definitions therefore constrain the value up front instead of discovering it from a 400, and a schema-driven validator will not strip it.
Composing with ?fields=
?expand= and ?fields= are separate namespaces and do not interact.
?fields=id,name&expand=state → { "id": "…", "name": "…", "state": { … } }state_id is a field. state is an expandable relation. Because they are different keys in different namespaces:
- Filtering fields never drops an expansion.
stateis returned even thoughstate_idwas not requested. - There is no precedence rule to learn, and no order dependency between the two parameters.
The namespaces are also strict in both directions. A relation name is not a valid ?fields= token, and a field name is not a valid ?expand= value. The 400 response tells you which parameter you meant:
{
"type": "invalid_request",
"code": "invalid_request",
"detail": "One or more fields failed validation.",
"errors": [
{
"field": "fields",
"code": "invalid",
"message": "'state' is an expandable relation, not a response field — use ?expand=state to embed the object, or ?fields=state_id for just its id."
}
]
}Why not unify them?
If ?fields=state implied an expansion, the parameters would interact the way they did in v1. There, expansion replaced a field, so filtering fields also removed the expansion, with no error. Keeping the namespaces separate means you can combine both parameters and get predictable results.
Before and after
Here is the same work item list, first sparse and then expanded.
Sparse (default)
curl "https://api.plane.so/api/v2/workspaces/my-team/projects/4af68566-94a4-4eb3-94aa-50dc9427067b/work-items/" \
-H "X-Api-Key: $PLANE_API_KEY"{
"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": null,
"previous": null,
"total_count": 1,
"pagination": { "style": "offset" }
}Expanded
curl "https://api.plane.so/api/v2/workspaces/my-team/projects/4af68566-94a4-4eb3-94aa-50dc9427067b/work-items/?expand=state,assignees" \
-H "X-Api-Key: $PLANE_API_KEY"{
"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",
"state": {
"id": "f960d3c2-8524-4a41-b8eb-055ce4be2a7f",
"name": "In Progress",
"color": "#3f76ff",
"group": "started"
},
"assignees": [
{
"id": "16c61a3a-512a-48ac-b0be-b6b46fe6f430",
"display_name": "Priya Raghavan",
"avatar_url": "https://assets.plane.so/avatars/16c61a3a.png",
"email": "priya@example.com"
}
]
}
],
"next": null,
"previous": null,
"total_count": 1,
"pagination": { "style": "offset" }
}In the expanded response, state_id is still "f960d3c2-…" and assignee_ids is still ["16c61a3a-…"]. The two new keys appear beside them.
Shape of each expanded object
Expanded objects are minimal on purpose: they hold enough to render a row without a second request. An expanded object never carries its own expansions or nested relations.
Work items
expand value | Adds key | Shape |
|---|---|---|
state | state | id, name, color, group |
type | type | id, name, logo_props, is_epic |
parent | parent | id, name, sequence_id |
assignees | assignees | Array of id, display_name, avatar_url, email |
labels | labels | Array of id, name, color |
estimate_point | estimate_point | id, key, value, description |
cycle | cycle | id, name |
modules | modules | Array of id, name |
Members
expand value | Adds key | Shape |
|---|---|---|
member | member | id, display_name, avatar_url, email |
curl "https://api.plane.so/api/v2/workspaces/my-team/members/?expand=member" \
-H "X-Api-Key: $PLANE_API_KEY"{
"data": [
{
"id": "c1b7e4a9-2f66-4a1d-9c3b-7d5e2f8a1b40",
"member_id": "16c61a3a-512a-48ac-b0be-b6b46fe6f430",
"role": "admin",
"member": {
"id": "16c61a3a-512a-48ac-b0be-b6b46fe6f430",
"display_name": "Priya Raghavan",
"avatar_url": "https://assets.plane.so/avatars/16c61a3a.png",
"email": "priya@example.com"
}
}
],
"next": null,
"previous": null,
"total_count": 1,
"pagination": { "style": "offset" }
}The identifier stays here too: member_id is the user's id, and member is the object.
Every other expansion that returns a user, such as lead, owned_by, actor, logged_by, or triggered_by, has the same shape as member.
Other relations
expand value | On | Shape |
|---|---|---|
page | Work item pages | id, name, access, is_locked, archived_at |
parent | Pages | id, name, access, is_locked, archived_at |
permission_schemes | Roles | Array of id, name, slug, namespace, is_system |
tag | Releases, project releases | id, version, git_tag, commit_hash |
Behavior notes
- A to-many expansion is always an array, never
null. With?expand=assignees, an unassigned work item gets"assignees": []. - A to-one expansion follows its id. If
parent_idisnull,?expand=parentproduces"parent": null. - Expansion works on detail routes as well as lists.
GET .../work-items/{pk}/?expand=state,labelsbehaves the same way. - Expanding a list does not cost a query per row. The relations are loaded in bulk, so
?expand=assigneeson a 200-row page is one additional query, not 200. - Expansion is read-only. Writes always take ids (
state_id,assignee_ids). Sending a nested object on aPOSTorPATCHdoes not create or link anything.
Related
- Filtering and ordering: filter on the same relations with
state_id,assignee_id, and similar parameters - Pagination: the envelope that wraps every expanded list
- Migrating from v1: what to do about v1 responses that embedded these objects by default

