Skip to content

Errors and status codes

TruePPM returns errors in two different shapes, and the difference decides whether you get a machine-readable code to branch on. Knowing which is which is the whole point of this page — it is the thing you would otherwise have to reverse-engineer.

For what a version bump may and may not change, see API stability. For webhook delivery failures — a separate surface with its own retry semantics — see Webhooks.

Shape 1 — field validation errors (no refusal code)

Section titled “Shape 1 — field validation errors (no refusal code)”

Serializer validation failures return a map of field name → list of messages, with the HTTP status 400:

{
"planned_start": ["This field is required."],
"duration_days": ["Ensure this value is greater than or equal to 0."]
}

There is no refusal code in this shape, and the individual messages are not stable. Internally these errors do carry a code, but Django REST Framework serializes each one to its message string alone, so it never reaches the wire.

A code key can still appear here — but as a field name, not as the envelope’s machine code. See When a field is named code.

Branch on the field key. Never match on the message text. The strings come from DRF and Django validators and can change with a framework upgrade.

A field’s value is not always a flat list

Section titled “A field’s value is not always a flat list”

A field key holds a list of messages only when the field is a scalar. When it is a list field or a nested object, the errors nest to match the payload, so that you can point at the element that was rejected rather than at the field as a whole:

{
"hidden_views": {"0": ["Not a valid string."], "3": ["Not a valid string."]},
"calendar": {"overlays": ["Unknown role."]},
"tasks": [{}, {"name": ["This field is required."]}]
}
  • List field — an object keyed by the item index as a string. Only the failing indices appear; "0" above means the first element of the array you sent.
  • Nested object — an object keyed by subfield name, recursing the same way.
  • List of objects — an array with one entry per item you submitted, in order, and {} in the slots that validated.

One exception to the index keying: a constraint on the list as a whole — a length bound, or the fact that what you sent was not a list at all — has no failing item to point at, so it reports as a nested object under non_field_errors instead:

{"hidden_views": {"non_field_errors": ["Expected a list of items but got type \"str\"."]}}

A client that assumes every list field’s errors are index-keyed will look for "0" and find nothing. Check for non_field_errors at each level as you descend.

Nesting is as deep as the payload is, and the leaves are always lists of message strings. A client that assumes string[] at the first level will fail to parse the majority of settings-shaped payloads — walk the value instead, and treat any non-list as another level to descend.

Non-field validation errors arrive under the non_field_errors key, and authentication or permission failures arrive as a bare detail:

{"detail": "You do not have permission to perform this action."}

A few refusals are a bare array, not an object

Section titled “A few refusals are a bare array, not an object”

non_field_errors is where a serializer’s object-level validation lands. A handful of endpoints refuse from the view itself, before or without a serializer, by raising a validation error on a single message. DRF wraps that message in a list and puts it on the wire as a top-level JSON array — there is no enclosing object, and therefore no key to look under:

["This dependency is not pending acceptance."]

Three operations answer in this shape, and the OpenAPI schema declares it for each of them:

  • POST /api/v1/dependencies/{id}/accept/
  • POST /api/v1/dependencies/{id}/reject/
  • POST /api/v1/slip-conflicts/{id}/acknowledge/

A client that assumes every 400 body is an object will throw on these before it can read the message. Check whether the parsed body is an array first; if it is, the messages are the elements. As with Shape 1, the strings are prose and are not a contract — branch on the status and the endpoint, not on the text.

Shape 2 — structured errors (with a stable code)

Section titled “Shape 2 — structured errors (with a stable code)”

Failures that a client is expected to handle differently — not merely report — return a flat object carrying a stable code:

{
"detail": "Cannot set progress on a task with no start date.",
"code": "progress_requires_anchor"
}

detail is for humans and may be reworded at any time. code is the contract. Some codes carry extra keys; those are noted in the tables below.

The codes on this page are the complete set of values that appear in this shape. If an endpoint returns a bare detail with no code, treat it as a generic failure of its HTTP status class.

The two shapes share a body, so a Shape 1 field key can collide with a Shape 2 envelope key. Two fields in the API are named code — a program’s and a project’s short identifier — and one is named detail. When one of those fails validation, DRF keys its errors by field name like any other field, and the result reads like Shape 2 until you look at the type:

{"code": ["Ensure this field has no more than 12 characters."]}

Discriminate on the type, not on the key’s presence. A refusal code is always a string drawn from the vocabulary below. A list or object under code — or under detail — is Shape 1 field validation of the field of that name:

const refusal = typeof body.code === "string" ? body.code : null;

if (body.code) is the form that breaks: on the twenty-one operations that accept a code field — POST, PUT and PATCH on programs and projects, plus the actions that reuse those request bodies — it hands you an array of human sentences where a stable identifier was expected. This affects only the two names the envelope uses; every other field key is unambiguous.

