API Reference
The TruePPM REST API is documented via OpenAPI 3.0.3, auto-generated by drf-spectacular.
Interactive schema
Section titled “Interactive schema”| Format | URL |
|---|---|
| Swagger UI | http://localhost:8000/api/schema/swagger-ui/ |
| Raw YAML | http://localhost:8000/api/schema/ |
Base URL
Section titled “Base URL”http://localhost:8000/api/v1/Authentication
Section titled “Authentication”TruePPM uses JWT auth with a split-token model: the short-lived access token
is returned in the JSON body and held in memory by the client, while the
long-lived refresh token is delivered in an httpOnly, Secure,
SameSite=Strict cookie that JavaScript can never read. This protects the
high-value refresh credential from theft via XSS — an injected script can ride
the current session but cannot exfiltrate the refresh token.
Because the refresh token lives in a cookie, browser clients must send
credentials on the auth requests (fetch(..., { credentials: "include" }) or
xhr.withCredentials = true).
The access token is short-lived by design: 15 minutes (SIMPLE_JWT["ACCESS_TOKEN_LIFETIME"]).
Clients are expected to refresh well before it expires rather than wait for a
401 — see Refresh the access token below.
Log in
Section titled “Log in”POST /api/v1/auth/token/Content-Type: application/json
{"username": "...", "password": "...", "remember_me": false}Returns only the access token in the body:
{"access": "<jwt>"}The refresh token is set in a response cookie (default name trueppm_refresh),
scoped to Path=/api/v1/auth/token/refresh/ so it is sent only on the refresh
request and never on ordinary API calls:
Set-Cookie: trueppm_refresh=<jwt>; HttpOnly; Secure; SameSite=Strict; Path=/api/v1/auth/token/refresh/Pass the access token on all subsequent requests:
Authorization: Bearer <access_token>remember_me and refresh-token lifetime (ADR-0544)
Section titled “remember_me and refresh-token lifetime (ADR-0544)”The optional remember_me boolean on the login body controls both the
refresh JWT’s exp and whether the refresh cookie survives browser close. The
lifetime is conditional on the flag — it is not simply “long-lived”:
remember_me | Refresh token exp | Cookie | Behavior |
|---|---|---|---|
true | 30 days (TRUEPPM_REFRESH_TOKEN_REMEMBER_DAYS, default 30) | Persistent (Max-Age set) | Survives browser close; a deliberate opt-in to a long-lived credential on a trusted device. |
false (default, or omitted) | 12 hours, sliding (TRUEPPM_REFRESH_TOKEN_SESSION_HOURS, default 12) | Session cookie (no Max-Age) | Dies when the browser closes. Each refresh rotates the token and re-mints the 12h window, so an actively-used session never expires mid-work — the 12h ceiling only bites after 12h of idle time with the browser still open. |
The choice is carried as a remember claim inside the refresh JWT itself (not
server-side state), so it survives rotation automatically. SSO logins
(below) have no checkbox and are always session-scoped (remember_me is
implicitly false). A refresh token minted before this behavior shipped (no
remember claim) keeps the legacy 7-day persistent cookie unchanged — nobody
already logged in is forced to re-authenticate or silently downgraded to a
session cookie.
Refresh the access token
Section titled “Refresh the access token”POST /api/v1/auth/token/refresh/The refresh endpoint reads the refresh token from the cookie — it is no longer accepted in the request body, so the request has no body. It returns a new access token:
{"access": "<jwt>"}A request that arrives without a valid refresh cookie returns 401. When refresh
rotation is enabled the cookie is re-issued (rotated) on each successful refresh;
the previous token is blacklisted if the blacklist app is installed.
The
TokenRefresh/TokenRefreshRequestbody schemas are intentionally gone from the OpenAPI document — the refresh endpoint takes no request body.
Log out
Section titled “Log out”POST /api/v1/auth/logout/Clears the refresh cookie and best-effort blacklists the presented refresh token
(when the blacklist app is installed). Idempotent — always returns 205 Reset Content, whether or not a cookie was present.
The legacy bare AUTH_REFRESH_COOKIE_* names are still accepted as fallbacks.
| Setting | Default | Purpose |
|---|---|---|
TRUEPPM_AUTH_REFRESH_COOKIE_NAME | trueppm_refresh | Cookie name for the refresh token. |
TRUEPPM_AUTH_REFRESH_COOKIE_PATH | /api/v1/auth/token/refresh/ | Restricts the cookie to the refresh endpoint. |
TRUEPPM_AUTH_REFRESH_COOKIE_SAMESITE | Strict | CSRF posture — the cookie is never sent cross-site. |
TRUEPPM_AUTH_REFRESH_COOKIE_SECURE | True | HTTPS-only cookie. Set False only for non-TLS local development. |
Project-scoped API token (projectApiTokenAuth)
Section titled “Project-scoped API token (projectApiTokenAuth)”The inbound task-sync surface (and the
CI acceptance-result ingest endpoint, POST /api/v1/projects/{id}/acceptance-results/,
ADR-0148 — see Acceptance criteria) use a separate,
non-JWT scheme. Mint a token in Project settings → API tokens; it is scoped
to a single project (or, for programs/{id}/api-tokens/, to every project in a
program) and authorizes only these two endpoints (ADR-0068). Send it as a
bearer token:
POST /api/v1/projects/{project_id}/task-sync/Authorization: Bearer tppm_<64-hex>The schema advertises this scheme as projectApiTokenAuth. It is deliberately
not interchangeable with the JWT session — a logged-in user cannot call
task-sync with their normal credentials, so every inbound push is attributable
to a minted token. A token whose project does not match the URL returns 401
(not 403) so callers cannot enumerate project existence.
Personal Access Tokens (/api/v1/me/api-tokens/, ADR-0214)
Section titled “Personal Access Tokens (/api/v1/me/api-tokens/, ADR-0214)”GET /api/v1/me/api-tokens/POST /api/v1/me/api-tokens/DELETE /api/v1/me/api-tokens/{id}/A Personal Access Token (PAT) is a tppm_-prefixed, user-owned credential
minted from your own account rather than a project or program. POST returns
the raw token exactly once; it is never retrievable again. Two scopes on
create (scopes, defaulting to ["legacy:full"]): legacy:full (acts as
you, no expiry required) or mcp:read (safe methods only, expiry
required). Capped at 10 active tokens per user, and every PAT is revoked
automatically when the owning account’s password changes. DELETE
soft-revokes; both mint and revoke are audited. See
Personal Access Tokens for the full
walkthrough (creating, scope picker, the MCP-client config snippet) and
MCP server for connecting an AI client with an
mcp:read token.
A legacy:full PAT authenticates the general API — reads and writes — exactly
as your own session would (personalApiTokenAuth in the schema, #2547).
OwnerScopedApiTokenAuthentication is in the API’s default authentication
stack (prepended before JWT): it accepts an owner-scoped token carrying
legacy:full on any endpoint your JWT session could already reach, subject to
the identical RBAC — a Viewer’s PAT can only read what a Viewer sees, and a
PAT never elevates its owner’s role. Two narrower surfaces are unaffected by
this and keep their existing, more restrictive rules:
mcp:read-only tokens stay confined toMcpReadableViewMixin— mixed onto about fifteen read endpoints (your profile, project/program overview, forecast, schedule derivation, Monte Carlo, search, My Work, sprints, labels, board config, workspace assets) — which is read-only by construction (writes403for a token caller regardless of scope) and can be disabled per-instance or per-project; see MCP server. Alegacy:fulltoken also satisfies this surface (legacy:fullis a superset for reads), but anmcp:read-only token is rejected everywhere else — it is deliberately not interchangeable withOwnerScopedApiTokenAuthentication.TaskSyncViewand the acceptance-result ingest endpoint (above) still requireIsTokenForProject— the token’sproject/programFK must resolve to the URL’s project. A personal token has neither set, so a PAT still cannot authenticate inbound sync or CI acceptance ingestion; those two endpoints remain project/program-token-only by design (they attribute every push to a minted, team-visible integration credential, not an individual’s personal one).
Single sign-on (OIDC / OAuth2)
Section titled “Single sign-on (OIDC / OAuth2)”Self-service login against your own identity provider (Keycloak, Authentik, Authelia, Zitadel, Google, GitHub, GitLab, or any OIDC-compliant IdP) is a three-endpoint browser redirect flow, configured per-provider under Workspace settings → SSO providers. All three are unauthenticated and public by design — they are the login flow:
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/auth/oidc/discover/?email= | Which enabled provider(s) apply to an email’s domain (or all enabled providers with no email). Always 200; never reveals whether an account exists. |
| GET | /api/v1/auth/oidc/login/?provider=<slug> | Starts the flow for one provider: mints a single-use state/PKCE/nonce and 302s to the IdP’s authorization endpoint. |
| GET | /api/v1/auth/oidc/callback/?code=&state= | The IdP redirects here. On success: validates state, exchanges the code, verifies the ID token (or fetches GitHub userinfo), resolves or creates the local user, sets the refresh cookie, and 302s to the SPA completion route — no token ever appears in a URL. On failure it 302s to the same completion route with a non-sensitive ?error= code (see SSO error codes). |
SSO-authenticated sessions are always session-scoped (12h sliding, session
cookie) — there is no remember_me checkbox in an IdP redirect, so the safe
default applies unconditionally. The admin-facing provider CRUD
(/api/v1/workspace/sso/providers/) is a separate, authenticated surface; see
Workspace Settings.
This is deliberately basic, self-service login federation — OSS per the auth carve-out: an admin points TruePPM at their own IdP and users log in through it. SAML federation, SCIM provisioning, LDAP/AD directory sync, enforced org-wide SSO, and group→role mapping are Enterprise org-identity governance, not part of this surface.
Endpoints
Section titled “Endpoints”Calendars
Section titled “Calendars”| Method | Path | Description |
|---|---|---|
| GET | /api/v1/calendars/ | List |
| POST | /api/v1/calendars/ | Create |
| GET | /api/v1/calendars/{id}/ | Retrieve |
| PUT / PATCH | /api/v1/calendars/{id}/ | Update |
| DELETE | /api/v1/calendars/{id}/ | Soft-delete |
Projects
Section titled “Projects”| Method | Path | Description |
|---|---|---|
| GET | /api/v1/projects/ | List (scoped to your memberships) |
| POST | /api/v1/projects/ | Create (caller becomes Owner) |
| GET | /api/v1/projects/{id}/ | Retrieve |
| PUT / PATCH | /api/v1/projects/{id}/ | Update |
| DELETE | /api/v1/projects/{id}/ | Soft-delete |
Projects and programs carry the inheritable sharing settings. The override fields
public_sharing and allow_guests are nullable (null = inherit from the parent
scope) and writable by an Owner/Admin; the resolved fields effective_public_sharing,
inherited_public_sharing, effective_allow_guests, and inherited_allow_guests are
read-only. See Sharing & Access Inheritance.
Projects and programs also carry the inheritable attachment policy (the same Workspace → Program → Project chain). The override fields are writable by an Owner/Admin:
| Field | Type | Meaning |
|---|---|---|
attachments_enabled | boolean | null | Whether file uploads are permitted. null = inherit from the parent scope. |
allowed_attachment_types | string[] | null | MIME allow-list (tri-state): null = inherit, [] = explicitly allow nothing, [...] = an explicit set. |
The resolved fields effective_attachments_enabled,
inherited_attachments_enabled, effective_allowed_attachment_types, and
inherited_allowed_attachment_types are read-only. effective_* is the value in
force after inheritance; inherited_* is what the parent scope would supply
(what effective_* falls back to when the override is null).
Writing a MIME type that is permanently security-denied (text/html,
image/svg+xml, application/xhtml+xml) into allowed_attachment_types returns
400 — these can never be allowed, at any scope. An empty list is accepted. See
Task collaboration for how the resolved policy
governs uploads.
Project members
Section titled “Project members”| Method | Path | Description |
|---|---|---|
| GET | /api/v1/projects/{id}/members/ | List (Viewer+) |
| POST | /api/v1/projects/{id}/members/ | Add member (Owner only) |
| GET | /api/v1/projects/{id}/members/{mid}/ | Retrieve |
| PATCH | /api/v1/projects/{id}/members/{mid}/ | Change role (Owner only) |
| DELETE | /api/v1/projects/{id}/members/{mid}/ | Remove (Owner, or self) |
See RBAC for the permission matrix and role escalation rules.
Programs
Section titled “Programs”A program is a container for related projects (see Programs).
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/programs/ | List (scoped to your memberships) |
| POST | /api/v1/programs/ | Create (caller becomes Owner) |
| GET | /api/v1/programs/{id}/ | Retrieve |
| PUT / PATCH | /api/v1/programs/{id}/ | Update |
| DELETE | /api/v1/programs/{id}/ | Soft-delete |
| GET | /api/v1/programs/samples/ | List the bundled samples available to the demo loader |
| POST | /api/v1/programs/load-sample/ | Load a bundled sample program (the in-app “Load demo data” action); body {"sample": "<key>"} |
| POST | /api/v1/programs/import/ | Import a JSON seed document as a new program (raw JSON body or multipart file upload); caller becomes Owner. Returns 202 Accepted — the program shell is created synchronously, the subtree is built by a worker. Optional replace / expected_program_id fields confirm a replacement; 409 without them |
| GET | /api/v1/programs/{id}/import/jobs/{job_id}/ | Poll one seed import job (Program Admin+). A job_id belonging to another program 404s |
| POST | /api/v1/programs/import/validate/ | Dry run — validate a JSON seed document and return every diagnostic, persisting nothing. Same request shapes and permissions as import/. An invalid document is 200 {"valid": false, "errors": [...]}, not a 400: the request succeeded, the document is what failed. Also echoes the schema version, program slug/name, project/task/resource counts the file claims, and a replaces object naming the program this import would replace (null when the slug is free), so you can confirm you grabbed the right file — and see what it would cost — before running the destructive import |
| GET | /api/v1/programs/{id}/export/ | Download the program as a canonical JSON seed file (Content-Disposition: attachment) |
| GET | /api/v1/programs/{id}/rollup-config/ | Read the program rollup KPIs config (enabled KPIs + aggregation policy) |
| PATCH | /api/v1/programs/{id}/rollup-config/ | Update the program rollup KPIs config (Admin only) |
| GET | /api/v1/programs/{id}/risk-policy/ | Read the program risk & dependencies policy |
| PATCH | /api/v1/programs/{id}/risk-policy/ | Update the program risk & dependencies policy (Admin only) |
| POST | /api/v1/programs/bulk-fields/ | Bulk-set inherited settings (methodology, iteration label, risk policy) across multiple programs; body {"ids": [...], "fields": {...}} — only the named rows and fields change (Workspace Admin) |
| POST | /api/v1/programs/{id}/bulk-project-fields/ | Bulk-set inherited settings (methodology, iteration label) across this program’s projects; body {"ids": [...], "fields": {...}} (Program Admin) |
| GET | /api/v1/programs/{id}/resource-contention/ | Within-program resource contention across member projects (Scheduler+; optional ?start= / ?end= window, repeatable ?resource= / ?status=) |
| GET | /api/v1/programs/{id}/schedule/ | Program-true cross-project critical path — merges every member project’s tasks and every accepted cross-project dependency into one CPM run, computed on read. Tasks in projects you cannot read are redacted to a minimal card (title + forecast dates only); links are flagged cross-project (any program member) |
| POST | /api/v1/programs/{id}/split/ | Split a program into sub-programs — planned, not yet implemented (returns 501) |
Both write endpoints carry a 6/min per-account scoped limit (see
Rate limiting below).
Seed import is asynchronous
Section titled “Seed import is asynchronous”POST /api/v1/programs/import/ returns 202 Accepted:
{ "queued": true, "program_id": "0f3a…", "import_request_id": "b71c…", "replaced_program_id": null}The program shell exists at program_id the moment this returns — validation,
the replace decision, the replacement itself, and the shell creation all happen
inside the request — so a client can navigate straight to it. Only the O(n)
subtree build (projects, tasks, sprints, dependencies) is queued. Poll:
GET /api/v1/programs/{program_id}/import/jobs/{import_request_id}/which returns { id, program, status, filename, replace, replaced_program_id, result_summary, error_detail, expires_at, created_at, started_at, completed_at }.
status is one of pending, running, success, failed. On success,
result_summary carries the entity counts { projects, tasks, sprints, dependencies }; on failure, error_detail carries the reason and the (empty)
program shell is deliberately left in place so you can see what happened and
retry or delete it. The poll endpoint requires Program Admin+, and a
job_id from another program 404s.
A malformed or oversized seed document still returns 400 synchronously —
validation runs before anything is queued. SEED_MAX_UPLOAD_MB is enforced on
both the multipart upload and the raw JSON body.
Replacing a program requires confirmation
Section titled “Replacing a program requires confirmation”A seed’s program.slug is persisted as Program.code. If a live program you
own already uses that code, the import refuses:
HTTP/1.1 409 Conflict{ "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 }}Two optional request fields confirm it — sent as multipart form fields alongside
file, or as sibling keys on a JSON body:
| Field | Type | Default | Meaning |
|---|---|---|---|
replace | boolean | false | Authorizes replacing whatever collides |
expected_program_id | UUID | — | Compare-and-swap: must equal the program that would actually be replaced |
expected_program_id exists so a client acting on an earlier dry run cannot
destroy the wrong program if the collision moved in between; a mismatch is
refused with 409 and code: "seed_replace_mismatch", carrying the same
conflict object. Only programs on which you hold a live Owner membership
are ever candidates, which is why naming one back to you leaks nothing.
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. Offline clients receive real deletion tombstones for the removed rows.
Loading a bundled sample is unchanged
Section titled “Loading a bundled sample is unchanged”POST /api/v1/programs/load-sample/ still runs synchronously and returns
201 Created with a {program, landing_project_id, sample_key} envelope —
landing_project_id is the project board to land a contributor on so their
assigned work is visible (null when the sample has no open sprint), and
sample_key echoes the loaded sample. Its payload is a server-curated bundled
fixture of at most a few hundred entities, so a call takes seconds; allow a
generous request timeout and do not poll it. Reloading a sample still deletes
the previous copy outright — demo data is disposable — and never replaces a
program containing a real, non-sample project. See
Sample projects.
The rollup-config and risk-policy endpoints use a method-level permission
split: GET is open to any program member (closed programs remain readable for
audit), while PATCH requires the Admin role and is blocked on closed programs.
Both are partial updates — send only the fields you want to change — and every
successful PATCH is audited automatically.
resource-contention returns each resource with their task spans across every
member project of the program, each span tagged with its source project, so the
client can surface people over-allocated across sibling projects in overlapping
windows. Overallocation detection is intentionally client-side. The window
defaults to the earliest span start and latest finish across member projects; it
returns 409 if no member project has a computed schedule yet, and 400 for an
invalid date or a start after end. This is within-program visibility only —
cross-program leveling and the portfolio heat map remain Enterprise.
Each task span in resource-contention (and the per-project
resource-allocation it mirrors) windows and renders on scheduled_start
through early_finish — the task’s span — not early_start through
early_finish, the narrower remaining-work window early_start shrinks
toward as an in-progress task’s percent_complete rises (ADR-0752). early_start
is still returned for tasks CPM has not populated scheduled_start on yet, in
which case the client falls back to it.
Program split is a planned endpoint that validates the request payload and
the caller’s Owner role, then returns 501 Not Implemented with a detail
message and a tracking_issue number. The request contract it accepts is
{"splits": [{"name": str, "project_ids": [uuid]}, ...]}; the working
implementation is not yet available.
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/tasks/ | List (filter: ?project=, ?is_critical=true) |
| GET | /api/v1/tasks/search/ | Board card search (required: ?project=, ?q=); returns slim {id, name, status, short_id} matches |
| POST | /api/v1/tasks/ | Create |
| GET | /api/v1/tasks/{id}/ | Retrieve |
| PUT / PATCH | /api/v1/tasks/{id}/ | Update |
| DELETE | /api/v1/tasks/{id}/ | Soft-delete (cascades to edges) |
| POST | /api/v1/projects/{id}/tasks/bulk/ | Apply many task writes, and optionally dependency edges, in one request — returns 207, see Batch task writes |
| PATCH | /api/v1/projects/{id}/tasks/classification/ | Classify a subtree on the governance and delivery axes — see Subtree classification |
| POST | /api/v1/tasks/delete-untouched-seeded/ | Bulk soft-delete every untouched-seeded row in a project — see Seed provenance |
CPM fields (early_start, early_finish, late_start, late_finish, total_float, is_critical) are read-only — set by the auto-scheduler. early_start/early_finish name the remaining-work window for an in-progress task, not its span — a 4-day task at 83% carries a one-day early_start..early_finish (ADR-0132). scheduled_start (paired with early_finish as scheduled_finish for symmetry — not a separate stored field, always identical to early_finish) and remaining_duration (also read-only) instead name the task’s span and the working days of work left on it, so a consumer never has to branch on task state to know which quantity a date field means (ADR-0752). Any client that assumes finish − start ≈ duration should read scheduled_start/scheduled_finish, not early_start/early_finish.
Assigning a phase (a task that rolls up one or more real child tasks) to a sprint is rejected unconditionally with 400 and a standard field error on sprint carrying the stable code phase_in_sprint_forbidden. This is a hard invariant — it is not affected by the project’s guardrail policy and cannot be escalated or relaxed by an Owner (assigning a phase to a sprint double-counts velocity). Assign the child tasks inside the phase instead. Other sprint-composition guardrails (summary_in_sprint, task_outside_sprint_window, recurring_in_sprint) remain Warn-by-default and are configurable via the guardrail policy.
Seed provenance
Section titled “Seed provenance”Every task records where it came from and whether a person has touched it since. All six fields are read-only — a client cannot assert its own provenance, and cannot clear the edited stamp on a row it edited.
| Field | Meaning |
|---|---|
source_kind | What wrote the row: hand, template, seed_import, csv_import, msproject_import, jira_import, paste. Defaults to hand, which is also what every row created before 0.4 reports |
source_id | Id of the template or import job that wrote it; null for hand-authored rows |
source_version | Template version the row was seeded from; empty string for every other source |
seeded_at | When a machine wrote the row; null on hand-authored rows |
edited_at | Last human-caused write; null means nobody has ever touched the row |
is_untouched_seed | The server’s verdict: seeded_at is set and edited_at is not |
Read is_untouched_seed rather than re-deriving it from the two timestamps. It is
the predicate behind the seeded-project landing’s “Delete untouched rows (N)”
offer, so a client copy that drifts by one clause would disagree with the server
about what a sweep is going to delete. It carries no time window — the
seven-day offer applies the window itself.
A recalculation never counts as an edit: the scheduling engine persists CPM output
through a path that does not touch edited_at, so a freshly seeded project still
reports its rows as untouched after the first schedule pass.
POST /api/v1/tasks/delete-untouched-seeded/ carries out the “Delete untouched
rows (N)” offer. The body is {"project": "<uuid>"} and nothing else — there is no
way to pass an explicit id list. The server recomputes the untouched set itself
(the same is_untouched_seed predicate, unwindowed) rather than trusting a
client-supplied one, because the affordance’s entire safety story is “these rows
were never touched,” and only the server can assert that. Returns
200 {"deleted": N}. Requires Project Manager (Admin) or above on the project —
checked explicitly rather than through the usual project-scoped permission class,
since this route is not nested under /projects/{id}/ and carries no URL-level
project id to gate on.
Project templates
Section titled “Project templates”| Method | Path | Description |
|---|---|---|
| GET | /api/v1/project-templates/ | Gallery (filter: ?program=) |
| POST | /api/v1/project-templates/publish/ | Freeze a project’s shape as a template |
| POST | /api/v1/project-templates/{id}/apply/ | Apply to a project — returns 202 |
| GET | /api/v1/template-applications/ | Adoption records (filter: ?project=) |
| POST | /api/v1/template-applications/{id}/undo/ | Reverse one application |
apply returns 202 {"queued": true, "application": "<uuid>"} — not a task id.
Dispatch is best-effort behind a transactional outbox, so there may be no Celery id
yet (or ever, for a delivery the drain re-dispatches). The application id is the
durable handle: it exists the moment the request commits, and GET /api/v1/template-applications/{id}/ reports pending → running → success /
failed.
The gallery does not publish the structure document. A gallery reader is a
wider audience than the source project’s members, so a whole project’s shape
(task names included) must not ride a list endpoint. task_count is the server’s
count off the frozen document, so it cannot disagree with what apply will write.
provenance is the display chip. source_kind is stored, so every reader agrees
on workspace and community; Yours is resolved per reader, because it is the
only tier that depends on who is asking.
Publishing and applying both require Project Manager (Admin) or above on the project in question (ADR-0773). Reading the gallery requires only authentication.
Apply is rate-limited on the shared seed_import throttle scope — the same bound
the seed and spreadsheet import paths carry.
Phase rollup locks
Section titled “Phase rollup locks”A phase is a non-subtask task with at least one structural (non-subtask)
child. A phase is a pure rollup: its status, estimate, assignee, percent-complete,
and logged time are all computed from its children and cannot be set directly. The
task serializer exposes a read-only computed boolean, is_phase, alongside the
existing is_summary:
is_summary— the task has any direct WBS child (including drawer subtasks).is_phase— the task has at least one direct child that is not a subtask.
The distinction matters: a leaf broken into drawer subtasks is is_summary: true
but is_phase: false, and stays fully writable. Only a task with real structural
children is a phase.
Writing a rolled-up attribute directly onto a phase returns 400 with a stable
error code. Each lock fires only when the request actually changes the locked
attribute — a PATCH that omits the field, or re-sends its current value, still
succeeds.
| Write to a phase | Error code |
|---|---|
percent_complete | summary_rollup_locked |
status | phase_status_rollup_locked |
optimistic_duration / most_likely_duration / pessimistic_duration | phase_estimate_rollup_locked |
assignee | assignee_on_phase |
| logging time against a phase (see Time tracking) | time_log_on_phase |
Phase → phase dependencies, baselines, and Monte Carlo are not restricted — those are derived/aggregate, not direct writes of leaf-owned values.
Batch task writes
Section titled “Batch task writes”POST /api/v1/projects/{id}/tasks/bulk/ applies many task writes in one request.
It is the endpoint behind paste-many, import, and agent-authored drafting.
Rows apply independently, and the response is 207 — not 200. One
unparseable row out of 38 does not discard the other 37. Every operation is
reported in exactly one of three buckets:
{ "applied": [{ "index": 0, "id": "…", "op": "create", "outcome": "created", "task": { } }], "rejected": [{ "index": 7, "id": null, "code": "malformed_id", "message": "…" }], "skipped": [{ "index": 9, "id": "…", "code": "tombstoned", "message": "…" }], "dependencies": { "applied": [], "rejected": [] }}index — the zero-based position of the operation in the request’s operations
array — is the correlation handle, not id. A row rejected because its id
could not be parsed has no usable id to echo back, and a create may legitimately
omit one.
skipped is a documented no-op, never a failure: a create whose id matches a
deleted row, or a classification that crossed a milestone gate.
code | Meaning |
|---|---|
malformed_id | id was not a UUID. Reported before any database query runs |
id_unavailable | The id cannot be used. Deliberately non-asserting — it does not reveal whether the id exists in a project you cannot see |
not_found | No such task in this project |
forbidden | Your role does not permit this row’s operation |
invalid | The row body failed validation |
conflict | A database constraint rejected the row |
cyclic_dependency / self_reference | The edge would make the schedule infeasible |
unresolved_endpoint | An edge endpoint is not a live task in scope |
Client-minted ids
Section titled “Client-minted ids”A create may carry its own id, and the server takes that UUID as the primary
key verbatim — it is never remapped. This matches the offline sync push, so a row
authored in the planner, pulled to a phone, edited offline, and pushed back travels
under one id the whole way. Omit id and the server mints one.
A create whose id already exists in this project is not a duplicate and not
an error: it applies as an in-place edit ("outcome": "updated") under the stricter
edit permission, and never creates a second row.
Dependencies
Section titled “Dependencies”An optional dependencies.created bucket writes edges after every task row exists,
so an edge may name a task whose create appears later in operations:
{ "operations": [ { "op": "create", "id": "3f1c…a1", "data": { "name": "Survey", "duration": 3 } }, { "op": "create", "id": "9b40…c7", "data": { "name": "Design", "duration": 5 } } ], "dependencies": { "created": [{ "predecessor": "3f1c…a1", "successor": "9b40…c7", "dep_type": "FS", "lag": 0 }] }}Edges name plain task UUIDs — there is no positional or by-name reference syntax. Creating an edge requires Resource Manager or above, checked per edge, so a Team Member’s task rows still apply while only their edge rows are refused. Every edge is checked against the dependency-graph guard before any of them is written; a detected cycle refuses the edges on the cycle path and leaves the task rows applied.
Limits and replay
Section titled “Limits and replay”- At most 500 operations and 500 dependency edges per request.
- Send an
Idempotency-Keyheader. A byte-identical replay returns the stored207withIdempotent-Replay: trueand performs no writes at all — see Idempotency. - Whenever any row commits, the schedule is recalculated and a
tasks_bulk_mutatedevent is broadcast carrying only the ids that actually changed.
Authoring the plan requires Team Member or above. The Resource Manager role is
excluded: it sits above Team Member in the role order but cannot edit task content,
so it could otherwise create rows it was then unable to change. Read
can_author on the project resource rather than comparing role ordinals yourself.
Subtree classification
Section titled “Subtree classification”PATCH /api/v1/projects/{id}/tasks/classification/ declares how a subtree is
governed and how it is delivered, in one call.
These are two orthogonal fields, not one choice.
| Field | Values | Means | Inherit bit |
|---|---|---|---|
governance_class | gated · flow · hybrid | which overlay governs the subtree | yes (parent_governance_inherited) |
delivery_mode | waterfall · scrum · kanban · milestone | how work is executed, estimated, rolled up | no |
scrum and kanban are not interchangeable: a scrum node rolls up from
story-point burndown and samples the team velocity distribution in Monte Carlo, a
kanban node rolls up from item throughput. Send whichever the team actually runs.
{ "subtree": "3f1c…a1", "cascade": true, "governance_class": "gated", "delivery_mode": "scrum", "preserve_governance_overrides": true, "skip_milestones": true}subtree is required — the generated OpenAPI schema marks every PATCH body
field optional, which under-declares it. Supply governance_class,
delivery_mode, or both — a request naming neither is a 400. cascade: false classifies the named task alone; otherwise the server
resolves the subtree from the WBS and the caller sends no row list. A cascade
cannot set delivery_mode to milestone — converting a task into a gate also has
to zero its duration, so that stays a single PATCH on the task.
The response is 200, and it reports each axis separately:
{ "subtree": "3f1c…a1", "matched": 24, "governance": { "requested": "gated", "applied": 21, "unchanged": 0, "overrides_kept": 1, "has_inherit_bit": true }, "delivery_mode": { "requested": "scrum", "applied": 21, "unchanged": 0, "overrides_kept": null, "has_inherit_bit": false }, "skipped": [ { "id": "9b40…c7", "code": "milestone_gate", "axes": ["governance_class", "delivery_mode"], "message": "…" } ]}overrides_kept is null on delivery_mode — not 0. Only
governance_class carries an inherit bit, so only it can have an override; zero
would claim the data had none, where the truth is that the axis cannot have one.
An axis you did not send is absent from the response entirely.
What survives a cascade
Section titled “What survives a cascade”- Explicit governance overrides. A descendant that declared its own governance
(
parent_governance_inherited: false) keeps it, and is counted inoverrides_kept. Sendpreserve_governance_overrides: falseto overwrite it. The override is governance-only: that row still receives the cascadeddelivery_mode. - Milestones. A milestone’s
delivery_modeis never rewritten, under any request.is_milestone,delivery_mode: "milestone"andduration: 0are three encodings of one fact, and a cascade that broke them would dissolve every gate in the phase.skip_milestonesgoverns the governance axis on those rows only: leave ittrueand a milestone is untouched; sendfalseand it takes the governance class but still keeps its delivery mode. Either way it appears inskippedwith the axes that were withheld.
The subtree root itself is written with parent_governance_inherited: false —
declaring a subtree’s governance is what breaking inheritance means — and its
cascaded descendants with true.
Limits and errors
Section titled “Limits and errors”- At most 2000 resolved tasks. A larger subtree is a
400with codesubtree_too_largeand the matched count; it is never truncated. - Permission is all-or-nothing. If you cannot edit every row in the subtree the
whole request is
403and nothing is written. Unlike batch task writes, a partially applied cascade would leave the plan asserting a split that is not true. - A project whose dependency graph is already cyclic is a
400(cyclic_dependency) — the cascade triggers a recalculation, and an infeasible graph is refused before the schedule engine sees it. - Re-sending an identical request writes nothing: rows already at the requested
values report under
unchanged, and no recalculation or broadcast is triggered. - When something does change, the schedule is recalculated and a
tasks_bulk_mutatedevent carries the ids that changed.
Authoring requires Team Member or above, with the same Resource Manager exclusion as batch task writes.
Task attachments
Section titled “Task attachments”Each attachment is either an uploaded file or an external URL — never both.
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/projects/{id}/tasks/{task_id}/attachments/ | List (Viewer+) |
| POST | /api/v1/projects/{id}/tasks/{task_id}/attachments/ | Add (Member+); multipart file xor external_url |
| GET | /api/v1/projects/{id}/tasks/{task_id}/attachments/{att_id}/ | Retrieve (Viewer+) |
| DELETE | /api/v1/projects/{id}/tasks/{task_id}/attachments/{att_id}/ | Soft-delete (uploader or Admin+) |
| GET | /api/v1/projects/{id}/tasks/{task_id}/attachments/{att_id}/signed-url/ | Issue a short-lived download URL (file attachments only) |
File uploads are governed by the project’s resolved attachment policy
(effective_attachments_enabled / effective_allowed_attachment_types on the
project — see Projects):
- If the resolved
attachments_enabledisfalse, a file upload returns403. External-URL attachments are not affected byattachments_enabled. - The uploaded file’s MIME type must be in the project’s resolved allow-list
(not a fixed list). A disallowed type returns
415with codeattachment_unsupported_mime. The declared MIME is also content-sniffed against the real bytes, so a payload that masquerades as an allowed type is rejected with415and codeattachment_content_mismatch. - External-URL attachments must use an
http(s)scheme.
Signed URLs require an object-storage backend that actually signs its URLs
(S3/MinIO, GCS, or Azure Blob via django-storages — see
Configuration).
On FileSystemStorage (the default) or an unrecognized backend, the
signed-url action returns 501 rather than a link claiming an expires_at
it can’t honor.
Time tracking
Section titled “Time tracking”Logging time requires Team Member role or above on the task’s project
(can_log_time); a Viewer who reads a task sees can_log_time: false. Every entry
is owned by the logged-in user — the owner is server-set and cannot be supplied in
the request body. Each contributor sees only their own hours; there is no
cross-contributor rollup in the community edition.
| Method | Path | Description |
|---|---|---|
| POST | /api/v1/tasks/{task_pk}/time-entries/ | Log time against a task (minutes 1–1440, optional entry_date, note); Member+ |
| GET | /api/v1/tasks/{task_pk}/time-entries/ | The caller’s own entries on the task plus total_logged_minutes; Viewer+ (theirs may be empty) |
| PATCH | /api/v1/me/time-entries/{id}/ | Edit minutes / entry_date / note — author only (others get 404) |
| DELETE | /api/v1/me/time-entries/{id}/ | Soft-delete an entry — author only |
| GET | /api/v1/me/time-entries/?from=&to= | Weekly cross-project rollup (results + totals.by_day / by_cell / today_minutes / week_minutes); defaults to the current week |
| GET | /api/v1/me/timer/ | The caller’s running timer with server-computed elapsed_seconds / stale, or {active: false} |
| POST | /api/v1/me/timer/start | Start a timer ({task, note?}); a second start atomically stops and logs the running timer first, returning it as finalized_entry; Member+ |
| POST | /api/v1/me/timer/stop | Stop the running timer and log it as a TimeEntry (source: "timer"); 409 if no timer is running |
A manual entry_date cannot be in the future, nor older than the backdate window
(TIMETRACKING_BACKDATE_DAYS, default 60 days). A timer left running past the stale
ceiling (TIMETRACKING_TIMER_MAX_MINUTES, default 600) is flagged stale: true, and
on stop its logged minutes are capped at the ceiling rather than the raw elapsed time.
Time cannot be logged against a phase (a task with structural children — see
Phase rollup locks): a phase rolls up the logged time of its
child tasks, so a direct entry would double-count. Logging time or starting a timer
on a phase returns 400 with code time_log_on_phase.
Sprint–milestone binding
Section titled “Sprint–milestone binding”| Method | Path | Description |
|---|---|---|
| POST | /api/v1/sprints/{id}/promote-to-milestone/ | Bind the sprint’s commitment to a schedule milestone so sprint velocity reforecasts the CPM finish |
| POST | /api/v1/sprints/{id}/unbind-milestone/ | Remove the binding between the sprint and its milestone |
See Sprint–milestone rollup for the UI workflow and error codes.
Dependencies
Section titled “Dependencies”| Method | Path | Description |
|---|---|---|
| GET | /api/v1/dependencies/ | List (filter: ?project=, ?dep_type=FS, ?task=) |
| POST | /api/v1/dependencies/ | Create |
| GET | /api/v1/dependencies/{id}/ | Retrieve |
| PUT / PATCH | /api/v1/dependencies/{id}/ | Update |
| DELETE | /api/v1/dependencies/{id}/ | Soft-delete |
| POST | /api/v1/dependencies/{id}/accept/ | Accept a pending cross-project edge (downstream Resource Manager+) |
| POST | /api/v1/dependencies/{id}/reject/ | Reject (soft-delete) a pending cross-project edge |
Predecessor and successor may belong to the same project or to two projects in the same program. Cross-program edges return HTTP 400 (the Enterprise boundary is unchanged). A cross-project edge whose successor sits in a project the creator cannot schedule is created pending: it is inert until the downstream project’s Resource Manager+ accepts it via accept/. Once accepted, the program’s schedule recomputes across the boundary so floats and criticality are program-true on every member project’s own schedule (not only the program schedule view).
The read-only is_driving flag marks each link whose relationship free float is zero — the predecessor that actually controls (drives) its successor’s early date. It is a CPM output set by the auto-scheduler (clients cannot write it), used by the schedule view to weight driving links above slack ones.
Cross-project slip conflicts
Section titled “Cross-project slip conflicts”When an accepted cross-project dependency pushes a committed task in an active sprint past its sprint boundary, the program recompute records a slip conflict for the downstream team. The dates stay honest — the firewall never moves a sprint, its membership, or its commitment math; it only surfaces the conflict for the team to acknowledge and resolve their own way.
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/slip-conflicts/ | List (filter: ?program=, ?project=, ?sprint=, ?open=true) — scoped to your member projects |
| GET | /api/v1/slip-conflicts/{id}/ | Retrieve |
| POST | /api/v1/slip-conflicts/{id}/acknowledge/ | Acknowledge (downstream Scrum Master / Product Owner facet, or Admin+) |
Acknowledgment is an audit act — “seen, handling it” — not a schedule change; only a member of the threatened project with the Scrum Master / Product Owner facet (or Admin+) may acknowledge. A conflict that stops slipping (the task moves out, the sprint is extended, the edge is rejected) auto-resolves on the next recompute.
Monte Carlo
Section titled “Monte Carlo”| Method | Path | Description |
|---|---|---|
| POST | /api/v1/projects/{id}/monte-carlo/ | Run a probabilistic schedule simulation synchronously and return P50/P80/P95 (no state written) |
| GET | /api/v1/projects/{id}/monte-carlo/latest/ | Retrieve the most recently recorded simulation run for the project |
| GET | /api/v1/projects/{id}/monte-carlo/history/ | List recorded simulation runs for the project |
The run endpoint accepts an optional n_simulations in the body; it must not
exceed the OSS simulation cap or the request returns 402. See
Monte Carlo and the MC_* caps in
Configuration.
Resources
Section titled “Resources”| Method | Path | Description |
|---|---|---|
| GET | /api/v1/resources/ | List (per-user throttle: 60 req/min) |
| POST | /api/v1/resources/ | Create |
| GET | /api/v1/resources/{id}/ | Retrieve |
| PUT / PATCH | /api/v1/resources/{id}/ | Update |
| DELETE | /api/v1/resources/{id}/ | Soft-delete |
| GET | /api/v1/resources/{id}/assignments/ | Cross-project task assignments for one resource (org admin only) |
The resource catalog is readable by any authenticated user, so the email field
is gated to prevent org-wide address harvesting: org admins (Admin or Owner
on any project, or superusers) receive email on every row, and a caller
always sees their own email (is_me: true). For all other callers the email
field is omitted from the payload entirely. A per-user throttle of 60 req/min
applies to the list endpoint to bound bulk scraping; exceeding it returns
429 Too Many Requests.
assignments/ returns every task the resource is assigned to, across all
projects, ordered by project then task name (soft-deleted tasks excluded;
completed tasks included; a deactivated resource still returns its assignments).
Because it carries task and project names — project-scoped confidential data
that the base catalog read deliberately withholds — it requires org-admin
(resource-manager: Admin or Owner on any project); other callers receive
403 Forbidden. It is a read-only projection: no utilization score, no
overallocation flag, and no cross-program rollup. Each row carries:
| Field | Type | Description |
|---|---|---|
id | UUID | Assignment (allocation) id |
task | UUID | Task id |
task_name | string | Task name |
project | UUID | Project id (client group key) |
project_name | string | Project name |
status | string | Task status |
percent_complete | number | Task completion percentage |
units | decimal | Allocated fraction of the resource’s capacity (0.5 = 50%) |
Task-resource assignments
Section titled “Task-resource assignments”| Method | Path | Description |
|---|---|---|
| GET | /api/v1/task-resources/ | List |
| POST | /api/v1/task-resources/ | Assign |
| GET | /api/v1/task-resources/{id}/ | Retrieve |
| PUT / PATCH | /api/v1/task-resources/{id}/ | Update |
| DELETE | /api/v1/task-resources/{id}/ | Remove |
Writes on this route require Resource Manager (Scheduler) or above — it is the
allocation-management surface. To assign an owner while composing a task, use the
task write’s owners field below, which takes the authority of the task write itself.
Assigning owners inline on a task write
Section titled “Assigning owners inline on a task write”POST /api/v1/tasks/ and PATCH /api/v1/tasks/{id}/ accept a write-only owners
array that creates TaskResource rows in the same request:
{ "name": "Draft the migration plan", "owners": [{ "resource": "8e2b…", "units": "0.5" }]}| Field | Type | Notes |
|---|---|---|
resource | uuid | Must be on the destination project’s roster (/project-resources/). An id outside it is a 400 on the owners field — never a silent drop, and never a match-or-create against the workspace-wide resource library. |
units | decimal | Fraction of full capacity, 0.01–2.0 (0.5 = 50%). Defaults to 1.0. |
Semantics worth pinning down:
- Write-only. The read projection is the nested
assignmentsarray on the task. - Upsert, not replace-set. Naming a resource already assigned updates its
units; naming a new one adds it. Owners not listed are left alone, so a one-owner write can never delete a co-assignee.[]is a no-op, not “remove everyone” — removal goes throughDELETE /api/v1/task-resources/{id}/. - Authority is the task write’s.
ownersadds no permission class: it is gated by whatever gates the surrounding create or update. - Never sets
assignee.Task.assigneeis the legacy quick-assign field and carries no units; every capacity, utilization, heat-map and sprint-capacity computation readsTaskResourceonly.ownersis the field that makes assigned work count. - Assigned resources are auto-added to the project roster, so an owner is never assigned yet invisible in Team → Roster.
- A summary task rejects an inline owner (
400) — summary rows roll up from children.
See ADR-0774.
Project resource roster
Section titled “Project resource roster”| Method | Path | Description |
|---|---|---|
| GET | /api/v1/project-resources/ | List the roster (filter: ?project=) |
| POST | /api/v1/project-resources/ | Add a resource to a project’s roster (Scheduler+) |
| GET | /api/v1/project-resources/{id}/ | Retrieve |
| PUT / PATCH | /api/v1/project-resources/{id}/ | Update (Scheduler+) |
| DELETE | /api/v1/project-resources/{id}/ | Remove from roster (Scheduler+) |
| DELETE | /api/v1/project-resources/{id}/?force=true | Force-remove and cascade-delete the resource’s task assignments |
A plain DELETE returns 409 Conflict with code has_assignments if the
resource has live task assignments on the project; the response body lists the
affected_tasks, a sample of task_names, and the assignment_count. Passing
?force=true cascades the deletion to the resource’s TaskResource rows on the
project and triggers a CPM recalculation for the affected tasks. All write and
delete operations require the Scheduler role or higher on the project.
Workspace
Section titled “Workspace”| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/v1/workspace/ | Any active member | Retrieve workspace config. |
| PATCH | /api/v1/workspace/ | Workspace Admin+ | Update workspace config (partial). |
The workspace config includes public_sharing and allow_guests (the inheritance
defaults for all programs and projects) and public_sharing_override_policy
(suggest/enforce). enforce is an Enterprise-only lock and degrades to suggest in
the community edition. See Sharing & Access Inheritance.
The workspace config also carries the attachment policy root — the non-null
top of the Workspace → Program → Project inheritance chain (lower scopes leave
their override null to inherit these):
| Field | Type | Meaning |
|---|---|---|
attachments_enabled | boolean | Whether task file uploads are permitted by default (external links are unaffected). |
allowed_attachment_types | string[] | MIME allow-list (seeded from the system default). An empty list is a deliberate “no file types allowed” policy. |
attachments_override_policy | string | inherit / suggest / enforce (default suggest). enforce is an Enterprise lock and is a no-op in the community edition. |
These three fields are writable by a Workspace Admin+. Writing a permanently
security-denied MIME type (text/html, image/svg+xml,
application/xhtml+xml) into allowed_attachment_types returns 400 — these
can never be allowed.
Workspace members
Section titled “Workspace members”| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/v1/workspace/members/ | Admin+ (non-admin sees own row only) | List workspace members. |
| PATCH | /api/v1/workspace/members/{user_id}/ | Admin+ | Change a member’s role or status. |
| DELETE | /api/v1/workspace/members/{user_id}/ | Admin+ | Deactivate a member. |
Workspace invites
Section titled “Workspace invites”| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/v1/workspace/invites/ | Admin+ | List pending invites. |
| POST | /api/v1/workspace/invites/ | Admin+ | Create an invite (email queued asynchronously). |
| DELETE | /api/v1/workspace/invites/{id}/ | Admin+ | Revoke a pending invite. |
| POST | /api/v1/workspace/invites/accept/ | Public | Accept an invite with a one-time token. Rate-limited: 20 req/min. |
See Workspace Settings for invite token security and the group-access cascade.
Workspace groups
Section titled “Workspace groups”| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/v1/workspace/groups/ | Any member | List groups. |
| POST | /api/v1/workspace/groups/ | Admin+ | Create a group. |
| GET | /api/v1/workspace/groups/{id}/ | Any member | Retrieve a group. |
| PATCH | /api/v1/workspace/groups/{id}/ | Admin+ | Update name, description, or lead. |
| DELETE | /api/v1/workspace/groups/{id}/ | Admin+ | Delete group (removes group-conferred memberships). |
| POST | /api/v1/workspace/groups/{id}/members/ | Admin+ | Add a member (triggers project-access cascade). |
| DELETE | /api/v1/workspace/groups/{id}/members/{user_id}/ | Admin+ | Remove a member (triggers cascade). |
| POST | /api/v1/workspace/groups/{id}/projects/ | Admin+ | Link group to a project with a conferred role (triggers cascade). |
| DELETE | /api/v1/workspace/groups/{id}/projects/{project_id}/ | Admin+ | Unlink group from a project (removes group-conferred memberships). |
Webhooks
Section titled “Webhooks”Webhooks are scoped to a project or a program:
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/projects/{id}/webhooks/ | List (also /programs/{id}/webhooks/) |
| POST | /api/v1/projects/{id}/webhooks/ | Create |
| GET | /api/v1/projects/{id}/webhooks/{wid}/ | Retrieve |
| PUT / PATCH | /api/v1/projects/{id}/webhooks/{wid}/ | Update |
| DELETE | /api/v1/projects/{id}/webhooks/{wid}/ | Delete |
TruePPM emits 19 event types across tasks, dependencies, schedule, projects, sprints, risks, baselines, and comments. The full catalog — every event name, what triggers it, the payload shape, HMAC signature verification, request headers, delivery ordering and gap detection, and the retry schedule — is documented in Webhooks. Start there before subscribing:
- Event types — the 19-event catalog
- Payload shape
- Signature verification
- Delivery ordering and gap detection
- Delivery retries
The signing secret (used to HMAC-sign delivered payloads) is write-only and
follows a one-time-secret model:
- It is never returned on
GET, list, or update responses. - It is echoed back exactly once, in the
201 Createdresponse body, so the caller can record it. Refetching the webhook afterward never exposes it again — if the secret is lost it must be rotated by supplying a new one. - If omitted or left blank on create, a cryptographically strong secret is
auto-generated (
token_urlsafe(32), ~43 URL-safe characters) and returned in that one-time create response. - A supplied secret must be at least 32 characters. A whitespace-only value
is rejected; blank is treated as “auto-generate”. Validation failures return
400.
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/projects/{id}/sync/ | Pull delta changes |
| POST | /api/v1/projects/{id}/sync/ | Push a batch of offline changes |
See Offline Sync.
The push endpoint accepts a batch of created / updated / deleted task rows
with a client-generated client_batch_id for idempotent replay. Two boundary
rules:
- Idempotency replay is scoped per actor. A repeated batch with the same
client_batch_idfrom the same authenticated user replays the original stored response. A different user who reuses the sameclient_batch_idgets a fresh batch — never the original actor’s response — because the stored response carries that actor’s task ids, server versions, and sync watermark. Replay never crosses the project or the user boundary. - Cross-project id collisions return
409. Acreatedrow whose client-generatedidcollides with a task that lives in another project returns409 Conflictwith adetailmessage telling the client to regenerate the id and re-upload — the server will not silently mutate a task in a project the caller’s URL scope does not own. The exception is internally taggedsync_id_collision, but — verified against the current DRF exception path — that tag is not serialized onto the response body; branch on the409status, not on acodefield. See issue #2550.
Program membership sync
Section titled “Program membership sync”| Method | Path | Description |
|---|---|---|
| GET | /api/v1/sync/user/programs/ | Pull-only delta sync for Program and ProgramMembership rows — every program the caller belongs to, plus every co-member’s membership row. No path parameter (scope is derived entirely from the caller’s own live memberships, so there is no per-user IDOR surface). Complements projects/{id}/sync/, which cannot reach the user-scoped program layer |
Additional resource groups
Section titled “Additional resource groups”The sections above are a curated tour, not an exhaustive endpoint dump — see each linked feature page for the full picture. The groups below exist in the API today but previously had no row anywhere in the docs; each gets at least a pointer here.
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/projects/{project_id}/teams/ | List a project’s teams |
| GET | /api/v1/teams/{id}/ | Retrieve a team |
| GET | /api/v1/teams/{team_id}/members/ | List a team’s roster |
| PATCH | /api/v1/teams/{team_id}/members/{id}/ | Change a member’s role/facets |
This release ships the read + role/facet-patch slice only; team create/delete and the team activity feed are tracked for a later release (#599). See Multi-team lens for the UI this powers.
Skills and resource-skills
Section titled “Skills and resource-skills”/api/v1/skills/, /api/v1/resource-skills/, and
/api/v1/task-skill-requirements/ are documented alongside the rest of the
resource catalog in Resources — see that page for
the full CRUD surface and the skill-match warning codes.
Assets (unified file/link feed)
Section titled “Assets (unified file/link feed)”| Method | Path | Description |
|---|---|---|
| GET | /api/v1/assets/?mine=&program=&kind=&label=&provider=&q=&cursor=&page_size= | Workspace-wide feed across every project the caller can read |
| GET | /api/v1/projects/{id}/assets/?kind=&label=&provider=&q=&cursor=&page_size= | One project’s feed (Viewer+; readable even on an archived project) |
| GET | /api/v1/programs/{id}/assets/?kind=&label=&provider=&q=&cursor=&page_size= | A program’s feed, aggregated across its member projects |
Read-only, cursor-paginated aggregation of every task’s file attachments and
external links (ADR-0215/ADR-0428). The workspace tier never surfaces an asset
from a project the caller cannot already open — it grants no new reach, which
is what keeps it OSS rather than a portfolio-governance surface. ?mine=true
hard-scopes to the caller’s own assigned tasks; there is no ?user= escape
hatch. See Assets.
Public share links
Section titled “Public share links”| Method | Path | Description |
|---|---|---|
| GET / POST | /api/v1/projects/{id}/share-links/ | List / mint a public link (Admin+) |
| POST | /api/v1/projects/{id}/share-links/{link_id}/revoke/ | Revoke a link (Admin+, idempotent) |
| GET | /api/v1/share/board/{token}/ | Public, unauthenticated board snapshot |
| GET | /api/v1/share/schedule/{token}/ | Public, unauthenticated schedule/Gantt snapshot |
The two public endpoints return 410 for a revoked link and a uniform 404
for an unknown token or a disabled instance-wide sharing kill switch (so a
caller cannot distinguish “never existed” from “feature disabled”), and
support ETag/If-None-Match (304 on an unchanged snapshot). See
Board sharing.
Agent actions
Section titled “Agent actions”| Method | Path | Description |
|---|---|---|
| GET | /api/v1/agent-actions/ | List the append-only, hash-chained log of MCP/agent decisions |
| GET | /api/v1/agent-actions/{id}/ | Retrieve one action record |
Read-only team-visible audit trail (ADR-0112). See Agent oversight.
Recurrence rules
Section titled “Recurrence rules”CRUD via /api/v1/recurrence-rules/ (Resource Manager+ to write; any member
may read). Attaching a rule pulls its template task out of the CPM graph and
triggers a recompute; detaching puts it back. See
Recurring tasks for the UI and field reference.
Estimation poker
Section titled “Estimation poker”| Method | Path | Description |
|---|---|---|
| GET / POST | /api/v1/sprints/{sprint_id}/poker/ | List live rounds / open a new round (facilitator) |
| POST | /api/v1/poker/{id}/vote/ | Cast or change my vote |
| POST | /api/v1/poker/{id}/reveal/ | Reveal votes (facilitator) |
| POST | /api/v1/poker/{id}/reopen/ | Reopen for a re-vote (facilitator) |
| POST | /api/v1/poker/{id}/commit/ | Commit the agreed points — writes Task.story_points (facilitator) |
| POST | /api/v1/poker/{id}/cancel/ | Cancel the round (facilitator) |
See Estimation poker.
Retro board items
Section titled “Retro board items”| Method | Path | Description |
|---|---|---|
| PATCH / DELETE | /api/v1/retro-items/{id}/ | Edit or remove a retro board item |
| POST | /api/v1/retro-items/{id}/convert-to-action/ | Convert a retro item into an action item |
Complements the /api/v1/sprints/{id}/retro/ read/upsert endpoints and the
action-item promote/pull-to-sprint routes documented in
Retrospective — that page covers the retro as a
whole; these two routes edit an individual board item once it exists.
Sprint scope changes
Section titled “Sprint scope changes”| Method | Path | Description |
|---|---|---|
| GET | /api/v1/sprints/{id}/scope-changes/ | Audit list + delta of pending mid-sprint scope-injection requests |
| POST | /api/v1/sprints/{id}/scope-changes/accept/ / /api/v1/scope-changes/{id}/accept/ | Accept a pending scope-injection request |
| POST | /api/v1/sprints/{id}/scope-changes/reject/ / /api/v1/scope-changes/{id}/reject/ | Reject a pending scope-injection request |
The mid-sprint scope-injection approve-gate (ADR-0102 §5) referenced from Sprints — a task added to an active sprint after it started lands here pending Scrum Master / Product Owner approval rather than silently joining the commitment.
Sprint and task duration changes
Section titled “Sprint and task duration changes”| Method | Path | Description |
|---|---|---|
| GET | /api/v1/sprints/{id}/duration-events/ | Every duration change recorded against tasks in this sprint, newest first |
| GET | /api/v1/tasks/{id}/duration-events/ | Duration-change history for one task, newest first |
The audit behind the mid-sprint duration change (ADR-0151): when a task’s
duration is edited, the effective percent-complete policy decides whether %
is kept or prorated, and one event row records the before/after and the policy
applied. Any project member (Viewer and up) may read both.
The two endpoints return different shapes — do not assume they match. The
per-sprint aggregate exists so a sprint changes-log renders in one request
instead of one per task, so it denormalizes task_name / actor_name and
returns a single object:
{"events": [ {"id": "…", "task_id": "…", "task_name": "Design", "old_duration": 10, "new_duration": 20, "percent_complete_at_change": 50.0, "percent_complete_after": 25.0, "policy_applied": "prorate", "actor_name": "Sarah Chen", "created_at": "2026-04-03T09:12:00+00:00"}]}Note the timestamp offset form. This aggregate is assembled as plain values rather
than through a serializer, so created_at is a raw ISO-8601 string with a numeric
offset — the per-task endpoint below renders the same instant as …09:12:00Z.
Parse both with an ISO-8601 parser rather than matching on the suffix.
percent_complete_after is null unless the policy actually changed % (that
is, under prorate) — under keep and confirm it stays null, which is how a
client distinguishes “the policy moved the number” from “the policy left it
alone”. The events list is empty, never absent, for a sprint with no changes.
The per-task endpoint is paginated instead, returning
{count, next, previous, results} where each result carries the raw task,
actor, and sprint foreign keys plus a source enum.
Task relations
Section titled “Task relations”| Method | Path | Description |
|---|---|---|
| GET / POST | /api/v1/task-relations/ | List / create an informational task-to-task relation |
| GET / PUT / PATCH / DELETE | /api/v1/task-relations/{id}/ | Retrieve, update, or remove one relation |
A relation (relates_to / blocks / duplicates, ADR-0455) is a
cross-reference, not a scheduling dependency — it is
inert: no CPM effect, no lag, no cycle check, and no schedule recompute on
write. Endpoints may sit in the same project or in two projects of the same
program; a cross-program relation is rejected. Returns a bare array (not
the paginated envelope) since a task’s relations are inherently few. See the
WebSocket event taxonomy for
the corresponding task_relation_* events — and note that task_link_* is a
different, unrelated family (external Jira/GitHub/GitLab links via the
integrations surface below), not a naming variant of this one.
User search
Section titled “User search”| Method | Path | Description |
|---|---|---|
| GET | /api/v1/users/search/ | Typeahead over workspace users, for the member-invite / mention-group pickers |
Throttled at user_search (60/min per user — see
Rate limiting) to bound bulk scraping of the user directory.
Admin: failed tasks
Section titled “Admin: failed tasks”/api/v1/admin/failed-tasks/ (list, retrieve, requeue, drop, requeue_all,
drop_all) is the Celery dead-letter queue surface, fully documented in
System health.
Import templates
Section titled “Import templates”GET /api/v1/import-templates/csv/ serves the same downloadable CSV template
used by the in-app import wizard, for scripted use — see
CSV import.
Integrations
Section titled “Integrations”| Method | Path | Description |
|---|---|---|
| GET / POST / DELETE | /api/v1/me/credentials/{provider}/ | Connect, read, or revoke your own credential for an external provider (ADR-0049) |
| GET / PUT / DELETE | /api/v1/me/connections/{source}/ | Your personal, read-only external task-source connection (ADR-0097 §3 — the OSS carve-out: user-scoped and one-way) |
| POST | /api/v1/me/connections/{source}/sync/ | Trigger a manual pull of your connection; returns 202 {"queued": true} |
| GET | /api/v1/me/external-items/ | Your cached external work items, for the My Work external section |
| GET / PUT | /api/v1/integrations/projects/{project_id}/git-automation/ | A project’s Git-event board-automation config (Admin+, ADR-0158) |
| POST | /api/v1/integrations/projects/{project_id}/git-automation/rotate-secret/ | Rotate the webhook signing secret |
| POST | /api/v1/integrations/projects/{project_id}/git-webhook/ | Inbound Git-event receiver (unauthenticated by session; verified by the rotatable secret) |
The org-wide, admin-configured, bidirectional Integration Hub is Enterprise; everything in this table is the OSS carve-out — a personal, one-way credential or connection, or a single project’s own Git automation. See Webhooks for the outbound event side.
Acceptance criteria
Section titled “Acceptance criteria”| Method | Path | Description |
|---|---|---|
| GET / POST | /api/v1/acceptance-criteria/?task= | List a task’s acceptance criteria / add one (Member+) |
| GET / PATCH / DELETE | /api/v1/acceptance-criteria/{id}/ | Read, tick met/unmet, or remove one criterion |
Stamps met_by/met_at when met flips (ADR-0105 §2); surfaced on
drill-down only, never aggregated to a PMO rollup. See
Product backlog for the
Definition-of-Ready meter this powers, and the PAT section
above for the separate CI-facing POST /api/v1/projects/{id}/acceptance-results/
ingest endpoint that flips these same flags from a test run.
Velocity suggestions
Section titled “Velocity suggestions”/api/v1/velocity-suggestions/ (list, accept, dismiss) is fully documented in
Velocity calibration.
Readiness probe
Section titled “Readiness probe”| Method | Path | Description |
|---|---|---|
| GET | /api/v1/readyz | Dependency-aware readiness for Kubernetes readiness/startup probes. Unauthenticated by design (kubelet sends no credentials). Coarse {"status": "ok"|"fail", "checks": {...}, "migration_state": "in_sync"} body with no infrastructure detail; 200 when every checked dependency is healthy, 503 otherwise |
migration_state reports which way the pod’s migrations differ from the recorded
schema: behind (this image ships migrations the database has not applied — the
rolling-forward window), ahead (the database records migrations this image does
not ship), unknown (the check itself failed), or in_sync.
behind and unknown always make the pod not-ready. ahead gates the pod only
when it booted into that state — the rollback case. A pod that booted in sync
and drifted to ahead while serving is the ordinary rolling upgrade, and stays
ready so the Service is not emptied while the new pods come up; the state is
still reported either way. An operator can also re-open the gated case with
TRUEPPM_READYZ_ALLOW_DB_AHEAD for a deliberate additive-only rollback, which
changes readiness without hiding the state. checks keeps its existing keys and
its ok/fail values, so a scraper reading only status/checks is unaffected.
ahead is a schema-presence signal, not a data-compatibility verdict — see
rollback.
Distinct from the plain unauthenticated liveness check at /api/v1/health/
and the admin-only, deeper /health/system/ used by
System health — readyz is the one
Kubernetes should point a readiness probe at.
Pagination
Section titled “Pagination”Default page size: 50. Response envelope:
{"count": 123, "next": "...?page=3", "previous": "...?page=1", "results": [...]}Endpoints over unbounded, append-only logs are cursor-paginated instead — the
audit log, the workspace member
list, and a webhook’s delivery history. A cursor envelope has no count
(computing one would defeat the point of a cursor), so it is
{next, previous, results} only. Follow next until it is null; do not
compute page counts from these endpoints.
Not every list endpoint is paginated. Some return a bare array where the
result set is inherently small and bounded — a task’s
relations, for instance. The generated
OpenAPI schema is authoritative per endpoint: check the
declared 200 response shape rather than assuming an envelope.
Rate limiting
Section titled “Rate limiting”Every endpoint is rate limited. A general default applies to any endpoint that does not declare a stricter, endpoint-specific limit:
| Caller | Default limit | Bucketed by |
|---|---|---|
| Unauthenticated | 60 requests / minute | Client IP |
| Authenticated | 1000 requests / minute | Account |
Both defaults are operator-configurable (TRUEPPM_THROTTLE_ANON_RATE and
TRUEPPM_THROTTLE_USER_RATE; see
Configuration).
- Probe endpoints are exempt.
/api/v1/health/,/api/v1/readyz, and/api/v1/edition/are never rate limited, so Kubernetes liveness/readiness loops are not throttled. - Scoped endpoints replace the default. An endpoint with its own limit carries only that specific limit — scoped limits do not stack on top of the general default (two different scoped throttles on the same endpoint, e.g. task-sync’s per-project limit, do stack with each other).
Complete scoped-throttle list
Section titled “Complete scoped-throttle list”Two mechanisms implement a scoped limit. Most are a named entry in
DEFAULT_THROTTLE_RATES (env-tunable via the TRUEPPM_THROTTLE_* variable
named alongside each one below, where one exists); a handful of endpoints
that need bespoke logic (a sliding ramp-up window, two stacked buckets, a
Redis-atomic counter) are hand-rolled throttle classes instead. Both kinds
return 429 with the same Retry-After envelope shown above.
Scoped rates (DEFAULT_THROTTLE_RATES):
| Scope | Rate | Applies to |
|---|---|---|
anon | 60/min (TRUEPPM_THROTTLE_ANON_RATE) | General default, unauthenticated |
user | 1000/min (TRUEPPM_THROTTLE_USER_RATE) | General default, authenticated |
login | 10/min | Login, per client IP |
login_account | 5/min (TRUEPPM_THROTTLE_LOGIN_ACCOUNT_RATE) | Login, per submitted username — stacks with login |
password_reset | 5/min | Password-reset request + confirm |
refresh | 60/min | JWT refresh |
user_search | 60/min | Member-invite user typeahead |
omni_search | 60/min (TRUEPPM_THROTTLE_OMNI_SEARCH_RATE) | ⌘K Epic/Story omni-search |
ws_ticket | 120/min | WebSocket connection-ticket minting |
invite_resend | 5/min | Workspace invite resend |
email_settings | 12/min | Workspace SMTP config writes |
email_settings_probe | 6/min | SMTP send-test + deliverability probe |
oidc_discover | 30/min | SSO domain discovery |
oidc_login | 20/min | SSO login start |
oidc_callback | 30/min | SSO callback |
sso_test_connection | 20/min | SSO admin “Test connection” |
credential_rotate | 10/min | Personal integration credentials + Git webhook secret rotation |
external_sync | 20/min | Manual external-connection pull trigger |
monte_carlo | 10/min | Synchronous Monte Carlo run |
monte_carlo_whatif | 6/min | Monte Carlo what-if (two CPM + two MC passes per call) |
sample_load | 6/min (TRUEPPM_THROTTLE_SAMPLE_LOAD_RATE) | Bundled-sample demo loader |
seed_import | 6/min (TRUEPPM_THROTTLE_SEED_IMPORT_RATE) | Caller-supplied program seed import |
seed_validate | 20/min (TRUEPPM_THROTTLE_SEED_VALIDATE_RATE) | Seed import dry run |
sample_download | 60/min (TRUEPPM_THROTTLE_SAMPLE_DOWNLOAD_RATE) | Bundled-fixture file download |
mcp_read | 120/min (TRUEPPM_THROTTLE_MCP_READ_RATE) | Per-token baseline on any MCP-readable view |
mcp_read_compute | 12/min (TRUEPPM_THROTTLE_MCP_READ_COMPUTE_RATE) | Stacks on mcp_read for the four compute-heavy MCP tools |
share_mint | 20/min (TRUEPPM_THROTTLE_SHARE_MINT_RATE) | Minting a public board/schedule share link |
share_access | 60/min (TRUEPPM_THROTTLE_SHARE_ACCESS_RATE) | Resolving a public share link |
telemetry_test | 6/min | Telemetry test-export probe |
Hand-rolled throttle classes (custom windows/keys DRF’s scope rates can’t express):
| Class | Rate | Applies to |
|---|---|---|
TaskSyncThrottle | 100/min steady, 1000/min in the first 60 min after token mint | Inbound task-sync, per project |
AcceptanceResultThrottle | Same ramp as TaskSyncThrottle | CI acceptance-result ingest, per token |
TokenIssuanceThrottle | 5/min (TRUEPPM_TOKEN_ISSUANCE_PER_MINUTE) | Minting any API token, per user |
TaskAttachmentUploadThrottle | 60/min | Task-attachment upload, per user |
SyncUploadThrottle | 60/min per (project, user) and 120/min per user | Offline sync push — fails closed (429) on a Redis outage, the one throttle in this table that does |
GitWebhookThrottle | 120/min | Inbound Git webhook receiver, per project |
TaskLinkRefreshThrottle | 30/min | Manual task-link refresh, per user |
MentionRateThrottle | 100/hour and 1000/day | Comment @mention fan-out, per user (both windows apply) |
Every hand-rolled class fails open on a Redis error (never blocks
legitimate traffic during a cache outage) except SyncUploadThrottle, which
fails closed — a denied offline sync retries with backoff, while an
unbounded write-path throttle bypass during an outage was judged the worse
failure mode.
When a caller exceeds a limit the API responds with 429 Too Many Requests and
a Retry-After header giving the number of seconds to wait before retrying:
HTTP/1.1 429 Too Many RequestsRetry-After: 42Content-Type: application/json
{"detail": "Request was throttled. Expected available in 42 seconds."}Clients should honor Retry-After and back off; retrying before it elapses
consumes no additional quota but continues to return 429.
Status codes
Section titled “Status codes”| Code | Meaning |
|---|---|
| 200 | OK |
| 201 | Created |
| 202 | Accepted — the work was queued and runs asynchronously (e.g. MS Project / Jira / CSV import, workspace/program/project export, invite-email (re)queueing, a task-run cancellation request). The response carries a job/status resource to poll, or a bare {"queued": true}, not the final result |
| 204 | No content (delete) |
| 207 | Multi-status — the rows of a batch were applied independently, so the body reports applied, rejected, and skipped together rather than one verdict for the whole request (batch task writes). A 207 does not mean every row succeeded: always read rejected |
| 304 | Not modified — the caller’s If-None-Match matched the current ETag (public share-link resolution; bundled-sample file download); the body is empty, refetch is unnecessary |
| 400 | Validation error |
| 401 | Missing or invalid token |
| 403 | Insufficient role |
| 404 | Not found or soft-deleted |
| 409 | Conflict (e.g. duplicate membership, sync id collision) |
| 410 | Gone — the resource existed but is deliberately no longer reachable and never will be again: a revoked public share link, or a completed workspace/program/project export download link past its expires_at. Distinct from 404 (never existed, or the caller cannot see it) |
| 413 | Payload too large (the workspace branding-logo upload exceeds its 2 MB ceiling) |
| 415 | Unsupported media type (attachment upload outside the MIME allow-list) |
| 422 | Well-formed but unprocessable (idempotency-key reuse, program-schedule limits) |
| 429 | Rate limit exceeded — general default or a scoped throttle; includes a Retry-After header |
| 501 | Not implemented by this deployment’s configured backend |
| 502 | An upstream identity provider could not be reached |
| 503 | Service unavailable — a dependency-aware readiness/health check reports a failing dependency (/api/v1/readyz, /health/system/, /health/beat/), or an aggregation endpoint’s per-section subservice failed and degraded gracefully (e.g. the integrations-summary view, whose body then carries a failed key naming the section so the client falls back to that section’s own endpoint) |
Errors come in two shapes: field-keyed validation messages with no machine
code, and structured bodies carrying a stable code you can branch on. See
Errors and status codes for the full code table, the extra keys
each carries, and which of them the stability contract covers.