Skip to content

Migrating from v1 ​

v2 is a separate API surface added alongside v1. v1 keeps working and is not being switched off, so you can move one call at a time. Both versions can run side by side in the same integration against the same workspace.

This page lists what to change when you move a call to v2.

At a glance ​

v1v2
Path/api/v1/…/api/v2/…
Auth headerX-API-KeyX-Api-Key
Update verbPUT or PATCHPATCH only
ErrorsAd-hoc JSON bodiesRFC 9457 application/problem+json with stable code
Relations on readEmbedded objects*_id / *_ids, plus ?expand= where the resource allows it
List enveloperesults + cursor stringsdata + pagination.style
Sparse fieldsets?fields=?fields=, which rejects unknown names with 400

Unchanged: the base URL (https://api.plane.so), the trailing slash on every path, JSON in and out, and the fine-grained OAuth scope names (projects.work_items:read, projects.states:write, and so on).

1. Change the path ​

/api/v1/ becomes /api/v2/. The rest of the URL (workspace slug, project id, trailing slash) works the same way.

diff
- https://api.plane.so/api/v1/workspaces/my-team/projects/{project_id}/work-items/
+ https://api.plane.so/api/v2/workspaces/my-team/projects/{project_id}/work-items/

2. Auth header casing ​

v1 docs use X-API-Key. v2 uses X-Api-Key.

HTTP header names are case-insensitive, so the change is cosmetic. An existing X-API-Key header keeps working against v2, and client libraries treat both spellings the same. Switch to the new casing the next time you edit that code; it does not need its own migration step.

OAuth is unchanged: Authorization: Bearer <access_token>.

3. PUT is gone ​

v2 has no PUT. Every update is a PATCH, and a PATCH is partial: it changes only the fields you send.

diff
- PUT  /api/v1/workspaces/my-team/projects/{project_id}/states/{pk}/
+ PATCH /api/v2/workspaces/my-team/projects/{project_id}/states/{pk}/

Sending PUT returns 405 with the code method_not_allowed.

Because updates are partial, you no longer have to read the object, modify it, and write it back. To change one field, send one field. An omitted key is not the same as null: leaving a key out means "don't touch it", and sending "target_date": null means "clear it".

4. Errors are problem+json ​

Every v2 error is application/problem+json with the same three keys (type, code and detail), plus errors[] on validation failures:

Response400
json
{
  "type": "invalid_request",
  "code": "invalid_request",
  "detail": "One or more fields failed validation.",
  "errors": [
    {
      "field": "name",
      "code": "required",
      "message": "This field is required."
    }
  ]
}

Branch on code. It is the stable, machine-readable part, and it is always present. type is a general category from a closed set of 13 values; use it as the fallback for a code you do not recognize. detail is prose that can be reworded at any time. errors[] appears only on invalid_request, and each entry carries field, code and message.

The body has no status or title

If you built against an early v2 preview, status and title were removed from the body, and type changed from a https://api.plane.so/errors/… URI to a bare slug. Read the HTTP status from the status line.

Other behavior to account for when you port error handling:

  • A resource outside your tenant returns 404 not_found, never 403. v2 does not reveal whether the resource exists.
  • DELETE returns 204 with an empty body.
  • Uniqueness and protected-state conflicts return 409 conflict, for example a duplicate state name, deleting a project's default state, or deleting a state that still holds work items.
  • Throttling returns 429 rate_limited. Wait for the Retry-After interval before you retry.

See Errors for the full code list.

5. Reads are sparse ​

This is the largest behavioral change. v1 embedded related objects in the response; v2 returns identifiers.

diff
- "state": { "id": "f960d3c2-…", "name": "In Progress", "group": "started" }
+ "state_id": "f960d3c2-…"

- "assignees": [ { "id": "16c61a3a-…", "display_name": "Priya Raghavan" } ]
+ "assignee_ids": ["16c61a3a-…"]

To-one relations come back as <name>_id; to-many relations as <name>_ids arrays. That makes every response a predictable, flat shape and keeps list pages cheap.

When you need the object, add ?expand=. It adds a separate key: ?expand=state keeps state_id and adds a state object beside it, so nothing you already read stops working.

Each resource declares its own allowlist. Work items expand state, type, parent, assignees, labels, cycle, modules, and estimate_point. Cycles expand owned_by, modules expand lead and members, comments expand actor, and so on. A resource that declares none rejects every value, and an unknown value always returns 400. The full table is in Expanding relations.

You usually need fewer objects than v1 gave you

v1 embedded relations whether or not you used them. Before you add ?expand=, check whether your code only used the id. For cache keys, joins, and foreign keys it almost always did.

?fields= trims further ​

v1 had a ?fields= parameter, and so does v2, but v2 is stricter in two ways:

  • Unrequested keys are omitted, not set to null. In v2, an absent key means "not requested" and null means the value is null. Code that reads row.target_date and expects the key to exist needs to check that the key is present.
  • Unknown names return 400. v1 ignored them without an error. A typo that used to return the full row now returns an error, so a misspelled field name can no longer undo the size reduction without telling you.

id is always returned, and all returns every requestable field. Some resources also leave large fields such as description_html off list rows by default; name the field to include it. See Sparse fields.

bash
curl "https://api.plane.so/api/v2/workspaces/my-team/projects/ENG/work-items/?fields=id,name,state_id" \
  -H "X-Api-Key: $PLANE_API_KEY"

6. Writes take ids ​

v2 write payloads take identifiers, never nested objects: state_id, parent_id, assignee_ids, label_ids, modules' lead_id. An id that belongs to another project or workspace returns 400, so the write never links to a record that isn't there.

Audit fields (created_at, created_by_id, and similar) are read-only. Sending them has no effect.

7. The pagination envelope changed ​

v1 returned results with cursor strings like 20:1:0. v2 returns data inside an envelope that declares its own style.

v2 offset (the default):

json
{
  "data": [],
  "next": 50,
  "previous": null,
  "total_count": 327,
  "pagination": { "style": "offset" }
}

v2 cursor (opt in with ?paginate=cursor):

json
{
  "data": [],
  "next_cursor": "b3A9MTcx",
  "has_more": true,
  "pagination": { "style": "cursor" }
}

Port your paginator to read data, then branch on pagination.style instead of checking which keys are present. On the offset path, next and previous are integer offsets, not tokens or URLs. per_page defaults to 50 and has a maximum of 200. offset has a maximum of 10000; at that point, switch to cursor. See Pagination.

8. Renamed and removed fields ​

Search your codebase for these fields.

Resourcev1v2
Statesdefaultis_default
Statescreated_bycreated_by_id
Statesupdated_byupdated_by_id
Statesproject, workspaceNot returned
Work itemscreated_bycreated_by_id
Work itemsupdated_byupdated_by_id
Work itemsprojectproject_id
Work itemsworkspaceNot returned
Work itemsdescription_html on readOn single-object responses. On list rows only when ?fields= names it
Work itemsNot presentcustom_fields, present only on single-object responses

Two of these changes need extra care:

description_html is off work item list rows by default

A work item's description_html comes back on every single-object response: retrieve, create, update, upsert, and the archive verbs. List rows leave it out unless you ask for it with ?fields=description_html or ?fields=all, because an unbounded HTML body on every row makes a large page expensive. A list loop ported from v1 that reads descriptions needs the ?fields= parameter in v2.

custom_fields is absent on list responses

custom_fields carries a work item's custom property values, and it is present only on single-object responses: retrieve, create, update, upsert and the archive verbs. List responses omit the key entirely (it is not null), because resolving properties per row would mean a query per work item. Requesting it on a list with ?fields=custom_fields returns 400 instead of an empty value. If you need property values for many items, list first and then fetch the ones you care about individually.

The state group enum also adds triage to v1's five groups.

9. Side by side: listing work items ​

The same call in both versions.

v1 ​

bash
curl "https://api.plane.so/api/v1/workspaces/my-team/projects/4af68566-94a4-4eb3-94aa-50dc9427067b/work-items/?per_page=20&expand=state,assignees" \
  -H "X-API-Key: $PLANE_API_KEY"
Response200
json
{
  "total_count": 327,
  "next_cursor": "20:1:0",
  "prev_cursor": "20:0:0",
  "next_page_results": true,
  "prev_page_results": false,
  "count": 20,
  "total_pages": 17,
  "results": [
    {
      "id": "8f4c2b1e-0d3a-4f7b-9c21-6e5a8b7d4f13",
      "name": "Fix login redirect",
      "description": "",
      "priority": "high",
      "sequence_id": 118,
      "state": {
        "id": "f960d3c2-8524-4a41-b8eb-055ce4be2a7f",
        "name": "In Progress",
        "group": "started"
      },
      "assignees": [
        { "id": "16c61a3a-512a-48ac-b0be-b6b46fe6f430", "display_name": "Priya Raghavan" }
      ],
      "labels": [],
      "created_at": "2026-01-14T09:22:41.478363Z"
    }
  ]
}

v2 ​

bash
curl "https://api.plane.so/api/v2/workspaces/my-team/projects/4af68566-94a4-4eb3-94aa-50dc9427067b/work-items/?per_page=20&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": 20,
  "previous": null,
  "total_count": 327,
  "pagination": { "style": "offset" }
}