CodeMeaningExtra keys
invalid_bodyThe request body parsed as valid JSON but is not an object — a top-level array, string, or number. No field is at fault; the envelope is the wrong shape. See Non-object request bodies
progress_requires_anchorProgress was set on a task with no date to anchor it to
milestone_rollup_lockedThe value is rolled up to the milestone and cannot be set directly
child_of_milestoneA milestone is a zero-duration gate and cannot be given children
zero_duration_not_milestoneA write set an existing, non-zero duration to 0 on a task that is not a milestone — removing the estimate without saying what the row became. Send is_milestone: true (or delivery_mode: "milestone") to make it a gate, or a duration of at least 1 to keep it as work. Creating a task at duration: 0, or re-writing a 0 that is already 0, is not refused — an unestimated row is legalsuggested_action
guardrail_blockedA configured guardrail refused the writerule, suggested_action
base_url_not_allowedThe supplied integration base URL is not permitted
invalid_tokenThe password-reset uid+token pair is bad, unknown, or expired
weak_passwordThe new password failed validationmessages
pin_limit_reachedPinning this project/program would exceed the account’s configured pin cap
invalid_graph_inputThe submitted dependency graph is malformed and cannot be interpreted
self_referenceA task in the graph depends on itselfoffending
cyclic_dependencyThe dependencies form a cycleoffending
subtree_too_largeThe targeted subtree exceeds the per-request cascade capmatched, max

Every write endpoint that reads named fields from the body expects a JSON object. A top-level array is legal JSON, so it reaches the endpoint and is refused with invalid_body:

Terminal window
$ curl -X POST .../api/v1/sprint-task-outcomes/{id}/toggle-demo/ \
-H 'Content-Type: application/json' -d '[{"demo_ready": true}]'
400
{"code": "invalid_body", "detail": "Request body must be a JSON object."}

This is a client-construction error, not a validation failure: no field is named because the request has no fields to name. The usual cause is a client that serializes a one-element list where the endpoint takes a single object, or a shell pipeline that wrapped a payload in […]. Send the object itself.

Two consequences worth knowing:

  • An empty object {} is a valid envelope and is not refused here — it proceeds to per-field validation, which may then report a required field. {} and [] are different errors and say so.
  • Endpoints that take multipart uploads (CSV and MS Project import) reject a JSON body earlier, with 415 Unsupported Media Type, and never reach this code.

invalid_graph_input is refused as a whole batch: a malformed graph has no identified cycle path, so there is no principled subset of edges to reject.

self_reference and cyclic_dependency carry offending — the node ids implicated. For self_reference that is the single offending id; for cyclic_dependency it is the ordered cycle path with the first id repeated at the end:

{
"code": "cyclic_dependency",
"detail": "Circular dependency: 1.1 — Design → 1.3 — Build → 1.1 — Design. Remove one of those links to schedule this plan.",
"offending": [
"3f2a…",
"9b1c…",
"3f2a…"
]
}

detail is prose and offending is data — do not parse one for the other. detail names each task by its WBS code and name, which is the reference the plan itself shows, so a person reading the refusal can find the tasks. It is bounded: past four members the chain elides its middle to … (N more), so a long cycle never produces a proportionally long sentence. offending stays the raw, complete ordered id list — branch and highlight rows on that.

subtree_too_large carries matched (how many descendants the request would have touched) and max (the cap), so a client can say how far over the limit it is without parsing the sentence in detail. Both are strings, not numbers — DRF renders a validation detail through ErrorDetail, a str subclass.

CodeMeaning
scope_accept_forbiddenThe caller may not accept this scope-injection request
attachment_delete_forbiddenThe caller is neither the attachment’s uploader nor a project Admin+
comment_edit_not_authorOnly the comment’s author may edit it (the edit window closing is a separate 400)
comment_delete_forbiddenThe caller is neither the comment’s author nor a project Admin+
reaction_delete_forbiddenThe reaction belongs to another user
note_edit_not_authorOnly the note’s author may edit it (the edit window closing is a separate 400)
note_delete_forbiddenThe caller is neither the note’s author nor a project Admin+

The six task-collaboration codes answered 400 before 0.4 and carried no code key at all — see API stability for the change record. Two membership refusals moved from 400 to 403 at the same time without gaining a code: removing a project or program member whose role is at or above your own now answers a bare {"detail": "..."} 403, the same shape as the not-an-Owner refusal beside it.

An archived project is read-only, and every write against one answers 403 with a bare detail and no code:

{ "detail": "This project is archived and cannot be modified. Unarchive it first." }

