Create a work item
Create a work item in a project. Only name is required. Every other field falls back to the project's defaults, so the smallest valid create sends one field.
Each relation accepts either its id field or a write-only human-readable equivalent: state instead of state_id, assignees (emails) instead of assignee_ids, labels (names) instead of label_ids, and so on. Send one or the other, never both. With the human-readable fields, an importer or an agent can write a fully populated work item without first resolving six UUIDs.
The response is the standard sparse read shape, with custom_fields populated.
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 to create the work item in.
Body parameters
name:requiredstringTitle of the work item. Maximum 255 characters. The only required field.
description_html:optionalstringRich-text body as HTML, for example <p>Steps to reproduce…</p>. Plane sanitizes the HTML when it's written, and content that can't be sanitized is rejected with 400.
The response returns the stored description_html. Omit it and the work item reads <p></p>.
priority:optionalstringOne of urgent, high, medium, low, none. Defaults to none.
state_id:optionalstring (uuid)The workflow state to create the work item in. Must be a state of this project. If you omit it, the work item is created in the project's default state.
state:optionalstringThe state's name instead of its id, for example In Progress. Matched case-insensitively within the project. Write-only.
An unknown name, or a name that matches more than one state, returns 400. So does sending both state and state_id.
type_id:optionalstring (uuid)The work item type. Accepts a type owned by the project and a workspace-level type imported into it. Requires work item types to be enabled.
type:optionalstringThe type's name instead of its id, for example Bug. Matched case-insensitively among the project's non-epic types. Write-only.
parent_id:optionalstring (uuid)The parent work item. The parent must be in the same workspace, but it may live in a different project.
parent:optionalstringThe parent's identifier instead of its id, for example PROJ-118. Resolved within the workspace. Write-only.
assignee_ids:optionalarray of string (uuid)User ids to assign. Each must be an active, assignable member of the project.
Leave it out and the project's default assignee is applied, if one is configured. Send [] to create the work item with no assignee at all.
assignees:optionalarray of string (email)Member email addresses instead of ids, for example ["ana@example.com"]. Every address must belong to an active, assignable project member. Any that don't are named in the 400 response. Write-only.
label_ids:optionalarray of string (uuid)Label ids to apply. Each must be a label of this project, or a workspace-level label available to it.
labels:optionalarray of stringLabel names instead of ids, for example ["regression", "auth"]. Matched case-insensitively against the project's labels and the workspace-level labels available to it. A name that exists at both levels is ambiguous and returns a 400 telling you to use label_ids. Write-only.
estimate_point_id:optionalstring (uuid)The estimate point to assign, from the project's active estimate system. Reads return it as estimate_point_id whichever of the two inputs you send.
estimate:optionalstringThe estimate point's value instead of its id, for example 5 or L. Resolved against the project's active estimate. Write-only.
start_date:optionalstring (date)Planned start, for example 2026-01-12. Must not be after target_date.
target_date:optionalstring (date)Planned due date, for example 2026-01-20.
external_id:optionalstringYour system's identifier for this work item, for sync and import correlation. 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.
Setting custom property values
Work item type custom properties are written through a separate custom_fields object on the request body, keyed by property name, and are validated against the type you selected. Its shape depends on the type you chose, so it is not declared in the OpenAPI schema. Read it from the type's schema endpoint. Custom properties must be enabled for the workspace, or sending the object returns 400. Properties you omit are filled from the type's defaults. See work item properties.
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 | Missing name; 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 creating a work item in the requested state. |
404 | not_found | No such workspace or project, or it's outside your 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 POST \
"https://api.plane.so/api/v2/workspaces/my-team/projects/4af68566-94a4-4eb3-94aa-50dc9427067b/work-items/" \
-H "X-Api-Key: $PLANE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Fix login redirect loop",
"priority": "high",
"state_id": "f960d3c2-8524-4a41-b8eb-055ce4be2a7f",
"assignee_ids": ["16c61a3a-512a-48ac-b0be-b6b46fe6f430"],
"target_date": "2026-01-20"
}'import requests
response = requests.post(
"https://api.plane.so/api/v2/workspaces/my-team/projects/4af68566-94a4-4eb3-94aa-50dc9427067b/work-items/",
headers={"X-Api-Key": "your-api-key"},
json={
"name": "Fix login redirect loop",
"priority": "high",
"state_id": "f960d3c2-8524-4a41-b8eb-055ce4be2a7f",
"assignee_ids": ["16c61a3a-512a-48ac-b0be-b6b46fe6f430"],
"target_date": "2026-01-20",
},
)
print(response.json())const response = await fetch(
"https://api.plane.so/api/v2/workspaces/my-team/projects/4af68566-94a4-4eb3-94aa-50dc9427067b/work-items/",
{
method: "POST",
headers: {
"X-Api-Key": "your-api-key",
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Fix login redirect loop",
priority: "high",
state_id: "f960d3c2-8524-4a41-b8eb-055ce4be2a7f",
assignee_ids: ["16c61a3a-512a-48ac-b0be-b6b46fe6f430"],
target_date: "2026-01-20",
}),
},
);
const data = await response.json();{
"id": "8f4c2b1e-0d3a-4f7b-9c21-6e5a8b7d4f13",
"name": "Fix login redirect loop",
"description_html": "<p></p>",
"identifier": "PROJ-142",
"sequence_id": 142,
"priority": "high",
"project_id": "4af68566-94a4-4eb3-94aa-50dc9427067b",
"state_id": "f960d3c2-8524-4a41-b8eb-055ce4be2a7f",
"type_id": null,
"assignee_ids": ["16c61a3a-512a-48ac-b0be-b6b46fe6f430"],
"label_ids": [],
"cycle_id": null,
"module_ids": [],
"parent_id": null,
"estimate_point_id": null,
"start_date": null,
"target_date": "2026-01-20",
"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-01-14T09:22:41.478363Z",
"created_by_id": "16c61a3a-512a-48ac-b0be-b6b46fe6f430",
"updated_by_id": null,
"custom_fields": {}
}curl -X POST \
"https://api.plane.so/api/v2/workspaces/my-team/projects/4af68566-94a4-4eb3-94aa-50dc9427067b/work-items/" \
-H "X-Api-Key: $PLANE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Fix login redirect loop",
"state": "In Progress",
"type": "Bug",
"parent": "PROJ-118",
"assignees": ["ana@example.com"],
"labels": ["regression", "auth"],
"estimate": "5"
}'import requests
response = requests.post(
"https://api.plane.so/api/v2/workspaces/my-team/projects/4af68566-94a4-4eb3-94aa-50dc9427067b/work-items/",
headers={"X-Api-Key": "your-api-key"},
json={
"name": "Fix login redirect loop",
"state": "In Progress",
"type": "Bug",
"parent": "PROJ-118",
"assignees": ["ana@example.com"],
"labels": ["regression", "auth"],
"estimate": "5",
},
)
print(response.json())const response = await fetch(
"https://api.plane.so/api/v2/workspaces/my-team/projects/4af68566-94a4-4eb3-94aa-50dc9427067b/work-items/",
{
method: "POST",
headers: {
"X-Api-Key": "your-api-key",
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Fix login redirect loop",
state: "In Progress",
type: "Bug",
parent: "PROJ-118",
assignees: ["ana@example.com"],
labels: ["regression", "auth"],
estimate: "5",
}),
},
);
const data = await response.json();{
"type": "invalid_request",
"code": "invalid_request",
"detail": "The request body failed validation.",
"errors": [
{
"field": "state",
"code": "invalid",
"message": "No state named 'In Reviewing' in this project."
}
]
}Names or ids, not both
The human-readable inputs are resolved to their id equivalents before anything is written, and the rules are the same for all six:
| You send | What happens |
|---|---|
state_id only | Used directly. Must belong to this project. |
state only | Resolved by name, case-insensitively, within the project. |
state and state_id | 400 with "Provide either state_id or state, not both." |
| A name matching nothing | 400 naming the field and the value you sent. |
| A name matching more than one record | 400 telling you to use the id field instead. |
Use the id fields in code you control and reuse across runs. Use the names when you're bridging a system that already refers to things by name, such as a Jira status, a Slack email, or a label the user typed.
Workflow rules can reject a create
If the project uses workflow rules, creating a work item directly in a restricted state returns 403 with workflow_transition_denied, which is different from a plain permission forbidden. Branch on the code: forbidden means you need access, and workflow_transition_denied means you should create the work item in an allowed state and move it from there.

