Skip to content

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:

bash
GET .../work-items/?expand=state,assignees

Expansion 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:

KeyTypePresent when
state_idstring (uuid)Always
stateobjectOnly with ?expand=state

Why this matters for your client ​

  • Each field has one type. state_id is a string in 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 reads state_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 of string | State, which every consumer has to narrow.
  • Both keys are present, so item.state.id and item.state_id always 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.

ResourceAllowed expand values
Work itemsstate, type, parent, assignees, labels, cycle, modules, estimate_point
Projectsproject_lead, default_assignee
Cyclesowned_by
Moduleslead, members
Initiativeslead
Teamspaceslead
Releases and project releaseslead, tag
Estimatespoints
Comments, work item activities, and work item votesactor
Worklogslogged_by
Project pages, workspace pages, collection pages, and published pagesowned_by, parent
Work item pagespage
Collectionsowned_by
Project views and workspace viewsowned_by
Workspace members, project members, collection members, teamspace membersmember
Rolespermission_schemes
Notificationstriggered_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:

Response400
json
{
  "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. state is returned even though state_id was 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:

json
{
  "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)

bash
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"
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": null,
  "previous": null,
  "total_count": 1,
  "pagination": { "style": "offset" }
}

Expanded

bash
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"
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",
      "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 valueAdds keyShape
statestateid, name, color, group
typetypeid, name, logo_props, is_epic
parentparentid, name, sequence_id
assigneesassigneesArray of id, display_name, avatar_url, email
labelslabelsArray of id, name, color
estimate_pointestimate_pointid, key, value, description
cyclecycleid, name
modulesmodulesArray of id, name

Members ​

expand valueAdds keyShape
membermemberid, display_name, avatar_url, email
bash
curl "https://api.plane.so/api/v2/workspaces/my-team/members/?expand=member" \
  -H "X-Api-Key: $PLANE_API_KEY"
Response200
json
{
  "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 valueOnShape
pageWork item pagesid, name, access, is_locked, archived_at
parentPagesid, name, access, is_locked, archived_at
permission_schemesRolesArray of id, name, slug, namespace, is_system
tagReleases, project releasesid, 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_id is null, ?expand=parent produces "parent": null.
  • Expansion works on detail routes as well as lists. GET .../work-items/{pk}/?expand=state,labels behaves the same way.
  • Expanding a list does not cost a query per row. The relations are loaded in bulk, so ?expand=assignees on 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 a POST or PATCH does not create or link anything.
  • 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