Three things worth knowing before you branch on it:

  • It is not a role problem, so retrying with more permission never helps. An Owner gets the same refusal as a Viewer; the state belongs to the plan, not to you. The only fix is POST /api/v1/projects/{id}/unarchive/.
  • It shares its status with the role refusals above. The two are indistinguishable by status code, and this refusal carries no code key — match on detail if you need to tell them apart.
  • Reads are unaffected, and so is taking access away. GET on any endpoint keeps working on an archived project, and so do the routes that remove a grant rather than add one: revoking a share link or an API token, removing yourself from the project, and cancelling an in-flight task run. An archived project keeps serving what those revoke, so closing them would strand you.

Where an endpoint cannot answer 403 without leaking something, it refuses differently and says so: the inbound Git webhook receiver answers its usual bare 404 — identical to every other pre-verification refusal, because it is unauthenticated — and records the reason as project_archived on the project’s Git automation config, which only an Admin can read.

POST/DELETE /projects/{id}/tasks/{id}/labels/ and PUT/DELETE /projects/{id}/tasks/{id}/field-values/{id}/ follow the same rule: the task lookup is membership-scoped, so a caller with no live membership on the project gets 404 whether the task id is real or made up — the two are deliberately indistinguishable. A project member who lacks write authority on the task (a Viewer, or a Member acting on someone else’s task) still gets 403, because that refusal is a fact about their role, not about the task’s existence.

404 / 409 — conflicts and protected references

Section titled “404 / 409 — conflicts and protected references”

Refused deletes carry a count of the rows still pointing at the target, and — only where the endpoint’s permission gate authorizes the caller to see them — a capped sample naming them:

{
"detail": "This calendar is still referenced and cannot be deleted.",
"code": "calendar_in_use",
"reference_count": 3,
"references": [{"type": "project", "name": "Harbor Fit-out"}]
}
CodeStatusMeaningExtra keys
protected_reference409Generic refused delete — other rows still reference this onereference_count, references?
calendar_in_use409The working calendar is still referencedreference_count, references?
skill_in_use409The skill is still referencedreference_count, references?
sprint_already_bound409The milestone is already bound to a sprint
sync_conflict409A stale write overlapped a concurrent writersee below
proposal_closed409The ceiling proposal is no longer open
not_open409The planning-poker round is not open for votes
not_live409The planning-poker session is not live
not_revealed409The planning-poker round has not been revealed yet
sprint_not_planned409The sprint is not in the PLANNED state
name_taken409A template with this name already exists in the pool you can see. Resend publish with new_version: true to extend that template’s chain insteadtemplate, version, next_version
seed_replace_required409A live program you own already uses this seed’s slug as its code, and the import did not confirm the replacementconflict
seed_replace_mismatch409expected_program_id does not name the program that would actually be replacedconflict
not_found404The targeted poker round or ceiling proposal does not exist

A sync_conflict carries the field-level divergence so the client can render a merge rather than guess:

{
"code": "sync_conflict",
"conflict_fields": ["name"],
"server_value": {"name": "..."},
"client_value": {"name": "..."},
"server_version": 41
}

Both seed_replace_* codes carry a conflict object describing the program the import would replace, so a client can render a confirmation naming a number and not just a name:

{
"detail": "A program you own already uses the code \"atlas\". Re-importing moves its projects to Trash. Confirm to continue.",
"code": "seed_replace_required",
"conflict": {
"program_id": "9c2d…",
"name": "Atlas Platform Launch",
"code": "atlas",
"project_count": 3,
"task_count": 214
}
}

Re-send with replace=true to confirm, optionally pinned to expected_program_id. The replaced program’s projects move to project Trash, where each can be restored individually as a standalone project — the program shell itself is not recoverable, and a restored project does not return to it. See Programs.

CodeMeaningExtra keys
program_schedule_invalid_inputA task in the program has data the schedule engine cannot computereason?, project?, task?
program_schedule_too_largeThe program exceeds the schedule-computation size limit
credential_requiredThe connection needs a credential that was not supplied
provider_verification_failedThe external provider rejected the supplied credential
source_verification_failedThe configured external source could not be verified
CodeMeaningExtra keys
sync_cooldownThe connection was refreshed too recently to sync againretry_after (seconds)

The general rate limiter is different: it returns a bare detail with no code, plus a Retry-After header. See rate limiting.

Codes that exist in code but not on the wire

Section titled “Codes that exist in code but not on the wire”

The codes on this page are the complete set that a client can actually branch on. A separate, larger family of code="..." values exists only as an internal annotation on a Django REST Framework ErrorDetail object — the task comment, attachment, note and reaction validation refusals (size, MIME type, reply depth, edit window, count caps; the six ownership refusals in the 403 table above are the exception, having been rebuilt as real body keys), signed download URLs, idempotency-key reuse, the sync id-collision conflict, and the phase-rollup-lock family (summary_rollup_locked, phase_status_rollup_locked, phase_estimate_rollup_locked, assignee_on_phase, time_log_on_phase, phase_in_sprint_forbidden, subtask_on_phase) all pass a code keyword when raising a ValidationError or a custom APIException.