The differences:

  • results → data; the cursor strings are replaced by an integer next plus a pagination.style discriminator.
  • state → state_id, and assignees → assignee_ids. ?expand= adds the state and assignees objects beside the ids; it does not replace them.
  • description is gone from the read shape.
  • New on every work item: identifier (PROJ-118), type_id, is_draft, archived_at, and created_by_id. custom_fields is absent here because this is a list. It appears on single-object responses.

10. What is new in v2 ​

These capabilities have no v1 equivalent, so there is no v1 code to port. Design for them directly.

Work item types and custom properties ​

Define types (Bug, Incident) and typed custom properties (TEXT, DATETIME, DECIMAL, BOOLEAN, OPTION, RELATION, URL, EMAIL, FILE, FORMULA), attach properties to types, and read a type's full writable schema in one call. A workspace runs these at either the project or the workspace level. Start at Work item type modes, because writing to the wrong surface returns 409.

Property options and contexts ​

OPTION properties carry their own managed choice lists. In workspace mode, a property context narrows where a workspace property applies and can override is_required, is_multi, or default_value for a specific set of projects and types. See Property options and Property contexts.

Workspace features ​

Read and toggle workspace-level capabilities through the API instead of the UI: GET and PATCH /api/v2/workspaces/{slug}/features/. See Workspace features.

