Project Endpoints
| Method | Endpoint | Description |
|---|
| GET | /projects | List projects (query: statusId, ownerId, search, page, limit) |
| GET | /projects/:id | Full project with phases, tasks, members |
| POST | /projects | Create project |
| POST | /projects/from-opportunity | Create from opportunity + template |
| PUT | /projects/:id | Update project |
| DELETE | /projects/:id | Delete project |
| GET | /projects/:id/kanban | Kanban view |
| GET | /projects/:id/gantt | Gantt timeline data |
Team & Members
| Method | Endpoint | Description |
|---|
| GET | /projects/:id/members | List members |
| POST | /projects/:id/members | Add member |
| DELETE | /projects/:id/members/:memberId | Remove member |
Tasks
| Method | Endpoint | Description |
|---|
| POST | /projects/:id/tasks | Create task |
| PUT | /projects/:id/tasks/:taskId | Update task |
| DELETE | /projects/:id/tasks/:taskId | Delete task |
Dependencies
| Method | Endpoint | Description |
|---|
| GET | /projects/:id/tasks/:taskId/dependencies | Get dependencies |
| POST | /projects/:id/tasks/:taskId/dependencies | Add dependency |
| DELETE | /projects/:id/tasks/:taskId/dependencies/:depId | Remove |
Time Tracking
| Method | Endpoint | Description |
|---|
| POST | /projects/:id/tasks/:taskId/time | Log time entry (requires project_time_entries.create) |
| GET | /projects/:id/tasks/:taskId/time-entries | List entries for a task (for edit/delete UI) |
| GET | /projects/:id/time-entries | List all entries for a project (used by the time report drill-down) |
| GET | /projects/:id/time-report | Time report grouped by user (with per-task breakdown) |
| GET | /projects/:id/time-report/by-task | Time report grouped by task |
| PUT | /projects/time-entries/:entryId | Edit a logged entry (requires project_time_entries.edit) |
| DELETE | /projects/time-entries/:entryId | Delete a logged entry (requires project_time_entries.delete) |
Active Timer (one per user)
Server-side stopwatch so a started timer survives page refresh / device switch.
A user can only have one active timer at a time.
| Method | Endpoint | Description |
|---|
| POST | /projects/:id/tasks/:taskId/timer/start | Start a timer on a task (errors if another timer is active) |
| POST | /projects/timer/stop | Stop the active timer and log the elapsed time (body: { minutes?, description?, isBillable? }; minutes overrides elapsed if supplied) |
| GET | /projects/timer/active | Return the current user's active timer or null |
Calendar Tasks
CRM tasks (the standalone /tasks module) can be linked to a project — and optionally to a specific project task — via project_id + project_task_id columns. Once linked, they sync to Google/Outlook calendar via the existing calendar-sync wiring.
| Method | Endpoint | Description |
|---|
| GET | /projects/:id/calendar?start=&end= | Unified calendar feed for a project: project tasks with due dates + CRM tasks linked to the project + CRM tasks assigned to any project member |
| GET | /projects/:id/tasks/:taskId/calendar-tasks | List CRM tasks linked to a specific project task (used by the project task detail panel) |
Each event has sourceType: 'project_task' \| 'linked_task' \| 'member_task' so the UI can color-code distinctly.
Client Portal
The portal is gated by email OTP. Token URLs alone are not enough to log in — the client must enter the email associated with the token and verify a 6-digit code sent to that email. Sessions last 24 hours and live in an httpOnly cookie (portal_<tenantSlug>).
Token Management (internal team, requires JWT)
| Method | Endpoint | Description |
|---|
| GET | /projects/:id/portal-tokens | List active portal tokens for a project |
| POST | /projects/:id/portal-token | Create a portal token (body: { email, label?, expiresAt? } — email required) |
| DELETE | /projects/portal-tokens/:tokenId | Revoke a portal token |
Client Messages (internal team side)
| Method | Endpoint | Description |
|---|
| GET | /projects/:id/client-messages | List the project-level message thread |
| POST | /projects/:id/client-messages | Send a message to the client (sender_type = 'team') |
| GET | /projects/:id/client-messages/unread-count | Count of client-sent messages not yet read by an internal user |
Public Portal Endpoints (no JWT — OTP-gated)
Public step (no auth, no session):
| Method | Endpoint | Description |
|---|
| GET | /portal/:tenantSlug/:token/info | Minimal info for the verify screen (project name, masked email hint) |
| POST | /portal/:tenantSlug/:token/request-otp | Send a 6-digit code to the authorized email. 10-min TTL, 5 attempts max, 30s cooldown |
| POST | /portal/:tenantSlug/:token/verify-otp | Verify code → sets portal_<tenantSlug> cookie + returns sessionToken |
| POST | /portal/:tenantSlug/:token/logout | Clear session |
Authenticated portal endpoints (require the cookie or Authorization: Bearer <sessionToken>):
| Method | Endpoint | Description |
|---|
| GET | /portal/:tenantSlug/:token/session | Return the resolved session context |
| GET | /portal/:tenantSlug/:token/project | Project overview (only client-visible items counted) |
| GET | /portal/:tenantSlug/:token/tasks | Tasks marked client_visible = true |
| GET | /portal/:tenantSlug/:token/milestones | Milestones marked client_visible = true |
| POST | /portal/:tenantSlug/:token/milestones/:id/accept | Accept a milestone (records client_accepted_at + email) |
| GET | /portal/:tenantSlug/:token/tasks/:taskId/comments | Comments on a client-visible task |
| POST | /portal/:tenantSlug/:token/tasks/:taskId/comments | Post a comment as the client |
| GET | /portal/:tenantSlug/:token/messages | Project-level message thread |
| POST | /portal/:tenantSlug/:token/messages | Send a message to the team |
Client Visibility
By default everything is internal. Toggle client_visible = true on individual project tasks and milestones to surface them on the portal. Task comments authored from the portal carry is_from_client = true + the client's email.
Approvals
| Method | Endpoint | Description |
|---|
| POST | /projects/:id/request-approval | Trigger the shared Approval Engine for (projects, project_start) |
| GET | /approvals/rules/check?entityType=projects&triggerEvent=project_start | Non-admin check — returns { configured: boolean } so detail pages can hide the Request Approval button when no rule is configured |
Admin
| Method | Endpoint | Description |
|---|
| POST/PATCH/DELETE | /projects/admin/project-statuses/:id | Manage statuses |
| POST/PATCH/DELETE | /projects/admin/task-statuses/:id | Manage task statuses |
| POST/PATCH/DELETE | /projects/admin/templates/:id | Manage templates |
| PUT | /projects/admin/templates/:id/structure | Update template phases + tasks |
Health Statuses
on_track, at_risk, off_track