That code never reaches the response body. Verified by invoking DRF’s own exception_handler against the exact raise sites: when detail is a plain string (or a dict whose value is a string), code stays attached to the ErrorDetail string subclass as a Python-side attribute — DRF’s JSON encoder renders an ErrorDetail as its plain string, so the wire body is just {"detail": "..."} (or a field-keyed message, for the phase-rollup family), with no sibling code key at all. This is a different code path from every structured code on this page, which is built as a literal Response({"code": "...", "detail": "...", ...}, status=...) — a real dict with code as its own key.

Do not rely on any of the codes named above as a wire contract. They read like the structured codes elsewhere on this page, but a client that if error.code === "attachment_too_large" will never match — only the detail prose changes, and this page tells you elsewhere never to match on that. Whether this is a bug (the raise sites should build a flat body like the rest of Shape 2) or intentional (these were meant to stay internal) is an open question, tracked in issue #2550 — not resolved here.

SSO failures are a third shape again. The login and callback endpoints are browser redirects, so the code arrives as an ?error= query parameter on the SPA completion URL — not in a response body:

https://ppm.example.com/auth/complete?error=invalid_state
CodeEquivalent statusMeaning
oidc_error400Generic SSO failure (the base code)
sso_not_configured400No enabled provider matched the request
invalid_state400The state parameter was missing, unknown, or did not match the cookie
token_exchange_failed400The provider rejected the authorization-code exchange
invalid_id_token400The ID token failed signature or claim validation
email_unverified403The provider reports the account’s email as unverified
sso_no_member403The authenticated identity maps to no workspace member
sso_account_disabled403The identity maps to a member whose account has been deactivated. Distinct from sso_no_member: the account exists and is a member, so the remedy is reactivation, not an invite
provider_unreachable502The provider’s discovery or JWKS endpoint could not be reached

These codes never carry token material or PII.

The admin provider-configuration endpoints under /api/v1/workspace/sso/providers/ are ordinary JSON APIs, not redirects, and add a refusal and two conflicts:

Code / statusMeaning
403 with refusal.constraint: capability_scopeThe caller authenticated with an API token. Provider configuration is session/JWT-only on every method, reads included — see Token management is session-only. A token that is revoked, expired, or carries the wrong scope is rejected earlier by the authenticator and gets 401 with refusal.reason: identity instead
409 on POST …/providers/A provider of that type is already configured. The provider type is its identity, so each type can be configured only once
409 with code: sso_removal_locks_out_members on DELETE …/providers/{slug}/Removing the provider would leave members with no way to sign in at all — no password and no other configured provider. The body carries locked_out_account_count. Re-send with ?confirm_lockout=true to proceed anyway

A 403 here can also come from the workspace-Admin gate when a signed-in non-admin calls it. That one carries detail alone, with no refusal envelope — which is how the two are told apart.

POST/PUT …/providers/ (or …/providers/{slug}/) also answers a bare 403 — no code, no refusal envelope — when default_role in the body is at or above the caller’s own workspace role: {"detail": "You cannot set default_role to a role equal to or higher than your own."}. This is the same shape as the not-an-Admin refusal above, so a client cannot tell the two apart from the status code alone — only an Owner can set default_role to Admin, matching the ceiling on changing an existing member’s role and on sending an invite.

A few code values appear on successful 2xx responses, inside a warnings array or a warning field. The write succeeded — these are advisories. Do not treat them as failures:

CodeAppears onMeaning
resource_overallocatedassignment writesThe resource’s load on at least one working day now exceeds their capacity. The detail names that day
skill_mismatchassignment writesThe resource lacks a skill the task requires
has_assignmentstask restructureA task became a summary task while still carrying assignments
scope_pending_on_closesprint closeScope-injection requests were still pending at close

The API stability contract applies as follows.

Within a minor release, TruePPM will not:

  • change the spelling of any code on this page;
  • change the HTTP status a listed code accompanies;
  • remove a listed code, or move one between the error and warning categories;
  • remove a documented extra key (retry_after, conflict_fields, rule, …) from a body that carries it.

Within a minor release, TruePPM may:

  • add new code values, including on endpoints that previously returned a bare detail;
  • promote an endpoint from Shape 1 to Shape 2 by introducing a code;
  • reword any detail string — detail is never part of the contract;
  • add new keys alongside code.

Not covered at all: the message strings in Shape 1 field errors, and the detail text everywhere. Match on the field key or on code, never on prose.

Because new codes may appear in a minor release, treat an unrecognized code as a generic failure of its HTTP status class rather than raising on it. That single rule is what keeps a client forward-compatible.