Audit logs ​

Query the workspace audit trail with filters for actor, category, event name, target, outcome, IP address, and time range: GET /api/v2/workspaces/{slug}/audit-logs/. See Audit logs.

Fetch a work item by its PROJ-123 identifier ​

The human-readable key shown in the Plane UI is directly addressable, without knowing the project id or resolving a UUID first:

bash
curl "https://api.plane.so/api/v2/workspaces/my-team/work-items/PROJ-118/" \
  -H "X-Api-Key: $PLANE_API_KEY"

This is the fastest way to turn something a person typed, such as a ticket reference in a chat message, a commit trailer, or a support email, into a work item. See Get a work item by identifier.

11. Routes that moved ​

Most v1 routes keep their path under /api/v2/. The table lists the main routes that moved or changed their verb. Each path in the table starts with /api/v1/workspaces/{slug}/ or /api/v2/workspaces/{slug}/.

v1v2
GET projects/{project_id}/archived-work-items/GET projects/{project_id}/work-items/?archived=only
GET projects/{project_id}/archived-cycles/GET projects/{project_id}/cycles/?is_archived=true
DELETE projects/{project_id}/archived-cycles/{cycle_id}/unarchive/POST projects/{project_id}/cycles/{pk}/unarchive/
GET projects/{project_id}/archived-modules/GET projects/{project_id}/modules/?is_archived=true
DELETE projects/{project_id}/archived-modules/{pk}/unarchive/POST projects/{project_id}/modules/{pk}/unarchive/
DELETE pages/{page_id}/archive/POST pages/{pk}/unarchive/
GET projects/{project_id}/pages/published/{anchor}/GET published-pages/{anchor}/
POST assets/ with entity_type: PAGE_DESCRIPTIONPOST pages/{page_id}/attachments/
GET customers/{customer_id}/issues/GET customers/{pk}/work-items/
POST customers/{customer_id}/issues/ and DELETE customers/{customer_id}/issues/{issue_id}/POST customers/{pk}/work-items/ with add and remove
projects/{project_id}/issues/…, issue-types/, and …/issue-attachments/projects/{project_id}/work-items/…, work-item-types/, and …/attachments/
projects/{project_id}/cycles/{cycle_id}/cycle-issues/projects/{project_id}/cycles/{pk}/work-items/, or ?cycle_id= on the work item list
POST projects/{project_id}/cycles/{cycle_id}/transfer-issues/POST projects/{project_id}/cycles/{pk}/transfer/
projects/{project_id}/modules/{module_id}/module-issues/projects/{project_id}/modules/{pk}/work-items/, or ?module_id= on the work item list
projects/{project_id}/epics/ and epics/{epic_id}/issues/Work items: filter by the epic type with ?type_id=, and list an epic's children with ?parent_id=
GET initiatives/{initiative_id}/epics/GET initiatives/{pk}/work-items/
GET projects/{project_id}/total-worklogs/GET projects/{project_id}/worklogs/summary/
workitems/templates/ and projects/{project_id}/workitems/templates/work-item-templates/ and projects/{project_id}/work-item-templates/
projects/{project_id}/estimates/{estimate_id}/estimate-points/projects/{project_id}/estimates/{estimate_id}/points/
POST projects/{project_id}/import-work-item-types/POST projects/{project_id}/work-item-types/import/
GET projects/{project_id}/work-item-types/schema/GET projects/{project_id}/work-item-types/{pk}/schema/
PATCH projects/{project_id}/intake-issues/{issue_id}/status/PATCH projects/{project_id}/intake-issues/{pk}/ with status in the body
pages/templates/page-templates/
projects/{project_id}/pages/templates/projects/{project_id}/page-templates/
projects/templates/ and projects/templates/copy/project-templates/ and project-templates/copy/
POST projects/templates/use/ with template_id in the bodyPOST project-templates/{pk}/use/
POST project-states/{pk}/default/POST project-states/{pk}/mark-default/
PUT projects/{project_id}/work-item-types/{type_id}/workflow/PATCH projects/{project_id}/work-item-types/{type_id}/workflow/
GET projects/{project_id}/work-item-types/{type_id}/workflows/GET projects/{project_id}/work-item-types/{type_id}/workflow/
releases/{release_id}/changelog/, labels/, work-items/, comments/, and links/ for a project releaseThe same subpaths under projects/{project_id}/releases/{pk}/

