Skip to content

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

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= on work items and members
List enveloperesults + cursor stringsdata + pagination.style
Sparse fieldsets?fields=Not needed — reads are already sparse

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.

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 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.

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.

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": null means "clear it".

4. Errors are RFC 9457

Every v2 error is application/problem+json with the same five keys:

Response400
json
{
  "type": "https://api.plane.so/errors/validation_error",
  "title": "Validation Error",
  "status": 400,
  "code": "validation_error",
  "detail": "One or more fields failed validation.",
  "errors": [{ "field": "name", "message": "This field is required." }]
}

Branch on code. It is the stable, machine-readable part. status is too coarse (several distinct conditions share 409) and detail is prose that can be reworded at any time. errors[] appears only on validation_error and names the offending fields.

Other behavior worth knowing while you port error handling:

  • A resource outside your tenant is 404 resource_not_found, never 403. v2 does not leak existence.
  • DELETE returns 204 with 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; honor Retry-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.

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.

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.

?expand= is supported on work items (state, type, parent, assignees, labels) and on workspace and project members (member). No other v2 resource supports it, and an unknown value is a 400. Full details 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.

There is no ?fields= in v2. Reads are already sparse, so there is nothing to trim.

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

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 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.

Resourcev1v2
Statesdefaultis_default
Statesupdated_at, updated_by, project, workspaceNot returned
Work itemsupdated_at, updated_by, project, workspaceNot returned
Work itemsdescription_html on readWrite-only — accepted on write, not returned on read
Work itemscustom_fields — populated only on single-item responses

Details on the two that bite hardest:

description_html is write-only on work items

You can send description_html on a create or update and it is stored, but it does not come back on any read — not on the list, not on the detail route, not in the create/update response. Code that round-trips a description through the API needs to keep its own copy of what it wrote.

custom_fields is null on list responses

custom_fields carries a work item's custom property values, and it is populated only on single-item responses — retrieve, create, and update. On the list path it is always null, because resolving properties per row would mean a query per work item. If you need custom 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

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",
      "custom_fields": null,
      "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:

  • resultsdata; the cursor strings are replaced by an integer next plus a pagination.style discriminator.
  • statestate_id, and assigneesassignee_ids. The expanded state and assignees objects are added by ?expand=, not substituted for the ids.
  • description is gone from the read shape.
  • New on every work item: identifier (PROJ-118), type_id, is_draft, archived_at, created_by_id, and custom_fields (null here because this is a list).

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:

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 — 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. Not yet in v2

v2 does not cover the whole product surface. It currently covers work items and their comments, states, labels, cycles, modules, work item types and custom properties, members, workspace features, audit logs, and the current user.

Everything else still lives in v1 — projects, pages and wiki pages, intake, teamspaces, initiatives, epics, estimates, customers, stickies, attachments and links, worklogs, work item relations, activity, milestones, and workspace invitations. So do the sub-resources and verbs of cycles and modules: adding or removing their work items, archiving, and unarchiving all remain v1-only.

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

  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 actually 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 stop reading updated_at, updated_by, project, and workspace from state and work item responses.
  7. Stop expecting description_html back from a work item read.
  8. Stop expecting custom_fields on list responses — fetch the item individually when you need its property values.
  9. Confirm nothing you depend on is in the "not yet in v2" list above.