Work Requests API
Cross-team work requests raised on CRM records. RBAC module work_requests; every lifecycle action needs work_requests.edit at the guard, and the service then checks the caller's relationship to the request (requester, team member, assignee, or manager). Requests the caller may not view return 404. See Work Requests Module.
Endpoints
| Method | Endpoint | Permission | Description |
|---|---|---|---|
| GET | /work-requests | view | List requests (paginated) |
| GET | /work-requests/counts | view | Badge counts for the current user |
| GET | /work-requests/by-entity/:entityType/:entityId | view | Requests raised on, or linked to, a record |
| POST | /work-requests | create | Raise a request |
| GET | /work-requests/:id | view | Request with time totals and allowed actions |
| PUT | /work-requests/:id | edit | Update request details |
| DELETE | /work-requests/:id | delete | Soft-delete (manager, or the requester while still unassigned and requested) |
| GET | /work-requests/:id/stage-history | view | Stage moves with time in stage |
| GET | /work-requests/:id/activities?page=&limit= | view | Activity timeline (limit ≤ 200, default 50) |
| GET | /work-requests/:id/documents | view | Files on the request |
| DELETE | /work-requests/:id/documents/:documentId | edit | Remove a file (uploader, assignee, requester or manager) |
Upload files with the existing POST /upload/document/work_requests/:id.
Lifecycle
| Method | Endpoint | Body | Allowed when / for |
|---|---|---|---|
| POST | /work-requests/:id/accept | — | requested and unassigned; owning-team member or manager |
| POST | /work-requests/:id/assign | { userId, note? } | Open; manager; target user active |
| POST | /work-requests/:id/release | { reason? } | Current assignee, accepted / in_progress → back to the team queue |
| POST | /work-requests/:id/decline | { reason } (required) | requested; owning-team member or manager |
| POST | /work-requests/:id/start | — | accepted; assignee or manager → in_progress |
| POST | /work-requests/:id/complete | { note? } | accepted / in_progress; assignee or manager |
| POST | /work-requests/:id/cancel | { reason? } | Open; requester or manager |
| POST | /work-requests/:id/reopen | { note? } | declined / completed / cancelled; requester or manager |
| POST | /work-requests/:id/change-stage | see below | accepted / in_progress; assignee or manager |
Managers are admins, users with work_requests record access all, the owning team's lead and the owning department's head. Every lifecycle endpoint returns the updated request (same shape as GET /work-requests/:id).
change-stage body
{
"stageId": "uuid",
"note": "Demo scheduled for Thursday",
"assignmentMode": "user",
"assignToUserId": "uuid",
"assignToTeamId": null
}
| Field | Description |
|---|---|
stageId | Required. Active stage of the request's pipeline. Sequential pipelines allow only adjacent stages (cancel stages exempt). |
assignmentMode | Optional: stage_rule (default) · record_owner (current assignee) · user · team_lead. A manual pick that can't be honoured falls back to the stage rule. |
assignToUserId / assignToTeamId | For user / team_lead |
Entering an is_won stage completes the request; an is_lost stage cancels it; an open stage moves an accepted request to in_progress and may hand it to the stage owner.
Query Parameters (GET /work-requests)
| Param | Description |
|---|---|
view | all (default) · assigned_to_me · requested_by_me · team_queue (unassigned requested requests for your teams) · my_teams |
status | Comma-separated statuses; also accepts open (requested, accepted, in_progress) and closed (declined, completed, cancelled) |
typeId, teamId, pipelineId, stageId | Filters |
assignedTo | User id, or unassigned |
requestedBy | User id |
entityType, entityId | Primary record |
priority | low · medium · high · urgent |
overdue | true — past due and still open |
search | ≥ 2 chars; matches title, request number, description |
sortBy | createdAt (default) · updatedAt · dueDate · priority · requestNumber · title · status · stage |
sortOrder | ASC · DESC (default) |
page, limit | Default 1 / 25; limit ≤ 200 |
Results are always filtered to requests the caller can view.
Response: { data: WorkRequest[], meta: { total, page, limit, totalPages } }.
Create Body
{
"typeId": "uuid",
"title": "Product demo for Acme",
"description": "Focus on reporting and integrations",
"entityType": "opportunities",
"entityId": "uuid",
"priority": "high",
"dueDate": "2026-10-12T00:00:00Z",
"estimatedMinutes": 120,
"requestData": { "products_to_demo": ["Analytics"], "preferred_demo_date": "2026-10-10" },
"associations": [{ "type": "contacts", "id": "uuid", "label": "Attendee" }],
"assignedTo": "uuid",
"tags": ["enterprise"]
}
| Field | Notes |
|---|---|
typeId, title | Required. Type must be active and have a pipeline with an open stage and an owning team or department. |
entityType + entityId | Optional, together. Type must allow the record type; caller must be able to view the record. |
requestData | Answers keyed by the type's request field keys; required fields are enforced (400 with missingFields[]). |
dueDate | Defaults to now + the type's default_due_days |
isBillable, hourlyRate, currency | Default from the type |
pipelineId | Defaults to the type's pipeline |
teamId | Used only when the type has no owning team |
associations[] | Extra records to link; failures are skipped |
assignedTo | Honoured only when the caller can manage the request; otherwise the type's assignment mode applies |
customFields | JSONB |
Update Body (PUT /work-requests/:id)
Any of title, description, priority, dueDate, estimatedMinutes, requestData (re-validated), customFields (merged), tags, and — assignee or manager only — isBillable, hourlyRate, currency. Closed requests must be reopened first.
Work Request Object
{
"id": "uuid",
"requestNumber": "WR-000042",
"title": "Product demo for Acme",
"description": "…",
"type": { "id": "uuid", "name": "Technical Demo", "color": "#7C3AED", "icon": null },
"pipeline": { "id": "uuid", "name": "Presales Demo" },
"stage": { "id": "uuid", "name": "Demo Prep", "color": "#3B82F6", "sortOrder": 2, "isWon": false, "isLost": false },
"stageEnteredAt": "2026-10-05T09:00:00Z",
"entity": { "type": "opportunities", "id": "uuid", "noun": "opportunity", "url": "/opportunities/uuid", "label": "Acme renewal" },
"team": { "id": "uuid", "name": "Presales" },
"department": { "id": "uuid", "name": "Solutions" },
"requestedBy": { "id": "uuid", "firstName": "Sam", "lastName": "Rep", "avatarUrl": null },
"assignedTo": { "id": "uuid", "firstName": "Pat", "lastName": "Engineer", "avatarUrl": null },
"assignedAt": "…",
"status": "in_progress",
"priority": "high",
"dueDate": "…",
"isOverdue": false,
"acceptedAt": "…", "startedAt": "…", "completedAt": null, "cancelledAt": null,
"declinedAt": null, "declinedBy": null, "declineReason": null,
"estimatedMinutes": 120,
"loggedMinutes": 45,
"isBillable": false, "hourlyRate": null, "currency": null,
"project": null,
"requestData": {}, "customFields": {}, "tags": [],
"createdBy": "uuid", "createdAt": "…", "updatedAt": "…"
}
GET /work-requests/:id (and every lifecycle response) adds:
{
"requestFields": [{ "key": "preferred_demo_date", "label": "Preferred demo date", "type": "date", "required": true }],
"time": { "totalMinutes": 45, "billableMinutes": 0, "billableAmount": 0 },
"can": {
"edit": true, "manage": false, "accept": false, "assign": false, "release": true,
"decline": false, "work": true, "cancel": false, "reopen": false, "logTime": true
}
}
Counts
GET /work-requests/counts →
{ "assignedToMe": 3, "requestedByMe": 5, "teamQueue": 2, "overdue": 1 }
assignedToMe and overdue count your accepted / in_progress requests; requestedByMe counts your open requests; teamQueue counts unassigned requested requests for teams you belong to. The sidebar badge shows assignedToMe + teamQueue.
Stage History Entry
{
"id": "uuid",
"fromStage": { "id": "uuid", "name": "New", "color": "#6366F1" },
"toStage": { "id": "uuid", "name": "Demo Prep", "color": "#3B82F6" },
"changedBy": { "id": "uuid", "firstName": "Pat", "lastName": "Engineer" },
"timeInStageSeconds": 86400,
"note": null,
"createdAt": "…"
}
The first entry (placement on the first stage at creation) has fromStage: null.