Behavior that changed with the move:

  • Release labels and work items are added and removed in one call: a POST with {"add": [...], "remove": [...]}. v1 used POST and DELETE with label_ids or work_item_ids.
  • The work item, project, label, and member lists under initiatives, releases, customers, teamspaces, and collections return full rows, in the same shape as the resource's own list, with ?fields= and ?expand=. They are paginated, 50 rows a page by default.
  • Governance conflicts return 409. In an ungoverned workspace, the workspace workflow catalog, workspace state writes, and the type governance endpoints return 409 states_managed_at_project, where v1 returned 400. A change that strands work items returns 409 workflow_state_orphans. See Errors.
  • Feature gates return problem+json. A 402 carries code: "payment_required", where v1 returned {"error": ..., "error_code": 1999}.
  • Archiving a cycle that hasn't ended, or a module that isn't completed or cancelled, returns 409. v1 returned 400.
  • Notifications cover every entity, so v2 returns the whole inbox. Add ?entity_name=issue to get v1's set. entity_identifier is now entity_id, and the inline triggered_by object is now triggered_by_id, with ?expand=triggered_by.
  • Recurring work items still can't be paused. As in v1, enabled is read-only, and neither version has a pause or resume route.

12. Not yet in v2 ​

v2 covers nearly every resource v1 does, with these exceptions:

  • v1's AI agent routes, agent runs, and agent run activities have no v2 equivalent yet.
  • The integration routes behind Plane's importers and integrations, such as workspace credentials, workspace connections, and import jobs, are not part of v2.
  • v1 has a server-side /server/ variant of each two-step upload route for callers that can't use a presigned URL. v2 has one upload flow per asset kind.
  • v1's work-items/count/ can return grouped work item counts, one per group. In v2, total_count on the list envelope is one number, so a count per group takes one call per group.
  • v1's workflow approval and state transfer routes, work-items/{work_item_id}/workflow-approval/ and workflows/{workflow_id}/states/{state_id}/transfer/, have no v2 route.
  • v1's work item search routes, work-items/search/, issues/search/, and work-items/advanced-search/, have no v2 route. Use ?search= on the work item list.
  • v1's lite lists, projects-lite/, members-lite/, project-members-lite/, cycles-lite/, and modules-lite/, have no v2 route. Use the full list with ?fields= to trim each row.
  • v2's ?search= on work items matches the name only. v1 also matches the description and the sequence id.

Run both versions side by side

You don't have to migrate everything at once. Point the calls that v2 covers at /api/v2/ and leave the rest on /api/v1/. The same API key authenticates both. For the v1 surface, see the v1 introduction.

Migration checklist ​

  1. Swap /api/v1/ for /api/v2/ on the calls v2 covers.
  2. Replace every PUT with PATCH, and trim the payload to the fields that change.
  3. Rewrite error handling to read code from the problem+json body.
  4. Update your paginator: read data, branch on pagination.style, treat next as an integer offset.
  5. Replace embedded relation reads with *_id / *_ids, adding ?expand= only where you need the object and only where it is supported.
  6. Rename state default to is_default, and rename updated_by to updated_by_id on states and work items. Read a work item's project from project_id, and stop reading workspace from state and work item responses.
  7. Add ?fields=description_html to work item list calls that read descriptions. Single-object responses include it already.
  8. Stop expecting custom_fields on list responses. Fetch the item individually when you need its property values.
  9. Update your error handling to the {type, code, detail} shape: v2 no longer sends status or title, and type is a bare category slug rather than a URI. See Errors.
  10. Use ?fields= where you only need a few keys. v1's ?fields= ignored unknown names without an error, but v2 returns 400, and v2 omits unrequested keys instead of setting them to null.
  11. Replace name-based lookups with the identity filters (?name=, ?key=, ?url=). v2 path segments take ids, not attributes. See Filtering and ordering.
  12. Confirm nothing you depend on is in the "not yet in v2" list above.