Errors
Every v2 error is returned as application/problem+json with the same three members, whether the failure is a typo in a request body or a throttled client, so one error handler can cover the whole API.
{
"type": "conflict",
"code": "states_managed_at_workspace",
"detail": "States are managed at the workspace level."
}The problem shape
Fields
Three members are always present and never null.
typestringThe general category, from a closed set of 13 values (listed below). Use it as the fallback for a
codeyou have not seen before. The set is small and stable enough to hard-code in a client.codestringThe specific condition, and the field to branch on. New codes are added as v2 grows. When a condition needs no refinement,
codeequalstype.detailstringA human-readable explanation of this occurrence, written for a developer reading a log. The wording is not part of the contract.
Validation failures add one more member:
errorsarrayPresent only on
invalid_request. One entry per rejected field, each carryingfield,codeandmessage. The per-fieldcode(required,invalid,unique,invalid_choice,max_length,does_not_exist) lets you branch per field without matching English prose.
Branch on code, fall back to type
Several conditions share a status: 403 covers both forbidden and workflow_transition_denied, and 409 covers conflict plus four refinements. The HTTP status alone cannot tell them apart, and neither can type.
Because code is always present, even when it repeats type, you never need a code ?? type null check. Switch on code, and let unrecognized values fall through to a type arm.
detail is written for people. Matching on its text will break.
No status, title, or URI type
Earlier v2 previews sent status and title in the body, and a type of the form https://api.plane.so/errors/<code>. v2 no longer sends any of them: status duplicated the HTTP status line, title was derivable boilerplate, and the URI pointed at a page a self-hosted instance never serves. type is now a bare slug.
{
"type": "invalid_request",
"code": "invalid_request",
"detail": "One or more fields failed validation.",
"errors": [
{
"field": "name",
"code": "required",
"message": "This field is required."
},
{
"field": "group",
"code": "invalid_choice",
"message": "\"in_review\" is not a valid choice."
}
]
}{
"type": "not_found",
"code": "not_found",
"detail": "The requested resource was not found."
}Handling errors
Read code first, and fall back to type for codes you do not recognize. New codes can appear as v2 grows, but the type set will not grow with them.
# -s keeps curl quiet, -w prints the status so you can see both parts
curl -s -w "\n%{http_code}\n" \
"https://api.plane.so/api/v2/workspaces/my-team/projects/4af68566-94a4-4eb3-94aa-50dc9427067b/states/" \
-H "X-Api-Key: $PLANE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"color": "#3f76ff"}'import requests
response = requests.post(
"https://api.plane.so/api/v2/workspaces/my-team/projects/4af68566-94a4-4eb3-94aa-50dc9427067b/states/",
headers={"X-Api-Key": "your-api-key"},
json={"color": "#3f76ff"},
)
if not response.ok:
problem = response.json()
if problem["code"] == "invalid_request":
for item in problem["errors"]:
print(f"{item['field']}: {item['message']}")
elif problem["code"] == "rate_limited":
retry_after = int(response.headers.get("Retry-After", "1"))
print(f"Throttled, retry in {retry_after}s")
elif problem["code"] == "payment_required":
print("Feature not enabled:", problem["detail"])
else:
# unknown code — fall back to the closed `type` set
print(problem["type"], problem["code"], problem["detail"])const response = await fetch(
"https://api.plane.so/api/v2/workspaces/my-team/projects/4af68566-94a4-4eb3-94aa-50dc9427067b/states/",
{
method: "POST",
headers: {
"X-Api-Key": "your-api-key",
"Content-Type": "application/json",
},
body: JSON.stringify({ color: "#3f76ff" }),
},
);
if (!response.ok) {
const problem = await response.json();
switch (problem.code) {
case "invalid_request":
problem.errors.forEach((e) => console.error(`${e.field}: ${e.message}`));
break;
case "rate_limited":
console.error(`Throttled, retry in ${response.headers.get("Retry-After")}s`);
break;
case "payment_required":
console.error("Feature not enabled:", problem.detail);
break;
default:
// unknown code — fall back to the closed `type` set
console.error(problem.type, problem.code, problem.detail);
}
}The type vocabulary
A closed set of 13 values. It grows only when a new kind of failure appears.
type | Status | Meaning |
|---|---|---|
invalid_request | 400 | The request itself is wrong. |
unauthorized | 401 | Credentials missing or invalid. |
payment_required | 402 | The feature isn't enabled on your plan. |
forbidden | 403 | Authenticated, but not allowed. |
not_found | 404 | No such resource, or it's outside your tenant. |
method_not_allowed | 405 | Method not supported on this route, most often a PUT. |
not_acceptable | 406 | Can't produce the representation your Accept header asks for. |
conflict | 409 | The write collides with existing state or a business rule. |
payload_too_large | 413 | Request body over the size limit. |
unsupported_media_type | 415 | Content-Type not accepted here. |
rate_limited | 429 | Throttled. |
server_error | 500 | Unexpected server-side failure. Retry; alert if it persists. |
service_unavailable | 502/503/504 | Temporarily unavailable. Unlike server_error, this is the retryable 5xx: back off and retry. |
Error codes
Each code maps to exactly one type, and the mapping never changes. Codes are only ever added. They are never renamed or repurposed, so a code you handle today keeps its meaning.
Status-level codes equal their type: invalid_request, unauthorized, payment_required, forbidden, not_found, method_not_allowed, not_acceptable, conflict, payload_too_large, unsupported_media_type, rate_limited, server_error, service_unavailable.
The following codes refine one of those:
| Status | Code | type | Meaning |
|---|---|---|---|
400 | count_pagination_disabled | invalid_request | Offset and COUNT are disabled for this resource. Use ?paginate=cursor. |
400 | ordering_not_cursor_eligible | invalid_request | This ordering can't back a keyset cursor. Use offset. |
400 | workflow_outside_allowlist | invalid_request | The workflow is not in the work item type's allowlist. |
403 | workflow_transition_denied | forbidden | A workflow rule blocked the create or state transition. |
403 | workflow_mandated | forbidden | The workspace requires one workflow for this type. Projects can't pick one. |
403 | workflow_assigned_by_workspace | forbidden | The workspace pinned this type's workflow for the project. |
409 | states_managed_at_workspace | conflict | States are governed at the workspace level for this workspace. |
409 | states_managed_at_project | conflict | States and workflows are managed in each project. Governance is off. |
409 | templates_managed_at_workspace | conflict | Templates are managed at the workspace level for this workspace. |
409 | work_item_types_managed_at_workspace | conflict | Wrong mode. This workspace manages work item types at the workspace level. |
409 | work_item_types_managed_at_project | conflict | Wrong mode. This workspace manages work item types at the project level. |
409 | governance_migration_in_progress | conflict | A governance migration is running. Every write in the workspace returns this until it finishes. |
409 | workflow_in_use | conflict | The workflow is the default, or a project pick, pin, or allowlist uses it. |
409 | workflow_state_orphans | conflict | The change strands work items. Send a state_mapping that covers them. |
409 | workflow_state_dependencies | conflict | Transitions still reference the state. Remove them first. |
409 | workflow_allowlist_excludes_in_use | conflict | The allowlist drops workflows that projects use. Resend with acknowledge. |
409 | workflow_pins_unavailable | conflict | The type is in required mode, so per-project pins don't apply. |
500 | listing_authorization_misconfigured | server_error | A server-side authorization guard tripped. Report it; retrying won't help. |
Handle the codes you care about, default the rest
The code set is large on purpose. A code exists whenever a client would handle the condition differently, which is why count_pagination_disabled ("switch to cursor") and ordering_not_cursor_eligible ("switch to offset") are separate. The size costs you nothing: match the codes you act on, and send everything else to a default branch based on type.
Validation failures
A 400 invalid_request is the only code that carries errors[]. Each entry names one rejected field, and a single response can carry several. The API validates the whole payload instead of stopping at the first problem, so one response lists everything you need to fix.
A few checks return errors as an object instead of a list:
- The
template_datashape check on project templates keyserrorsby the failingtemplate_datakey. Itsdetailnames what failed, for exampleInvalid work item template at index 2. - The chain checks on workspace workflows return
errorsas{"error": "…"}, withdetailset toInvalid workflow chain.orInvalid state ids.
Query parameters are validated too. Enum-backed parameters such as priority, state_group, state group, and module status are checked against their allowed values, so a typo returns 400 instead of an empty list.
?fields= and ?expand= are validated the same way. Their error messages name the closest match and list the valid values, so a client can correct the request in one retry:
{
"type": "invalid_request",
"code": "invalid_request",
"detail": "One or more fields failed validation.",
"errors": [
{
"field": "fields",
"code": "invalid",
"message": "Unknown field(s): titel — did you mean 'title'? Valid fields: all, id, name, identifier, …"
}
]
}If you name an expandable relation in ?fields=, or a field in ?expand=, the error tells you which parameter to use. See Sparse fields.
Surface field and message verbatim
When you relay an error to a person, for example in a CLI, a form, or a Slack notification, the field/message pairs are specific enough to act on. Pass them through instead of replacing them with a generic "invalid request". Use the per-entry code when you need to branch in code.
Feature gating (402)
Endpoints that belong to a paid or optional feature return 402 payment_required when the feature is off, not 403 or 404:
{
"type": "payment_required",
"code": "payment_required",
"detail": "This feature is not available on your current plan."
}Two things can produce it: the feature isn't in your plan, or it is but hasn't been switched on for the workspace or project. Both are configuration problems, not permission problems, so retrying or adding scopes to your token will not help. Affected surfaces include customers, releases, project releases, workflows, work item type governance, automations, worklogs, custom relation definitions, page templates, project templates, project states, and recurring work items.
Every operation declares 402, so handle it in shared client code instead of treating it as specific to certain endpoints.
Rate limiting
Plane throttles requests per token, with a separate bucket for each token class: API keys, OAuth tokens, workspace tokens, service tokens, and external tokens. Exceeding a bucket returns 429 rate_limited with a Retry-After header.
{
"type": "rate_limited",
"code": "rate_limited",
"detail": "Rate limit exceeded."
}Wait the number of seconds in Retry-After before retrying. If you back off on a fixed schedule instead, or retry immediately, you stay throttled. Because the buckets are per token, splitting a bulk job across several tokens does not merge their limits, but it does multiply the load on the workspace.
Mode conflicts
A workspace manages work item types in exactly one mode: at the project level or at the workspace level. Both sets of endpoints always exist, so a write to the surface that is not in use returns 409, not 404 or 403. The operation exists, but on the other surface.
- A write to a project-mode endpoint while workspace mode is on returns
work_item_types_managed_at_workspace. - A write to a workspace-mode endpoint while project mode is on returns
work_item_types_managed_at_project.
Reads are never blocked by mode
Only writes conflict. You can list and read types and properties on either surface in either mode. A project still lists its imported types while the workspace is in workspace mode.
The code tells you where to send the write. See Work item type modes for details.
States, workflows, and templates under governance
Workspace governance manages states, workflows, and page templates at the workspace instead of in each project. Both surfaces exist, and a call to the one that is not in use returns 409. Unlike type modes, some governance-only endpoints refuse reads as well as writes.
- Writing a project's states while the workspace is governed returns
states_managed_at_workspace. Use the workspace states and workspace workflows instead. - Writing workspace states in an ungoverned workspace returns
states_managed_at_project. Reading them still works. - In an ungoverned workspace, every call to these endpoints returns
states_managed_at_project, reads included: the workspace workflow catalog with its states, transitions, and hooks, work item type governance, a project's work item type workflows, and the workflow fallback preview. Use the project's states and workflows. - Writing a project's page templates while the workspace is governed returns
templates_managed_at_workspace. Use the workspace page templates. - Writing a project's labels while the workspace is governed returns
400with a v1-shaped body,{"error": "This resource is managed at the workspace level", "code": "workspace_managed"}, instead of a problem document. See Labels.
Two workflow codes carry an extra top-level member that describes what the change would affect:
workflow_state_orphanscarriesorphans, an object with its ownorphanslist and aninvalid_targetslist. Eachorphansentry names a state that would be dropped, withstate_id,state_name,count, and aper_projectbreakdown. Map eachstate_idto a target instate_mappingand resend.invalid_targetslists thestate_mappingtargets that aren't in the new chain. A target outside the chain returns this409even when no work items would be stranded.workflow_allowlist_excludes_in_usecarriesworkflows, a list of the in-use workflows (id,name) that the allowlist would drop. Resend withacknowledge: trueto drop those picks.
Pagination errors
Both pagination 400s mean the same thing: the pagination style and the request do not fit together.
ordering_not_cursor_eligible: some orderings, for example?order_by=priority, sort by a semantic order instead of by a stored column. Those cannot back a keyset cursor. Drop?paginate=cursorand use offset for that ordering.count_pagination_disabled: offset with a COUNT is unavailable for this resource. Switch to?paginate=cursor.
See Pagination for the two envelopes and when to prefer each.
Related
- Authentication: what separates
401,403, and a tenant-safe404. - Pagination: offset and cursor styles.
- Introduction: request conventions, including the required trailing slash.

