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
ws/v1/projects/{project_id}/workshop/WorkshopConsumerLive workshop session (cursors + edits)

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

Program-scoped channels are planned. A ws/v1/programs/{program_id}/ endpoint will land in a future release (#836) to push program-scoped events in real time. It did not ship in 0.3 — program-scoped events will be delivered over WebSocket in a later release. Subscribe to the per-project channels in the meantime.

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 (Member+ to subscribe)
4004(workshop endpoint only) no active WorkshopSession for the project

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, tasks_reordered, tasks_restructured, tasks_bulk_mutated
  • Dependencies: dependency_created, dependency_updated, dependency_deleted, dependency_accepted, dependency_rejected
  • Scheduling: cpm_complete, cpm_error, task_run_started, task_run_progress, task_run_completed, task_run_failed, task_run_cancelled
  • Baselines: baseline_created, baseline_activated, baseline_deleted
  • Risks: risk_created, risk_updated, risk_deleted, risks_imported
  • Sprints: sprint_created, sprint_updated, sprint_deleted, sprint_activated, sprint_cancelled, sprint_closed, sprint_reranked, sprint_retro_updated, milestone_rollup_updated, poker_session_updated
  • 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_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
  • Membership / project: member_added, member_role_changed, member_removed, project_updated, project_archived, project_unarchived, project_transferred, project_deleted, project_hard_deleted
  • 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)
  • Cross-project (ADR-0120): slip_conflict_acknowledged, slip_conflicts_updated
  • 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.

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
dependency_createddependency.created
dependency_deleteddependency.deleted
dependency_updatedWS-only
dependency_acceptedWS-only
dependency_rejectedWS-only
project_createdproject.created
project_updatedWS-only
project_archivedWS-only
project_unarchivedWS-only
project_deletedWS-only
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
program_reopenedWS-only
program_deletedWS-only
program_splitWS-only
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

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).

The OSS webhook event set is capped at 14 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, and sprint.scope_changed. The agile trio (sprint.*) was added in ADR-0147, raising the cap from 11. Adding a 15th event requires its own ADR — the cap is the gate against per-customer event proliferation, which is the Enterprise upsell line.

ws/v1/projects/{project_id}/workshop/ requires an active WorkshopSession (otherwise 4004). Messages are relayed to all other participants and are not echoed back to the sender. The consumer validates the message type against its ALLOWED_EVENT_TYPES allow-list; the seven accepted client message types are:

  • cursor
  • cursor_move
  • phase_rename
  • phase_add
  • phase_move
  • task_add
  • task_move

cursor and cursor_move are both accepted. cursor is the legacy name and cursor_move is the current name; the consumer accepts either for backward compatibility.

The server also broadcasts participant_joined / participant_left as participants connect and disconnect.