Update a work item
Update a work item. Every field is optional and the update is partial: send only what changes, and everything you leave out stays as it was.
Omitting a field is not the same as sending null. Omit target_date and the existing due date is preserved; send "target_date": null and it is cleared.
There is no PUT in v2. A PUT to this path returns 405 method_not_allowed.
Path parameters
slug:requiredstringThe workspace slug. It appears in your Plane URLs. In https://app.plane.so/my-team/projects/, the slug is my-team.
project_id:requiredstring (uuid)The project the work item belongs to.
pk:requiredstringThe work item's UUID or its identifier, such as PROJ-142.
Body parameters
name:optionalstringNew title. Maximum 255 characters.
description_html:optionalstringReplacement rich-text body as HTML. Plane sanitizes it when it's written, and content that can't be sanitized is rejected with 400. The response returns the new description_html.
priority:optionalstringOne of urgent, high, medium, low, none.
state_id:optionalstring (uuid)Move the work item to another state of the same project. Workflow rules apply to this field. See Workflow rules.
state:optionalstringThe target state's name instead of its id, for example Done. Matched case-insensitively within the project. Write-only. Sending both state and state_id returns 400.
type_id:optionalstring (uuid)Change the work item type. Send null to make the work item untyped.
type:optionalstringThe type's name instead of its id, for example Bug. Write-only.
parent_id:optionalstring (uuid)Re-parent the work item. The parent must be in the same workspace and may be in a different project. Send null to detach it and make it top-level.
parent:optionalstringThe parent's identifier instead of its id, for example PROJ-118. Write-only.
assignee_ids:optionalarray of string (uuid)Replaces the whole assignee set instead of adding to it. Send the complete list you want, [] to unassign everyone, or omit the field to leave assignees unchanged.
assignees:optionalarray of string (email)Member email addresses instead of ids. Same replace-the-set semantics. Every address must belong to an active, assignable project member. Write-only.
label_ids:optionalarray of string (uuid)Replaces the whole label set. Send [] to strip all labels, or omit the field to leave them untouched.
labels:optionalarray of stringLabel names instead of ids. Same replace-the-set semantics. A name that exists at both project and workspace level is ambiguous and returns a 400 telling you to use label_ids. Write-only.
estimate_point_id:optionalstring (uuid)Change the estimate point, from the project's active estimate system. Send null to clear the estimate.
estimate:optionalstringThe estimate point's value instead of its id, for example 5. Write-only.
start_date:optionalstring (date)Planned start, or null to clear it. Must not be after target_date.
target_date:optionalstring (date)Planned due date, or null to clear it.
external_id:optionalstringYour system's identifier for this work item. Maximum 255 characters. Returned on reads and filterable on List work items.
external_source:optionalstringThe system external_id came from, for example github or jira. Maximum 255 characters.
Fields you cannot patch
id, identifier, sequence_id, is_draft, archived_at, created_at, and created_by_id are read-only, and sending them has no effect. In particular, archiving is not a PATCH on archived_at; use Archive a work item.
Custom property values go through a separate custom_fields object on the request body, keyed by property name. Its shape follows the work item's type, so it is not declared in the OpenAPI schema. See the type's schema endpoint. On a PATCH, only the properties you submit are replaced. Properties you leave out keep their values and are never checked as required.
Response shaping
fields:optionalstringComma-separated list of fields to return. Fields you don't request are left out of the response instead of coming back as null, so a missing key means you didn't ask for it and null means the value is null. id is always included, whether or not you name it.
Pass all to get every requestable field. An unknown field name returns 400 with the list of valid names and the closest match, so a typo is caught instead of being silently ignored.
Requestable here: archived_at, assignee_ids, completed_at, created_at, created_by_id, custom_fields, cycle_id, description_html, estimate_point_id, external_id, external_source, id, identifier, is_draft, label_ids, module_ids, name, parent_id, priority, project_id, sequence_id, sort_order, start_date, state_id, target_date, type_id, updated_at, updated_by_id.
See Sparse fields.
expand:optionalstringComma-separated relations to embed alongside the ids: assignees (the assigned users), cycle (the cycle it belongs to), estimate_point (its estimate point), labels (the applied labels), modules (the modules it belongs to), parent (its parent work item), state (the work item's state object), type (its work item type).
Expanded objects get their own key: ?expand=state keeps state_id and adds a state object next to it, so an id is never replaced by an object. An unknown value returns 400.
?fields= and ?expand= take separate sets of names. Relation names aren't valid in ?fields=, field names aren't valid in ?expand=, and field filtering never removes an expanded object: ?fields=id,name&expand=state returns id, name and state. See Expanding relations.
Scopes
projects.work_items:write
Errors
| Status | Code | Cause |
|---|---|---|
400 | invalid_request | A bad priority; start_date after target_date; an unresolvable or ambiguous state/type/parent/assignees/labels/estimate; both a name and its *_id; or an id from another project or workspace. |
401 | unauthorized | Missing or invalid credentials. |
402 | payment_required | The feature this endpoint belongs to isn't enabled on your plan, or is switched off. |
403 | forbidden | A workflow rule forbids the requested state transition. |
404 | not_found | No such work item, or it's outside your project or tenant. |
406 | not_acceptable | The Accept header asks for a representation the API can't produce. |
409 | conflict | The write collides with a uniqueness or protected-resource constraint. |
413 | payload_too_large | The request body is over the size limit. |
415 | unsupported_media_type | The Content-Type isn't one this endpoint accepts. |
429 | rate_limited | Throttled. Honor the Retry-After header before retrying. |
curl -X PATCH \
"https://api.plane.so/api/v2/workspaces/my-team/projects/4af68566-94a4-4eb3-94aa-50dc9427067b/work-items/8f4c2b1e-0d3a-4f7b-9c21-6e5a8b7d4f13/" \
-H "X-Api-Key: $PLANE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"priority": "urgent",
"state": "In Review",
"assignees": ["ana@example.com", "rk@example.com"],
"target_date": null
}'import requests
response = requests.patch(
"https://api.plane.so/api/v2/workspaces/my-team/projects/4af68566-94a4-4eb3-94aa-50dc9427067b"
"/work-items/8f4c2b1e-0d3a-4f7b-9c21-6e5a8b7d4f13/",
headers={"X-Api-Key": "your-api-key"},
json={
"priority": "urgent",
"state": "In Review",
"assignees": ["ana@example.com", "rk@example.com"],
"target_date": None,
},
)
print(response.json())const response = await fetch(
"https://api.plane.so/api/v2/workspaces/my-team/projects/4af68566-94a4-4eb3-94aa-50dc9427067b" +
"/work-items/8f4c2b1e-0d3a-4f7b-9c21-6e5a8b7d4f13/",
{
method: "PATCH",
headers: {
"X-Api-Key": "your-api-key",
"Content-Type": "application/json",
},
body: JSON.stringify({
priority: "urgent",
state: "In Review",
assignees: ["ana@example.com", "rk@example.com"],
target_date: null,
}),
},
);
const data = await response.json();{
"id": "8f4c2b1e-0d3a-4f7b-9c21-6e5a8b7d4f13",
"name": "Fix login redirect loop",
"description_html": "<p>Signing in after a session expires sends the user back to the login page in a loop.</p>",
"identifier": "PROJ-142",
"sequence_id": 142,
"priority": "urgent",
"project_id": "4af68566-94a4-4eb3-94aa-50dc9427067b",
"state_id": "5d2a91b7-64c0-4f38-b9e2-0a3f7c6d8149",
"type_id": "2d9d1a97-5c6f-4a1e-9d5b-8c2f7e30b6a4",
"assignee_ids": ["16c61a3a-512a-48ac-b0be-b6b46fe6f430", "9b7f4c53-2d18-4a6e-8c05-1f3e7d9a2b64"],
"label_ids": ["c1b8f3d6-9a44-4e12-8f7a-2b6d5c9e1a03"],
"cycle_id": null,
"module_ids": [],
"parent_id": null,
"estimate_point_id": null,
"start_date": "2026-01-12",
"target_date": null,
"is_draft": false,
"sort_order": 85535.0,
"completed_at": null,
"archived_at": null,
"external_id": null,
"external_source": null,
"created_at": "2026-01-14T09:22:41.478363Z",
"updated_at": "2026-02-03T16:45:12.904117Z",
"created_by_id": "16c61a3a-512a-48ac-b0be-b6b46fe6f430",
"updated_by_id": "16c61a3a-512a-48ac-b0be-b6b46fe6f430",
"custom_fields": {
"severity": {
"id": "b52e7c18-4d3f-4a90-8e61-0f7a3c9d2b45",
"value": "Sev-1",
"value_detail": {
"id": "7c0a5f39-2e84-4b17-9a6c-1d8e4f2b60c9",
"name": "Sev-1",
"logo_props": {}
}
}
}
}{
"type": "forbidden",
"code": "workflow_transition_denied",
"detail": "State transition is not allowed."
}Omitted, empty, and null
These three are different, and the difference matters most on the list fields:
| Request body | Result |
|---|---|
{} (field omitted) | Assignees unchanged. |
{"assignee_ids": []} | All assignees removed. |
{"assignee_ids": ["…"]} | Assignees become exactly that list. |
{"parent_id": null} | Parent detached. |
assignee_ids and label_ids replace the set rather than adding to it, so to add one assignee you send the existing ids plus the new one. Read the work item first if you don't already hold the current list.
Workflow rules can reject a transition
If the project uses workflow rules, a state change they don't permit returns 403 with workflow_transition_denied and nothing is written. That is different from forbidden, which means your role or token scope can't edit the work item at all. Both return 403, so branch on the code, not the status.
Only a state change can trigger it. Updating a name or a due date never runs the transition check.

