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.
When a field is named code
Section titled “When a field is named code”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.
Structured error codes
Section titled “Structured error codes”400 — refused writes
Section titled “400 — refused writes”| Code | Meaning | Extra keys |
|---|---|---|
invalid_body | The 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_anchor | Progress was set on a task with no date to anchor it to | — |
milestone_rollup_locked | The value is rolled up to the milestone and cannot be set directly | — |
child_of_milestone | A milestone is a zero-duration gate and cannot be given children | — |
zero_duration_not_milestone | A 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 legal | suggested_action |
guardrail_blocked | A configured guardrail refused the write | rule, suggested_action |
base_url_not_allowed | The supplied integration base URL is not permitted | — |
invalid_token | The password-reset uid+token pair is bad, unknown, or expired | — |
weak_password | The new password failed validation | messages |
pin_limit_reached | Pinning this project/program would exceed the account’s configured pin cap | — |
invalid_graph_input | The submitted dependency graph is malformed and cannot be interpreted | — |
self_reference | A task in the graph depends on itself | offending |
cyclic_dependency | The dependencies form a cycle | offending |
subtree_too_large | The targeted subtree exceeds the per-request cascade cap | matched, max |
Non-object request bodies
Section titled “Non-object request bodies”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:
$ 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.
403 — refused by policy
Section titled “403 — refused by policy”| Code | Meaning |
|---|---|
scope_accept_forbidden | The caller may not accept this scope-injection request |
attachment_delete_forbidden | The caller is neither the attachment’s uploader nor a project Admin+ |
comment_edit_not_author | Only the comment’s author may edit it (the edit window closing is a separate 400) |
comment_delete_forbidden | The caller is neither the comment’s author nor a project Admin+ |
reaction_delete_forbidden | The reaction belongs to another user |
note_edit_not_author | Only the note’s author may edit it (the edit window closing is a separate 400) |
note_delete_forbidden | The 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.
The archived-project 403
Section titled “The archived-project 403”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
codekey — match ondetailif you need to tell them apart. - Reads are unaffected, and so is taking access away.
GETon 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"}]}| Code | Status | Meaning | Extra keys |
|---|---|---|---|
protected_reference | 409 | Generic refused delete — other rows still reference this one | reference_count, references? |
calendar_in_use | 409 | The working calendar is still referenced | reference_count, references? |
skill_in_use | 409 | The skill is still referenced | reference_count, references? |
sprint_already_bound | 409 | The milestone is already bound to a sprint | — |
sync_conflict | 409 | A stale write overlapped a concurrent writer | see below |
proposal_closed | 409 | The ceiling proposal is no longer open | — |
not_open | 409 | The planning-poker round is not open for votes | — |
not_live | 409 | The planning-poker session is not live | — |
not_revealed | 409 | The planning-poker round has not been revealed yet | — |
sprint_not_planned | 409 | The sprint is not in the PLANNED state | — |
name_taken | 409 | A template with this name already exists in the pool you can see. Resend publish with new_version: true to extend that template’s chain instead | template, version, next_version |
seed_replace_required | 409 | A live program you own already uses this seed’s slug as its code, and the import did not confirm the replacement | conflict |
seed_replace_mismatch | 409 | expected_program_id does not name the program that would actually be replaced | conflict |
not_found | 404 | The 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.
422 — well-formed but unprocessable
Section titled “422 — well-formed but unprocessable”| Code | Meaning | Extra keys |
|---|---|---|
program_schedule_invalid_input | A task in the program has data the schedule engine cannot compute | reason?, project?, task? |
program_schedule_too_large | The program exceeds the schedule-computation size limit | — |
credential_required | The connection needs a credential that was not supplied | — |
provider_verification_failed | The external provider rejected the supplied credential | — |
source_verification_failed | The configured external source could not be verified | — |
429 — throttled
Section titled “429 — throttled”| Code | Meaning | Extra keys |
|---|---|---|
sync_cooldown | The connection was refreshed too recently to sync again | retry_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 error codes
Section titled “SSO error codes”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| Code | Equivalent status | Meaning |
|---|---|---|
oidc_error | 400 | Generic SSO failure (the base code) |
sso_not_configured | 400 | No enabled provider matched the request |
invalid_state | 400 | The state parameter was missing, unknown, or did not match the cookie |
token_exchange_failed | 400 | The provider rejected the authorization-code exchange |
invalid_id_token | 400 | The ID token failed signature or claim validation |
email_unverified | 403 | The provider reports the account’s email as unverified |
sso_no_member | 403 | The authenticated identity maps to no workspace member |
sso_account_disabled | 403 | The 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_unreachable | 502 | The 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 / status | Meaning |
|---|---|
403 with refusal.constraint: capability_scope | The 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.
Warning codes are not errors
Section titled “Warning codes are not errors”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:
| Code | Appears on | Meaning |
|---|---|---|
resource_overallocated | assignment writes | The resource’s load on at least one working day now exceeds their capacity. The detail names that day |
skill_mismatch | assignment writes | The resource lacks a skill the task requires |
has_assignments | task restructure | A task became a summary task while still carrying assignments |
scope_pending_on_close | sprint close | Scope-injection requests were still pending at close |
What the stability contract covers
Section titled “What the stability contract covers”The API stability contract applies as follows.
Within a minor release, TruePPM will not:
- change the spelling of any
codeon this page; - change the HTTP status a listed
codeaccompanies; - 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
codevalues, including on endpoints that previously returned a baredetail; - promote an endpoint from Shape 1 to Shape 2 by introducing a
code; - reword any
detailstring —detailis 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.