Migrating from v1
v2 is a separate, additive surface. v1 keeps working, nothing is being switched off underneath you, and you can move one call at a time — the two versions can run side by side in the same integration against the same workspace.
This page is the checklist for moving a call across.
At a glance
| v1 | v2 | |
|---|---|---|
| Path | /api/v1/… | /api/v2/… |
| Auth header | X-API-Key | X-Api-Key |
| Update verb | PUT or PATCH | PATCH only |
| Errors | Ad-hoc JSON bodies | RFC 9457 application/problem+json with stable code |
| Relations on read | Embedded objects | *_id / *_ids, plus ?expand= where the resource allows it |
| List envelope | results + cursor strings | data + 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/. Everything else about the URL — workspace slug, project id, trailing slash — works the same way.
- 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 standardizes on X-Api-Key.
HTTP header names are case-insensitive, so this is cosmetic — an existing X-API-Key header keeps working against v2, and no client library will care. Adopt the new casing when you touch the code, not as a migration step of its own.
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.
- 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.
Two things follow from partial updates:
- You no longer have to read-modify-write. To change one field, send one field.
- Omitted is not the same as
null. Leaving a key out means "don't touch it"; sending"target_date": nullmeans "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:
{
"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 coarse category from a closed 13-value set — use it as the fallback arm 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, note that 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 worth knowing while you port error handling:
- A resource outside your tenant is
404 not_found, never403. v2 does not leak existence. DELETEreturns204with an empty body.- Uniqueness and protected-state conflicts are
409 conflict— a duplicate state name, deleting a project's default state, deleting a state that still holds work items. - Throttling is
429 rate_limited; honorRetry-After.
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.
- "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.
Where you genuinely need the object, add ?expand=. It is 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 is always a 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 reaching for ?expand=, check whether the id is all your code actually consumed — for cache keys, joins, and foreign keys it almost always is.
?fields= trims further
v1 had a ?fields= parameter, and so does v2 — but the semantics are stricter in two ways worth porting carefully:
- Unrequested keys are omitted, not nulled. In v2, absent means "not requested" and
nullmeans "genuinely null". Code that readsrow.target_dateexpecting a key to exist needs to check presence instead. - Unknown names are a
400. v1 ignored them silently. A typo that used to return the full row now fails loudly — which is the point, since a silently-ignored field name costs you the saving without telling you.
id always comes back, and all returns the full requestable set. Some resources also leave heavy fields such as description_html off list rows by default; naming the field pulls it back. See Sparse fields.
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 belonging to another project or workspace is a clean 400 — it never silently links to nothing.
Audit fields (created_at, created_by_id, and friends) 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):
{
"data": [],
"next": 50,
"previous": null,
"total_count": 327,
"pagination": { "style": "offset" }
}v2 cursor (opt in with ?paginate=cursor):
{
"data": [],
"next_cursor": "b3A9MTcx",
"has_more": true,
"pagination": { "style": "cursor" }
}Port your paginator to read data, then branch on pagination.style rather than sniffing which keys are present. On the offset path next and previous are integer offsets, not tokens and not URLs. per_page defaults to 50 and caps at 200; offset caps at 10000, which is the point at which you should switch to cursor. See Pagination.
8. Renamed and removed fields
These are the concrete differences to grep your codebase for.
| Resource | v1 | v2 |
|---|---|---|
| States | default | is_default |
| States | created_by | created_by_id |
| States | updated_by | updated_by_id |
| States | project, workspace | Not returned |
| Work items | created_by | created_by_id |
| Work items | updated_by | updated_by_id |
| Work items | project | project_id |
| Work items | workspace | Not returned |
| Work items | description_html on read | On single-object responses. On list rows only when ?fields= names it |
| Work items | — | custom_fields, present only on single-object responses |
Details on the two that bite hardest:
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. On the list path the key is omitted entirely (not null), because resolving properties per row would mean a query per work item. Asking for it there with ?fields=custom_fields is a 400 rather than a silent empty answer. If you need property values for many items, list first and then fetch the ones you care about individually.
Also note: the state group enum gained triage alongside v1's five groups.
9. Side by side: listing work items
The same call in both versions.
v1
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"{
"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
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"{
"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" }
}Line up the differences:
results→data; the cursor strings are replaced by an integernextplus apagination.stylediscriminator.state→state_id, andassignees→assignee_ids. The expandedstateandassigneesobjects are added by?expand=, not substituted for the ids.descriptionis gone from the read shape.- New on every work item:
identifier(PROJ-118),type_id,is_draft,archived_at, andcreated_by_id.custom_fieldsis absent here because this is a list — it appears on single-object responses.
10. What is new in v2
Capabilities that have no v1 equivalent and are worth designing around rather than porting:
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 is a 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 slice 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 key that appears in the Plane UI is now directly addressable, without knowing the project id or resolving a UUID first:
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 — a ticket reference in a chat message, a commit trailer, 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}/.
| v1 | v2 |
|---|---|
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_DESCRIPTION | POST 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 body | POST 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 release | The same subpaths under projects/{project_id}/releases/{pk}/ |
Behavior that changed with the move:
- Add and remove in one call. Release labels and work items take one
POSTwith{"add": [...], "remove": [...]}, where v1 usedPOSTandDELETEwithlabel_idsorwork_item_ids. - Child lists return full rows. The work item, project, label, and member lists under initiatives, releases, customers, teamspaces, and collections return the same row shape as the resource's own list, with
?fields=and?expand=. They are paginated, 50 rows a page by default. - Governance conflicts are
409. In an ungoverned workspace, the workspace workflow catalog, workspace state writes, and the type governance endpoints return409 states_managed_at_project, where v1 returned400. A change that strands work items returns409 workflow_state_orphans. See Errors. - Feature gates are problem+json. A
402carriescode: "payment_required", where v1 returned{"error": ..., "error_code": 1999}. - Archive rules. Archiving a cycle that hasn't ended, or a module that isn't completed or cancelled, returns
409. v1 returned400. - Notifications cover every entity. v2 returns the whole inbox. Add
?entity_name=issueto get v1's set.entity_identifieris nowentity_id, and the inlinetriggered_byobject is nowtriggered_by_id, with?expand=triggered_by. - Recurring work items still can't be paused. As in v1,
enabledis 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:
- AI agents. v1's agent routes, agent runs, and agent run activities have no v2 equivalent yet.
- Integration routes. The routes behind Plane's importers and integrations, such as workspace credentials, workspace connections, and import jobs, are not part of v2.
- Server-side upload variants. v1 has a
/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. - Grouped work item counts. v1's
work-items/count/can return a count per group. In v2,total_counton the list envelope is one number, so a count per group takes one call per group. - Workflow approvals and state transfer. v1's
work-items/{work_item_id}/workflow-approval/andworkflows/{workflow_id}/states/{state_id}/transfer/have no v2 route. - Work item search routes. v1's
work-items/search/,issues/search/, andwork-items/advanced-search/have no v2 route. Use?search=on the work item list. - Lite lists. v1's
projects-lite/,members-lite/,project-members-lite/,cycles-lite/, andmodules-lite/have no v2 route. Use the full list with?fields=to trim each row. - Work item search scope. 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
There is no requirement to migrate wholesale. 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
- Swap
/api/v1/for/api/v2/on the calls v2 covers. - Replace every
PUTwithPATCH, and trim the payload to the fields that actually change. - Rewrite error handling to read
codefrom the problem+json body. - Update your paginator: read
data, branch onpagination.style, treatnextas an integer offset. - Replace embedded relation reads with
*_id/*_ids, adding?expand=only where you need the object and only where it is supported. - Rename state
defaulttois_default, and renameupdated_bytoupdated_by_idon states and work items. Read a work item's project fromproject_id, and stop readingworkspacefrom state and work item responses. - Add
?fields=description_htmlto work item list calls that read descriptions. Single-object responses include it already. - Stop expecting
custom_fieldson list responses — fetch the item individually when you need its property values. - Update your error handling to the
{type, code, detail}shape: v2 no longer sendsstatusortitle, andtypeis a bare category slug rather than a URI. See Errors. - Adopt
?fields=where you only need a few keys — v1's?fields=silently ignored unknown names, whereas v2 returns a400, and v2 omits unrequested keys rather than nulling them. - Replace name-based lookups with the identity filters (
?name=,?key=,?url=) — v2 path segments take ids, not attributes. See Filtering and ordering. - Confirm nothing you depend on is in the "not yet in v2" list above.

