Bulk create work items
Create up to 50 work items in one request. Each row is validated and written exactly as Create a work item would, so every business rule and permission check still applies per row.
200 does not mean every row succeeded
By default this endpoint reports partial success: it answers 200 whenever the batch was processed, even if some rows failed. Read failed and the per-row results rather than branching on the status code alone.
If you want all-or-nothing instead, send all_or_none: true and treat 409 as the failure signal.
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 items belong to. Accepts the project UUID or its bare identifier, for example ENG.
Body Parameters
items:requiredarray of objectThe work items to create — each entry takes the same body as Create a work item. Between 1 and 50 per call.
all_or_none:optionalbooleanDefaults to false. Leave it off for partial success: each row runs in its own savepoint, successful rows commit, and you read the breakdown.
Set it to true to make the batch atomic. Every row is still evaluated, but if any row fails the whole batch is discarded and the call answers 409 instead of the 200 envelope. Side effects scheduled by the rows follow the same outcome.
Scopes
projects.work_items:write
Errors
| Status | Code | Cause |
|---|---|---|
400 | invalid_request | The envelope itself is malformed — items missing or empty, more than 50 rows, or a non-UUID id. |
401 | unauthorized | Missing or invalid credentials. |
403 | forbidden | Your role or token scope can't write work items in this project. |
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 | An all_or_none: true batch had at least one failing row, so the whole batch was discarded. |
413 | payload_too_large | The request body is over the size limit. |
415 | unsupported_media_type | The body wasn't JSON. These routes are JSON-only — a form or multipart body can't express items. |
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/bulk-create/" \
-H "X-Api-Key: $PLANE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"items": [
{
"name": "Fix login redirect loop",
"priority": "high"
},
{
"name": "Fix login redirect loop 2",
"priority": "high"
}
]
}'import requests
response = requests.post(
"https://api.plane.so/api/v2/workspaces/my-team/projects/4af68566-94a4-4eb3-94aa-50dc9427067b/work-items/bulk-create/",
headers={"X-Api-Key": "your-api-key"},
json={
"items": [
{
"name": "Fix login redirect loop",
"priority": "high"
},
{
"name": "Fix login redirect loop 2",
"priority": "high"
}
]
},
)
body = response.json()
print(body["succeeded"], body["failed"])const response = await fetch(
"https://api.plane.so/api/v2/workspaces/my-team/projects/4af68566-94a4-4eb3-94aa-50dc9427067b/work-items/bulk-create/",
{
method: "POST",
headers: {
"X-Api-Key": "your-api-key",
"Content-Type": "application/json",
},
body: JSON.stringify({
items: [
{
name: "Fix login redirect loop",
priority: "high",
},
{
name: "Fix login redirect loop 2",
priority: "high",
},
],
}),
},
);
const body = await response.json();
console.log(body.succeeded, body.failed);{
"results": [
{
"index": 0,
"result": "created",
"id": "b7e42a19-3c5d-4f80-9a26-8d1c0f4e7b53"
},
{
"index": 1,
"result": "failed",
"type": "invalid_request",
"code": "invalid_request",
"detail": "One or more fields failed validation.",
"errors": [
{
"field": "name",
"code": "unique",
"message": "A work item with this name already exists."
}
]
}
],
"succeeded": 1,
"failed": 1
}{
"type": "conflict",
"code": "conflict",
"detail": "No rows were written because all_or_none was set and at least one row failed.",
"errors": [
{
"field": "items.1.name",
"code": "unique",
"message": "A work item with this name already exists."
}
]
}Reading the response
The 200 envelope has three members:
results— one row per input row, in request order, each carrying itsindexsucceeded/failed— counts, so you can branch without walking the array
A succeeded row is { index, result, id } where result is created. A failed row keeps the same members as a top-level error body — type, code, detail and, on validation failures, errors[] — plus index and result: "failed". So the same error-handling code works per row as at the top level. See Errors.
Atomic batches
With all_or_none: true, every row is still evaluated — you get the full picture of what would have failed, not just the first problem — and then the batch is rolled back. The 409 body's errors[] entries are prefixed with the row index, as items.<index>.<field>, so you can map each complaint back to the row that caused it.
Because the rollback covers the whole batch, side effects scheduled on commit — activity feed entries, webhooks — are discarded with it.
Limits and related routes
- 50 rows per call, and at least 1. Exceeding the cap is a
400for the whole envelope. - JSON only. A form or multipart body can't express
items, so these routes reject it with415rather than silently misreading the payload. - Reconciling on external ids is not bulk's job. Use Upsert a work item per row, or list and diff.
?fields=does not apply here — the response is a per-row result envelope, not a work item body. See Sparse fields.

