Skip to content

WebSocket API

TruePPM pushes real-time collaboration events over WebSocket. OpenAPI 3.0 cannot describe WebSocket channels, so this page is the reference for the WS surface that docs/api/openapi.json does not cover.

There are two endpoints, both scoped to a single project by its UUID.

EndpointConsumerPurpose
ws/v1/projects/{project_id}/ProjectConsumerBoard/schedule events + presence

{project_id} is the project’s UUID. Use wss:// against a TLS deployment and ws:// only for local development.

WebSocket handshakes cannot carry an Authorization header, so the credential must travel in the URL. To keep the long-lived JWT out of access logs, load balancer logs, and browser history, the handshake uses a short-lived, single-use ticket (RFC 6750 §2.3) rather than the access token itself.

First mint a ticket with an authenticated REST call:

POST /api/v1/ws/ticket/
Authorization: Bearer <access_token>
→ 200 { "ticket": "<opaque>", "expires_in": 30 }

Then open the socket with the ticket as the ticket query parameter:

wss://trueppm.example.com/ws/v1/projects/3f9a…/?ticket=<ticket>

The ticket is valid for 30 seconds and is consumed on first use — request a fresh one before every connection, including each reconnect. It carries authentication only; the server still enforces project membership and role before accepting.

If the server rejects the connection it closes with one of these application close codes (rather than accepting and then dropping):

CodeMeaning
4001Missing, invalid, expired, or already-consumed ticket (or, on the deprecated path, an invalid token)
4003Authenticated but lacks the required role on the project (Team Member+ to subscribe)

A client that receives 4001 should mint a fresh ticket (refreshing the access token first if needed) and reconnect; a persistent 4001 means the session has expired and the user must re-authenticate. Retryable transport drops (network loss, server restart) use the standard 1006/1001 codes and should be reconnected with backoff.

Every board/schedule event on ws/v1/projects/{project_id}/ arrives as a JSON envelope:

{ "protocol_version": 1, "event_type": "<name>", "payload": { ... } }

event_type is a snake_case name. Clients dispatch on it and typically invalidate the corresponding cache (the web client maps these to TanStack Query keys). protocol_version is a bare integer identifying the envelope wire version (currently 1); it is reserved so a future backward-incompatible envelope change can be negotiated without breaking clients that ignore it today. The set is open-ended and grows as features land; current event types include:

  • Tasks: task_created, task_updated, task_deleted, task_duration_changed, task_dates_updated, task_restored, tasks_reordered, tasks_restructured, tasks_bulk_mutated
  • Dependencies: dependency_created, dependency_updated, dependency_deleted, dependency_accepted, dependency_rejected, dependencies_bulk_created (one aggregated event per POST /tasks/bulk/ batch, carrying every applied edge’s id as dependency_ids — not one dependency_created per edge, #3770)
  • Task relations: task_relation_created, task_relation_updated, task_relation_deleted (informational relates-to / blocks / duplicates links; payload carries the relation id). A cross-project relation fans to both endpoint projects. Inert — no schedule recompute follows.
  • Task links — a separate family from Task relations above, despite the similar name: task_link_created, task_link_updated, task_link_deleted cover an external Git/Jira/GitHub/GitLab reference attached to a task (payload carries the link id and task_id), not an internal task-to-task cross-reference. If you are grepping for “link” events looking for the relates-to/blocks/duplicates feature, that is Task relations above, not this family.
  • Scheduling: cpm_complete, cpm_error, task_run_started, task_run_progress, task_run_completed, task_run_failed, task_run_cancelled. cpm_complete’s payload is {"project_finish": <ISO date or null>, "critical_path": [<task id>, ...], "status_date": <ISO date or null>} — status_date is the CPM data date this run resolved (Project.status_date if set, otherwise today; ADR-0752 §4), echoed so a consumer knows which “today” produced the dates. On the program-scoped pass (ADR-0120 D3, escalated when the project’s program holds an accepted cross-project edge) status_date is always null: member projects can carry different status_date values and the merged run floors none of them, so no single value would honestly describe what was computed. Both dispatch paths build this payload through one shared helper so the key set cannot drift between them (#3776).
  • Baselines: baseline_created, baseline_activated, baseline_deleted
  • Risks: risk_created, risk_updated, risk_deleted, risks_imported
  • Labels: label_created, label_updated, label_deleted (catalog changes; payload carries the label id). Assigning or removing a label from a task emits task_updated with changed_fields: ["labels"], not a distinct event.
  • Sprints: sprint_created, sprint_updated, sprint_deleted, sprint_activated, sprint_cancelled, sprint_closed, sprint_close_failed, sprint_reranked, sprint_retro_updated, milestone_rollup_updated, milestone_forecast_updated, poker_session_updated, demo_toggled. sprint_close_failed is the negative counterpart of sprint_closed: closing a sprint is asynchronous (the endpoint returns 202 with a request_id), and this event fires when that request is abandoned and the sprint will stay open. It is emitted only for a terminal outcome — a failure the server still intends to retry emits nothing, so receiving it always means the close is dead. Payload: {"id": <sprint_id>, "request_id": <uuid>, "failure_reason": <cancelled|not_closable|stalled|error>, "attempt_count": <int>, "terminal": true}. It deliberately carries no error text — that field is role-gated and is read from GET /api/v1/sprints/{id}/close-request/, which returns the detail the calling identity is entitled to.
  • Retro board: retro_item_created, retro_item_updated, retro_item_moved, retro_item_deleted
  • Comments / attachments: task_comment_created, task_comment_updated, task_comment_deleted, task_comment_ack_changed, task_comment_reaction_added, task_comment_reaction_removed, task_attachment_created, task_attachment_deleted, comment_created
  • Notes log: task_note_created, task_note_updated, task_note_deleted, task_note_pinned, task_note_decision_toggled
  • Roster / assignments: roster_changed, assignment_created, assignment_updated, assignment_deleted
  • Board config: board_config_updated, board_view_created, board_view_updated, board_view_deleted, project_custom_fields_updated
  • Guardrails (ADR-0101): guardrail_policy_updated — a sprint-composition guardrail level changed, or an external policy was acknowledged by the team. Payload is {"id": <policy id>} only — never the levels map — so a client re-reads GET /api/v1/projects/{id}/guardrail-policy/, which re-applies the sovereignty gate for the reading identity
  • Membership / project: member_added, member_role_changed, member_removed, mention_group_changed, project_updated, project_archived, project_unarchived, project_restored, project_transferred, project_deleted, project_hard_deleted
  • Programs — ⚠️ not deliverable until the program channel ships in 0.8 (#836); emitted by the server but no client can subscribe, see the caution above: program_closed, program_reopened, program_deleted, program_split, program_sponsorship_transferred
  • Task suggestions: suggestion_created, suggestion_declined, suggestion_revoked (decline/revoke carry only the suggestion + task id — never the actor — a silent state reconciliation, not a callout)
  • API tokens: api_token_minted, api_token_revoked (project/program-scoped tokens only — payload carries token_prefix + name, never the raw token or hash; personal access tokens broadcast nothing, see API reference)
  • Cross-project (ADR-0120): slip_conflict_acknowledged, slip_conflicts_updated
  • Velocity suggestions: velocity_suggestion_accepted, velocity_suggestion_dismissed — the suggestion settling. An accept also emits a normal task_updated carrying changed_fields: ["most_likely_duration"], because the task’s own field changed and that field is a Monte Carlo input the CPM delta does not carry
  • Signal privacy (ADR-0104): signal_privacy_changed (an audience or ceiling moved), signal_ceiling_proposal_changed (a raise proposal opened, ratified, was rejected, expired, was superseded, or withdrawn), signal_ceiling_vote_cast (a vote that left the proposal open — the running tally). Payloads are ID-only: no vote values and no voter identities cross the wire, so a client re-reads through REST, which re-applies the privacy gate
  • Working-time calendar: project_calendar_changed — the project’s effective calendar changed. Fired on the affected projects for a Calendar edit, a calendar-exception edit, or a program/workspace calendar reassignment; Calendar itself is a shared org-level resource with no channel of its own. The payload carries actor, the display name of whoever made the edit (empty for a system or unauthenticated write) — a calendar edit can move finish dates on a project whose own members did nothing, so the event names who did it
  • Presence: presence_join, presence_leave

Event-name convention. WebSocket event_type values are snake_case across the board — including presence, which previously used a dot-namespaced presence.join / presence.leave (aligned to snake_case in 0.2, #828).

Webhook event names are deliberately dot-namespaced (task.created, task.updated, …) — a different transport with a different audience (external integrations expect dotted topic-style names). So the same domain event is task_created over the WebSocket and task.created in a webhook payload. This is an intentional per-transport distinction, not drift.

The naming pattern new events must follow (<resource>_<past-tense-verb>), the frozen-contract guarantee, and the steps to register a new event are documented in WebSocket event conventions.

Treat broadcast delivery as best-effort: events may be missed during a reconnect, so a client should refetch the affected resource on reconnect rather than rely on having seen every event. Event payloads are intentionally minimal (usually { "id": "<uuid>" } or a small id set) — fetch the resource for the full state.

An event’s payload may be richer at some emitting sites than others, but all sites emitting a given event_type share at least one key, so one handler can always read every emission of an event it subscribes to. For the task events (task_created, task_updated) that key is always id. This is enforced by a conformance test over every broadcast call site, not by convention.

The task_updated event carries a richer field-level delta (ADR-0152):

{
"id": "<task uuid>",
"changed_fields": ["status", "assignee"],
"version": 42,
"actor_id": "<user uuid or null>",
"ts": "2026-06-20T16:00:00Z"
}

changed_fields lists the names of the fields that changed — never their values, because task fields are role-gated (e.g. story_points is nulled below the velocity audience); a client that needs the new values re-reads the task through the serializer, which re-applies per-user gating. version is the post-commit server_version (clients ignore an event whose version they have already applied), and actor_id lets the originating client suppress its own echo rather than re-fetching over its optimistic update. The id key is retained for backward compatibility.

The same domain event uses two different naming conventions depending on the transport: WebSocket event_type values are snake_case (task_created), while webhook events are dot-namespaced noun.verb (task.created). This is an intentional per-transport distinction, not drift — see the convention note above. The two event sets also do not fully overlap: some WS events have no webhook counterpart (and vice-versa).

These are emitted via broadcast_board_event(). The table below highlights the events with a webhook equivalent plus a representative selection of the WS-only events. It is illustrative, not exhaustive — the complete, authoritative set of WebSocket event types is frozen in the API test suite (packages/api/tests/apps/sync/test_broadcast.py, FROZEN_WS_EVENT_TYPES), which fails CI if a new broadcast_board_event() call introduces an event type without adding it to that frozen set. Events with no webhook counterpart are marked WS-only.

WebSocket event (snake_case)Webhook event (noun.verb)
task_createdtask.created
task_updatedtask.updated
task_deletedtask.deleted
task_duration_changedWS-only
task_dates_updatedWS-only
task_restoredWS-only
dependency_createddependency.created
dependency_deleteddependency.deleted
dependency_updatedWS-only
dependency_acceptedWS-only
dependency_rejectedWS-only
dependencies_bulk_createdWS-only
task_relation_createdWS-only
task_relation_updatedWS-only
task_relation_deletedWS-only
task_link_createdWS-only — distinct family from task_relation_* above, see the note above
task_link_updatedWS-only
task_link_deletedWS-only
task_comment_ack_changedWS-only
task_comment_reaction_addedWS-only
task_comment_reaction_removedWS-only
project_createdproject.created
project_updatedWS-only
project_archivedWS-only
project_unarchivedWS-only
project_restoredWS-only
project_deletedWS-only
project_custom_fields_updatedWS-only
guardrail_policy_updatedWS-only
api_token_mintedWS-only
api_token_revokedWS-only
demo_toggledWS-only
milestone_forecast_updatedWS-only
sprint_close_failedWS-only
program_sponsorship_transferredWS-only — ⚠️ not deliverable until 0.8 (#836)
backlog_rerankedWS-only
sprint_rerankedWS-only
baseline_activatedWS-only
baseline_deletedWS-only
board_view_createdWS-only
board_view_updatedWS-only
board_view_deletedWS-only
milestone_rollup_updatedWS-only
phases_reorderedWS-only
queue_reorderedWS-only
program_closedWS-only — ⚠️ not deliverable until 0.8 (#836)
program_reopenedWS-only — ⚠️ not deliverable until 0.8 (#836)
program_deletedWS-only — ⚠️ not deliverable until 0.8 (#836)
program_splitWS-only — ⚠️ not deliverable until 0.8 (#836)
risk_createdWS-only
risk_updatedWS-only
risk_deletedWS-only
risks_importedWS-only
sprint_createdWS-only
sprint_updatedWS-only
sprint_deletedWS-only
sprint_scope_changedWS-only
sprint_retro_updatedWS-only
retro_item_createdWS-only
retro_item_updatedWS-only
retro_item_movedWS-only
retro_item_deletedWS-only
demo_reorderedWS-only
demo_presenter_setWS-only
review_note_setWS-only
flagged_for_backlogWS-only
tasks_bulk_mutatedWS-only
tasks_reorderedWS-only
tasks_restructuredWS-only
team_member_changedWS-only
suggestion_createdWS-only
suggestion_declinedWS-only
suggestion_revokedWS-only
slip_conflict_acknowledgedWS-only
slip_conflicts_updatedWS-only
velocity_suggestion_acceptedWS-only
velocity_suggestion_dismissedWS-only
signal_privacy_changedWS-only
signal_ceiling_proposal_changedWS-only
signal_ceiling_vote_castWS-only
project_calendar_changedWS-only

Presence and scheduling-progress events are broadcast over channels other than the board channel and have no webhook counterpart:

WebSocket eventChannel / purpose
presence_joinPresence (a user connected) — WS-only
presence_leavePresence (a user disconnected) — WS-only
cpm_completeScheduling progress (CPM run finished) — WS-only¹
cpm_errorScheduling progress (CPM run failed) — WS-only¹

¹ The cpm_* scheduling-progress events relate approximately to the webhook schedule.recalculated event — both signal that a schedule recalculation occurred — but they are not a one-to-one mapping (the webhook fires once per recalculation; the WS events stream the lifecycle of a run). Treat the correspondence as loose.

These webhook events have no WebSocket broadcast — they are delivered only to configured webhook endpoints:

Webhook eventNotes
schedule.recalculatedLoosely relates to the cpm_* WS events (see above).
task.assignedNo WS broadcast.
task.assignee_changedNo WS broadcast.
task.mentionedNo WS broadcast.
task.due_date_changedNo WS broadcast.
sprint.activatedNo WS broadcast. First-party agile domain event (ADR-0147).
sprint.closedNo WS broadcast. Completion snapshot (velocity) is privacy-gated in its payload per ADR-0104.
sprint.scope_changedNo WS broadcast. Fires on post-activation scope injection (ADR-0102).
risk.openedNo WS broadcast under this name (a generic risk_created WS event exists). First-party domain event (ADR-0206).
risk.escalatedNo WS broadcast under this name. Fires when computed severity (probability × impact) increases (ADR-0206).
risk.closedNo WS broadcast under this name. Fires on the transition into CLOSED (ADR-0206).
baseline.capturedNo WS broadcast under this name (a generic baseline_created WS event exists). First-party domain event (ADR-0206).
comment.createdNo WS broadcast under this name (a generic task_comment_created WS event exists). Carries no comment body (ADR-0206).

The OSS webhook event set is capped at 19 events: task.created, task.updated, task.deleted, dependency.created, dependency.deleted, schedule.recalculated, project.created, task.assigned, task.assignee_changed, task.mentioned, task.due_date_changed, sprint.activated, sprint.closed, sprint.scope_changed, risk.opened, risk.escalated, risk.closed, baseline.captured, and comment.created. The agile trio (sprint.*) was added in ADR-0147, raising the cap from 11 to 14; the risk/baseline/comment domain events were added in ADR-0206, raising it from 14 to 19. Adding a 20th event requires its own ADR — the cap is the gate against per-customer event proliferation.