171 KiB
Optima API Routes Documentation
This document provides a comprehensive overview of all API routes available in the Optima API.
Base URL
http://localhost:3000/v1
WebSocket Base URL
ws://localhost:3000/socket.io/
WebSocket Events
Secure Namespace
Namespace: /secure
Authentication Required: Yes
Provide authorization during Socket.IO handshake using one of:
handshake.headers.authorizationasBearer <accessToken>orKey <accessToken>handshake.auth.authorizationasBearer <accessToken>orKey <accessToken>handshake.auth.tokenas raw access token
When connected, server emits secure:connected with { userId }.
If the linked session expires, is invalidated, or no longer exists, server emits secure:session:expired and disconnects.
Register Live Quote Preview Channel
Client → Server Event: opp:live_quote_preview
Registers a per-opportunity live preview channel.
Required Permissions: sales.opportunity.fetch
Payload:
{
"id": "<opportunity-id>"
}
Ack Response (success):
{
"ok": true,
"event": "opp:live_quote_preview:<id>:data"
}
Ack Response (error):
{
"ok": false,
"error": "Missing opportunity id"
}
Server may also emit opp:live_quote_preview:ready and opp:live_quote_preview:error.
Live Quote Preview Data Channel
Client → Server Event: opp:live_quote_preview:<id>:data
After registration, clients send live quote preview options on this dynamic event.
The server will:
- Relay the incoming
...:datapayload to other sockets in the same preview room. - Fetch the target opportunity.
- Generate a preview PDF using
generateQuotewithshowPreview: true. - Emit the generated preview on
opp:live_quote_preview:<id>:preview.
Payload (all optional):
{
"lineItemPricing": true,
"includeQuoteNarrative": true,
"includeItemNarratives": true,
"logoPath": "/absolute/or/runtime/path/to/logo.png"
}
Server → Client Event: opp:live_quote_preview:<id>:preview
{
"id": "<opportunity-id>",
"mimeType": "application/pdf",
"contentBase64": "JVBERi0xLjQKJ..."
}
Server → Client Event (error): opp:live_quote_preview:error
{
"id": "<opportunity-id>",
"message": "Failed to generate live quote preview"
}
Object Type Field-Level Gating
All fetch and fetchAll endpoints gate response object keys via processObjectValuePerms. Each key on the returned object is checked against <scope>.<field> — only fields the user has permission for are included in the response. Grant <scope>.* to see all fields.
| Object Type | Scope | Affected Routes |
|---|---|---|
| Company | obj.company |
GET /company/companies, GET /company/companies/:identifier |
| Credential | obj.credential |
GET /credential/credentials/:id, GET /credential/credentials/company/:companyId, GET /credential/credentials/:id/sub-credentials, GET /credential-type/:id/credentials |
| Credential Type | obj.credentialType |
GET /credential-type/:identifier, GET /credential-type/ |
| User | obj.user |
GET /user/@me, GET /user/users/:identifier, GET /user/users, GET /role/:identifier/users |
| Role | obj.role |
GET /role/:identifier, GET /role/, GET /user/users/:identifier/roles |
| Catalog Item | obj.catalogItem |
GET /procurement/items, GET /procurement/items/:identifier, GET /procurement/items/:identifier/linked |
| Opportunity | obj.opportunity |
GET /sales/opportunities, GET /sales/opportunities/:identifier |
| UniFi Site | obj.unifiSite |
GET /unifi/sites, GET /unifi/site/:id, GET /company/companies/:identifier/unifi/sites |
| WiFi Network | unifi.site.wifi.read |
GET /unifi/site/:id/wifi |
See PERMISSIONS.md for the full list of field-level permission nodes within each scope.
Authentication Routes
ConnectWise Routes
Fetch CW Members
GET /cw/members
Returns all ConnectWise members from the server-side member cache, sorted alphabetically by name. By default only active members are returned.
Authentication Required: Yes (any authenticated user)
Permissions Required: None
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
active |
string | true |
Set to false to include inactive members |
Response:
{
"status": 200,
"message": "CW members fetched successfully!",
"data": [
{
"id": 250,
"identifier": "jroberts",
"firstName": "John",
"lastName": "Roberts",
"name": "John Roberts",
"officeEmail": "jroberts@totaltech.net",
"inactive": false
}
],
"successful": true
}
Receive ConnectWise Callback
POST /cw/callback/:secret/:resource
Receives ConnectWise callback/webhook payloads for supported resources and returns a normalized success response.
Authentication Required: No
Path Parameters:
secret— Shared callback secret, validated againstCW_CALLBACK_SECRETwhen configuredresource— one of:opportunity,ticket,company,activity
Behavior:
- Parses JSON request body when present.
- Decodes JSON-encoded payload fields such as
Entity. - Logs a concise callback summary to console.
Response:
{
"status": 200,
"message": "CW callback received.",
"data": {
"resource": "ticket",
"summary": {
"resource": "ticket",
"messageId": "1bec7421-204a-4b30-8b06-465915e9a0f5",
"action": "updated",
"type": "ticket",
"id": 36073,
"memberId": "jroberts",
"entityStatus": "In Progress",
"entitySummary": "Onsite Installation: Rough-In",
"entityUpdatedBy": "cirvine",
"entityLastUpdated": "2026-03-03T21:43:29.903"
},
"bodyParsed": {},
"receivedAt": "2026-03-04T00:00:00.000Z"
},
"successful": true
}
Get Authentication URI
GET /auth/uri
Get the Microsoft OAuth authentication URI for user login.
Authentication Required: No
Response:
{
"status": 200,
"message": "Successfully fetch Auth URI",
"data": {
"uri": "https://login.microsoftonline.com/...",
"callbackKey": "ck123..."
},
"successful": true
}
OAuth Redirect Handler
GET /auth/redirect
Handles the OAuth redirect callback from Microsoft. This endpoint processes the authorization code and creates a user session.
Authentication Required: No
Query Parameters:
code- Authorization code from Microsoftstate- Callback key for WebSocket notification
Response: Closes the browser window and emits authentication tokens via WebSocket.
Refresh Access Token
POST /auth/refresh
Refresh an expired access token using a valid refresh token.
Authentication Required: Yes (Refresh Token)
Headers:
x-refresh-token- The refresh token
Response:
{
"status": 201,
"message": "Token refreshed successfully!",
"data": {
"accessToken": "eyJhbGc...",
"refreshToken": "eyJhbGc..."
},
"successful": true
}
User Routes
Get Current User
GET /user/@me
Fetch the currently authenticated user's information.
Authentication Required: Yes
Required Scopes: user.read
Field-Level Gating: obj.user — see Object Type Field-Level Gating
Response:
{
"status": 200,
"message": "Fetched user.",
"data": {
"id": "ckx...",
"name": "John Doe",
"email": "john.doe@example.com",
"login": "john.doe",
"image": "https://...",
"roles": ["admin"],
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-02-14T00:00:00.000Z"
},
"successful": true
}
Update Current User
PATCH /user/@me
Update the currently authenticated user's information.
Authentication Required: Yes
Required Scopes: user.write
Request Body:
{
"name": "Jane Doe",
"image": "https://example.com/avatar.jpg"
}
Response:
{
"status": 200,
"message": "Successfully updated user.",
"data": {
"id": "ckx...",
"name": "Jane Doe",
"email": "jane.doe@example.com",
"image": "https://example.com/avatar.jpg"
},
"successful": true
}
Check User Permissions
POST /user/@me/check-permission
Check if the currently authenticated user has specific permissions. Accepts an array of permission nodes and returns the status for each.
Authentication Required: Yes
Required Scopes: user.read
Request Body:
{
"permissions": ["user.read", "company.create", "credential.write"]
}
Response:
{
"status": 200,
"message": "Permission check completed.",
"data": {
"results": [
{
"permission": "user.read",
"hasPermission": true
},
{
"permission": "company.create",
"hasPermission": false
},
{
"permission": "credential.write",
"hasPermission": true
}
]
},
"successful": true
}
Other User Routes
Get All Users
GET /user/users
Fetch a list of all users.
Authentication Required: Yes
Required Permissions: user.read.other, user.list.other
Field-Level Gating: obj.user — see Object Type Field-Level Gating
Response:
{
"status": 200,
"message": "Users Fetched Successfully!",
"data": [
{
"id": "ckx...",
"name": "John Doe",
"email": "john.doe@example.com",
"login": "john.doe",
"image": "https://...",
"roles": ["admin"]
}
],
"successful": true
}
Get User by ID
GET /user/users/:identifier
Fetch a specific user by their ID.
Authentication Required: Yes
Required Permissions: user.read.other
Field-Level Gating: obj.user — see Object Type Field-Level Gating
Path Parameters:
identifier- The user's ID
Response:
{
"status": 200,
"message": "User Fetched Successfully!",
"data": {
"id": "ckx...",
"name": "John Doe",
"email": "john.doe@example.com",
"login": "john.doe",
"image": "https://...",
"roles": ["admin"]
},
"successful": true
}
Error Response (404):
{
"status": 404,
"message": "User with identifier 'ckx...' was not found.",
"error": "UserNotFound",
"successful": false
}
Update User by ID
PATCH /user/users/:identifier
Update a specific user's information. Supports updating profile fields, roles, and direct permissions.
Authentication Required: Yes
Required Permissions: user.write.other
Conditional Permissions:
- If
rolesis included in the body:user.roles.otheris also required - If
permissionsis included in the body:user.permissions.otheris also required
Path Parameters:
identifier- The user's ID
Request Body:
All fields are optional. Include only the fields you want to update.
{
"name": "Jane Doe",
"image": "https://example.com/avatar.jpg",
"roles": ["admin", "moderator"],
"permissions": ["credential.fetch", "company.fetch"]
}
| Field | Type | Description |
|---|---|---|
name |
string |
The user's display name |
image |
string |
URL to the user's avatar image |
roles |
string[] |
Array of role ids or monikers to assign (replaces all roles) |
permissions |
string[] |
Array of permission nodes to assign (replaces all permissions) |
Response:
{
"status": 200,
"message": "User Updated Successfully!",
"data": {
"id": "ckx...",
"name": "Jane Doe",
"email": "jane.doe@example.com",
"login": "jane.doe",
"image": "https://example.com/avatar.jpg",
"roles": ["admin", "moderator"]
},
"successful": true
}
Error Response (403 - Missing role permission):
{
"status": 403,
"message": "You do not have permission to modify roles on another user.",
"error": "InsufficientPermission",
"successful": false
}
Delete User by ID
DELETE /user/users/:identifier
Delete a specific user.
Authentication Required: Yes
Required Permissions: user.delete.other
Path Parameters:
identifier- The user's ID
Response:
{
"status": 200,
"message": "User Deleted Successfully!",
"data": {
"id": "ckx...",
"name": "John Doe",
"email": "john.doe@example.com",
"login": "john.doe",
"image": "https://...",
"roles": ["admin"]
},
"successful": true
}
Get User Roles
GET /user/users/:identifier/roles
Fetch all roles assigned to a specific user.
Authentication Required: Yes
Required Permissions: user.read.other, role.read
Field-Level Gating: obj.role — see Object Type Field-Level Gating
Path Parameters:
identifier- The user's ID
Response:
{
"status": 200,
"message": "User Roles Fetched Successfully!",
"data": [
{
"id": "uuid...",
"title": "Administrator",
"moniker": "admin",
"permissions": ["*"]
}
],
"successful": true
}
Check User Permissions (Other User)
POST /user/users/:identifier/check-permission
Check if a specific user has certain permissions.
Authentication Required: Yes
Required Permissions: user.read.other
Path Parameters:
identifier- The user's ID
Request Body:
{
"permissions": ["user.read", "company.fetch", "credential.write"]
}
Response:
{
"status": 200,
"message": "Permission check completed.",
"data": {
"results": [
{
"permission": "user.read",
"hasPermission": true
},
{
"permission": "company.fetch",
"hasPermission": false
},
{
"permission": "credential.write",
"hasPermission": true
}
]
},
"successful": true
}
Company Routes
Get All Companies
GET /company/companies
Fetch a paginated list of all companies with optional search functionality.
Authentication Required: Yes
Required Permissions: company.fetch.many
Field-Level Gating: obj.company — see Object Type Field-Level Gating
Query Parameters:
page(optional) - Page number (default: 1)rpp(optional) - Records per page (default: 30)search(optional) - Search query to filter companies
Response:
{
"status": 200,
"message": "Companies Fetched Successfully!",
"data": [
{
"id": "ckx...",
"name": "Acme Corp",
"cw_CompanyId": 12345,
"cw_Identifier": "AcmeCorp"
}
],
"pagination": {
"previousPage": null,
"currentPage": 1,
"nextPage": 2,
"totalPages": 10,
"totalRecords": 300,
"listedRecords": 30
},
"successful": true
}
Get Company by ID
GET /company/companies/:identifier
Fetch a single company by its ID. Automatically fetches fresh data from ConnectWise and returns it along with internal company data.
Authentication Required: Yes
Required Permissions:
company.fetch(base permission)company.fetch.address(required whenincludeAddress=true)company.fetch.contacts(required whenincludeAllContacts=true)
Field-Level Gating: obj.company — see Object Type Field-Level Gating
URL Parameters:
identifier- Company ID (internal database ID)
Query Parameters:
includeAddress(optional) - Set to "true" to include full address information. Requirescompany.fetch.addresspermission. (default: false)includePrimaryContact(optional) - Set to "true" to include the company's default contact from ConnectWise. (default: false)includeAllContacts(optional) - Set to "true" to include all contacts for the company from ConnectWise. Requirescompany.fetch.contactspermission. (default: false)
Response (without optional query params):
{
"status": 200,
"message": "Company Fetched Successfully!",
"data": {
"id": "ckx...",
"name": "Acme Corp",
"cw_CompanyId": 12345,
"cw_Identifier": "AcmeCorp",
"cw_Data": {}
},
"successful": true
}
Response (with includeAddress=true):
{
"status": 200,
"message": "Company Fetched Successfully!",
"data": {
"id": "ckx...",
"name": "Acme Corp",
"cw_CompanyId": 12345,
"cw_Identifier": "AcmeCorp",
"cw_Data": {
"address": {
"line1": "123 Main St",
"line2": null,
"city": "Springfield",
"state": "IL",
"zip": "62701",
"country": "United States"
}
}
},
"successful": true
}
Response (with includePrimaryContact=true):
{
"status": 200,
"message": "Company Fetched Successfully!",
"data": {
"id": "ckx...",
"name": "Acme Corp",
"cw_CompanyId": 12345,
"cw_Identifier": "AcmeCorp",
"cw_Data": {
"primaryContact": {
"firstName": "John",
"lastName": "Doe",
"cwId": 456,
"inactive": false,
"title": "IT Manager",
"phone": "555-0123",
"email": "john.doe@acmecorp.com"
}
}
},
"successful": true
}
Response (with includeAllContacts=true):
{
"status": 200,
"message": "Company Fetched Successfully!",
"data": {
"id": "ckx...",
"name": "Acme Corp",
"cw_CompanyId": 12345,
"cw_Identifier": "AcmeCorp",
"cw_Data": {
"allContacts": [
{
"firstName": "John",
"lastName": "Doe",
"cwId": 456,
"inactive": false,
"title": "IT Manager",
"phone": "555-0123",
"email": "john.doe@acmecorp.com"
},
{
"firstName": "Jane",
"lastName": "Smith",
"cwId": 789,
"inactive": false,
"title": "CTO",
"phone": "555-0456",
"email": "jane.smith@acmecorp.com"
}
]
}
},
"successful": true
}
Get Company Configurations
GET /company/companies/:identifier/configurations
Fetch configurations for a specific company from ConnectWise.
Authentication Required: Yes
Required Permissions: company.fetch, company.fetch.configurations
URL Parameters:
identifier- Company ID
Response:
{
"status": 200,
"message": "Company Configurations Fetched Successfully!",
"data": {
// ConnectWise configuration data
},
"successful": true
}
Get Company Sites
GET /company/companies/:identifier/sites
Fetch all ConnectWise sites for a specific company.
Authentication Required: Yes
Required Permissions: company.fetch, company.fetch.sites
URL Parameters:
identifier- Company ID
Response:
{
"status": 200,
"message": "Company Sites Fetched Successfully!",
"data": [
{
"id": 1,
"name": "Main Office",
"address": {
"line1": "123 Main St",
"line2": null,
"city": "Springfield",
"state": "Illinois",
"zip": "62704",
"country": "United States"
},
"phoneNumber": "555-123-4567",
"faxNumber": null,
"primaryAddressFlag": true,
"defaultShippingFlag": true,
"defaultBillingFlag": true,
"defaultMailingFlag": true
}
],
"successful": true
}
Get Company UniFi Sites
GET /company/companies/:identifier/unifi/sites
Fetch all UniFi sites linked to a specific company.
Authentication Required: Yes
Required Permissions: unifi.access, company.fetch
Field-Level Gating: obj.unifiSite — see Object Type Field-Level Gating
URL Parameters:
identifier- Company ID
Response:
{
"status": 200,
"message": "Company UniFi Sites Fetched Successfully!",
"data": [
{
"id": "ckx...",
"name": "Main Office",
"siteId": "abc123",
"companyId": "ckx...",
"createdAt": "2025-01-01T00:00:00.000Z",
"updatedAt": "2025-01-01T00:00:00.000Z"
}
],
"successful": true
}
Credential Routes
Get Value Types
GET /credential/valuetypes
Returns all available field value types for credential type fields.
Authentication Required: Yes
Response:
{
"status": 200,
"message": "Value Types Fetched Successfully!",
"data": [
"plain_text",
"license_key",
"ip_address",
"generic_secret",
"bitlocker_key",
"password",
"multi_credential"
],
"successful": true
}
Get Credential by ID
GET /credential/credentials/:id
Fetch a single credential by its ID.
Authentication Required: Yes
Required Permissions: credential.fetch
Field-Level Gating: obj.credential — see Object Type Field-Level Gating
URL Parameters:
id- Credential ID
Response:
{
"status": 200,
"message": "Credential Fetched Successfully!",
"data": {
"id": "ckx...",
"name": "AWS Credentials",
"notes": null,
"typeId": "cky...",
"companyId": "ckz...",
"fields": [
{
"id": "accessKeyId",
"name": "Access Key ID",
"secure": false,
"required": true,
"valueType": "plain_text",
"value": "AKIAIOSFODNN7EXAMPLE"
},
{
"id": "secretAccessKey",
"name": "Secret Access Key",
"secure": true,
"required": true,
"valueType": "password",
"value": null
}
],
"type": {
"id": "cky...",
"name": "AWS",
"fields": [...],
"permissionScope": "aws.credentials"
},
"company": {
"id": "ckz...",
"name": "Acme Corp"
},
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-02-14T00:00:00.000Z"
},
"successful": true
}
Get Credentials by Company
GET /credential/credentials/company/:companyId
Fetch all credentials associated with a specific company.
Authentication Required: Yes
Required Permissions: credential.fetch.many
Field-Level Gating: obj.credential — see Object Type Field-Level Gating
URL Parameters:
companyId- Company ID
Response:
{
"status": 200,
"message": "Company Credentials Fetched Successfully!",
"data": [
{
"id": "ckx...",
"name": "AWS Credentials",
"notes": null,
"typeId": "cky...",
"companyId": "ckz...",
"fields": [
{
"id": "accessKeyId",
"name": "Access Key ID",
"secure": false,
"required": true,
"valueType": "plain_text",
"value": "AKIAIOSFODNN7EXAMPLE"
},
{
"id": "secretAccessKey",
"name": "Secret Access Key",
"secure": true,
"required": true,
"valueType": "password",
"value": null
}
],
"type": {...},
"company": {...}
}
],
"successful": true
}
Create Credential
POST /credential/credentials
Create a new credential with validated and encrypted fields.
Authentication Required: Yes
Required Permissions: credential.create
Request Body:
{
"name": "Production AWS Credentials",
"notes": "Used for production S3 access",
"typeId": "cky...",
"companyId": "ckz...",
"fields": [
{
"id": "ckx1...",
"fieldId": "accessKeyId",
"value": "AKIAIOSFODNN7EXAMPLE"
},
{
"id": "ckx2...",
"fieldId": "secretAccessKey",
"value": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
}
],
"subCredentials": {
"tunnels": [
{
"name": "Tunnel 1",
"fields": [
{ "fieldId": "server", "value": "vpn1.example.com" },
{ "fieldId": "port", "value": "443" }
]
}
]
}
}
| Field | Type | Required | Description |
|---|---|---|---|
name |
string |
Yes | Display name for the credential |
notes |
string |
No | Optional notes |
typeId |
string |
Yes | The credential type ID |
companyId |
string |
Yes | The company ID this credential belongs to |
fields |
array |
Yes | Array of field values ({ fieldId, value }) |
subCredentials |
object |
No | Keyed by multi-credential field ID. Each value is an array of { name, fields } objects for inline sub-credentials |
**Response:**
```json
{
"status": 201,
"message": "Credential Created Successfully!",
"data": {
"id": "ckx...",
"name": "Production AWS Credentials",
"typeId": "cky...",
"companyId": "ckz...",
"fields": [
{
"id": "accessKeyId",
"name": "Access Key ID",
"secure": false,
"required": true,
"valueType": "plain_text",
"value": "AKIAIOSFODNN7EXAMPLE"
},
{
"id": "secretAccessKey",
"name": "Secret Access Key",
"secure": true,
"required": true,
"valueType": "password",
"value": null
}
],
"type": {...},
"company": {...}
},
"successful": true
}
Update Credential
PATCH /credential/credentials/:id
Update a credential's basic properties (name, notes) and/or field values. Secure fields are automatically encrypted.
Authentication Required: Yes
Required Permissions: credential.update
URL Parameters:
id- Credential ID
Request Body:
All properties are optional. Include only the properties you want to update.
{
"name": "Updated Credential Name",
"notes": "Updated notes for this credential",
"fields": [
{
"fieldId": "accessKeyId",
"value": "AKIAIOSFODNN7EXAMPLE"
},
{
"fieldId": "secretAccessKey",
"value": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
}
]
}
Response:
{
"status": 200,
"message": "Credential Updated Successfully!",
"data": {
"id": "ckx...",
"name": "Updated Credential Name",
"notes": "Updated notes for this credential",
"typeId": "cky...",
"companyId": "ckz...",
"fields": [
{
"id": "accessKeyId",
"name": "Access Key ID",
"secure": false,
"required": true,
"valueType": "plain_text",
"value": "AKIAIOSFODNN7EXAMPLE"
}
],
"type": {...},
"company": {...}
},
"successful": true
}
Update Credential Fields
PUT /credential/credentials/:id/fields
Validate and update credential field values. Secure fields are automatically encrypted.
Authentication Required: Yes
Required Permissions: credential.update, credential.fields.update
URL Parameters:
id- Credential ID
Request Body:
{
"fields": [
{
"fieldId": "accessKeyId",
"value": "AKIAIOSFODNN7NEWVALUE"
},
{
"fieldId": "secretAccessKey",
"value": "newSecretKeyValue123"
}
]
}
Response:
{
"status": 200,
"message": "Credential Fields Updated Successfully!",
"data": {
"id": "ckx...",
"name": "Production AWS Credentials",
"notes": null,
"typeId": "cky...",
"companyId": "ckz...",
"fields": [
{
"id": "accessKeyId",
"name": "Access Key ID",
"secure": false,
"required": true,
"valueType": "plain_text",
"value": "AKIAIOSFODNN7NEWVALUE"
},
{
"id": "secretAccessKey",
"name": "Secret Access Key",
"secure": true,
"required": true,
"valueType": "password",
"value": null
}
],
"type": {...},
"company": {...}
},
"successful": true
}
Get Credential Fields
GET /credential/credentials/:id/fields
Fetch all field values for a credential (secure fields returned encrypted).
Authentication Required: Yes
Required Permissions: credential.fetch, credential.fields.fetch
URL Parameters:
id- Credential ID
Response:
{
"status": 200,
"message": "Credential Fields Fetched Successfully!",
"data": [
{
"id": "ckx-accessKeyId",
"fieldId": "accessKeyId",
"value": "AKIAIOSFODNN7EXAMPLE"
},
{
"id": "ckx1...",
"fieldId": "secretAccessKey",
"value": "base64EncryptedValue=="
}
],
"successful": true
}
Read Secure Values
GET /credential/credentials/:id/secure-values
Decrypt and return all secure field values for a credential.
Authentication Required: Yes
Required Permissions: credential.fetch, credential.secure_values.read
URL Parameters:
id- Credential ID
Response:
{
"status": 200,
"message": "Secure Values Fetched Successfully!",
"data": {
"secretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"apiKey": "sk_live_123456789abcdef"
},
"successful": true
}
Read Single Secure Value
GET /credential/credentials/:id/secure-values/:fieldId
Decrypt and return a single secure field value for a credential.
Authentication Required: Yes
Required Permissions: credential.fetch, credential.secure_values.read
URL Parameters:
id- Credential IDfieldId- The field ID of the secure value to read
Response:
{
"status": 200,
"message": "Secure Value Fetched Successfully!",
"data": {
"fieldId": "secretAccessKey",
"value": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
},
"successful": true
}
Error Response (404):
{
"status": 404,
"message": "Secure field not found: unknownField",
"error": "SecureFieldNotFound",
"successful": false
}
Delete Credential
DELETE /credential/credentials/:id
Delete a credential and all associated secure values. Sub-credentials are cascade-deleted automatically.
Authentication Required: Yes
Required Permissions: credential.delete
URL Parameters:
id- Credential ID
Response:
{
"status": 200,
"message": "Credential Deleted Successfully!",
"data": null,
"successful": true
}
Get Sub-Credentials
GET /credential/credentials/:id/sub-credentials
Fetch all sub-credentials that belong to a specific parent credential.
Authentication Required: Yes
Required Permissions: credential.fetch, credential.sub_credentials.fetch
Field-Level Gating: obj.credential — see Object Type Field-Level Gating
URL Parameters:
id- Parent Credential ID
Response:
{
"status": 200,
"message": "Sub-Credentials Fetched Successfully!",
"data": [
{
"id": "ckx1...",
"name": "Tunnel 1",
"notes": null,
"typeId": "cky...",
"companyId": "ckz...",
"subCredentialOfId": "ckx...",
"fields": [
{ "id": "server", "value": "vpn1.example.com", "secure": false },
{ "id": "port", "value": "443", "secure": false }
],
"type": { "..." },
"company": { "..." },
"createdAt": "2026-02-20T00:00:00.000Z",
"updatedAt": "2026-02-20T00:00:00.000Z"
}
],
"successful": true
}
Add Sub-Credential
POST /credential/credentials/:id/sub-credentials
Create a new sub-credential under an existing parent credential for a specific multi-credential field.
Authentication Required: Yes
Required Permissions: credential.fetch, credential.sub_credentials.create
URL Parameters:
id- Parent Credential ID
Request Body:
{
"fieldId": "tunnels",
"name": "Tunnel 2",
"fields": [
{ "fieldId": "server", "value": "vpn2.example.com" },
{ "fieldId": "port", "value": "1194" }
]
}
| Field | Type | Required | Description |
|---|---|---|---|
fieldId |
string |
Yes | The multi-credential field ID on the parent credential type |
name |
string |
Yes | Display name for the sub-credential |
fields |
array |
Yes | Array of field values matching the multi-credential's subFields |
Response:
{
"status": 201,
"message": "Sub-Credential Created Successfully!",
"data": {
"id": "ckx2...",
"name": "Tunnel 2",
"typeId": "cky...",
"companyId": "ckz...",
"subCredentialOfId": "ckx...",
"fields": [
{ "id": "server", "value": "vpn2.example.com", "secure": false },
{ "id": "port", "value": "1194", "secure": false }
],
"type": { "..." },
"company": { "..." },
"createdAt": "2026-02-20T00:00:00.000Z",
"updatedAt": "2026-02-20T00:00:00.000Z"
},
"successful": true
}
Remove Sub-Credential
DELETE /credential/credentials/:id/sub-credentials/:subId
Delete a sub-credential and remove its reference from the parent credential's multi-credential field.
Authentication Required: Yes
Required Permissions: credential.fetch, credential.sub_credentials.delete
URL Parameters:
id- Parent Credential IDsubId- Sub-Credential ID to remove
Response:
{
"status": 200,
"message": "Sub-Credential Removed Successfully!",
"data": null,
"successful": true
}
Error Response (404):
{
"status": 404,
"message": "Sub-credential not found",
"error": "SubCredentialNotFound",
"successful": false
}
Credential Type Routes
Get Credential Type by ID or Name
GET /credential-type/:identifier
Fetch a single credential type by its ID or name.
Authentication Required: Yes
Required Permissions: credential_type.fetch
Field-Level Gating: obj.credentialType — see Object Type Field-Level Gating
URL Parameters:
identifier- Credential Type ID or name
Response:
{
"status": 200,
"message": "Credential Type Fetched Successfully!",
"data": {
"id": "cky...",
"name": "AWS",
"permissionScope": "aws.credentials",
"icon": "https://aws.amazon.com/favicon.ico",
"fields": [
{
"id": "accessKeyId",
"name": "Access Key ID",
"required": true,
"secure": false,
"valueType": "plain_text"
},
{
"id": "secretAccessKey",
"name": "Secret Access Key",
"required": true,
"secure": true,
"valueType": "password"
}
],
"credentialCount": 5,
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-02-14T00:00:00.000Z"
},
"successful": true
}
Get All Credential Types
GET /credential-type
Fetch all credential types in the system.
Authentication Required: Yes
Required Permissions: credential_type.fetch.many
Field-Level Gating: obj.credentialType — see Object Type Field-Level Gating
Response:
{
"status": 200,
"message": "Credential Types Fetched Successfully!",
"data": [
{
"id": "cky...",
"name": "AWS",
"permissionScope": "aws.credentials",
"icon": "https://aws.amazon.com/favicon.ico",
"fields": [...],
"credentialCount": 5
},
{
"id": "ckz...",
"name": "Azure",
"permissionScope": "azure.credentials",
"icon": "https://azure.microsoft.com/favicon.ico",
"fields": [...],
"credentialCount": 3
}
],
"successful": true
}
Create Credential Type
POST /credential-type
Create a new credential type with field definitions.
Authentication Required: Yes
Required Permissions: credential_type.create
Request Body:
{
"name": "GitHub",
"permissionScope": "github.credentials",
"icon": "https://github.com/favicon.ico",
"fields": [
{
"id": "username",
"name": "Username",
"required": true,
"secure": false,
"valueType": "plain_text"
},
{
"id": "personalAccessToken",
"name": "Personal Access Token",
"required": true,
"secure": true,
"valueType": "password"
}
]
}
Multi-Credential Example:
Fields with valueType: "multi_credential" support an optional subFields array that defines the field structure for each sub-credential entry. Sub-fields use the same schema and can be nested recursively.
{
"name": "VPN Config",
"permissionScope": "vpn.credentials",
"icon": "https://example.com/vpn.ico",
"fields": [
{
"id": "hostname",
"name": "Hostname",
"required": true,
"secure": false,
"valueType": "plain_text"
},
{
"id": "tunnels",
"name": "Tunnels",
"required": false,
"secure": false,
"valueType": "multi_credential",
"subFields": [
{
"id": "server",
"name": "Server",
"required": true,
"secure": false,
"valueType": "plain_text"
},
{
"id": "psk",
"name": "Pre-Shared Key",
"required": true,
"secure": true,
"valueType": "password"
}
]
}
]
}
| Field | Type | Required | Description |
|---|---|---|---|
fields[].id |
string |
Yes | Unique identifier for the field |
fields[].name |
string |
Yes | Display name for the field |
fields[].required |
bool |
Yes | Whether the field is required when creating a credential |
fields[].secure |
bool |
Yes | Whether the field value should be encrypted at rest |
fields[].valueType |
string |
Yes | One of the supported value types (see GET /credential/valuetypes) |
fields[].subFields |
array |
No | Only for multi_credential fields. Defines the field structure for each nested sub-credential |
Response:
{
"status": 201,
"message": "Credential Type Created Successfully!",
"data": {
"id": "ck1...",
"name": "GitHub",
"permissionScope": "github.credentials",
"icon": "https://github.com/favicon.ico",
"fields": [...]
},
"successful": true
}
Update Credential Type
PATCH /credential-type/:id
Update a credential type's properties or field definitions.
Authentication Required: Yes
Required Permissions: credential_type.update
URL Parameters:
id- Credential Type ID
Request Body:
{
"name": "GitHub Enterprise",
"icon": "https://github.enterprise.com/favicon.ico",
"fields": [
{
"id": "username",
"name": "Username",
"required": true,
"secure": false,
"valueType": "plain_text"
},
{
"id": "personalAccessToken",
"name": "Personal Access Token",
"required": true,
"secure": true,
"valueType": "password"
},
{
"id": "enterpriseUrl",
"name": "Enterprise URL",
"required": true,
"secure": false,
"valueType": "plain_text"
}
]
}
Note: Fields with
valueType: "multi_credential"support an optionalsubFieldsarray — see the Create Credential Type section for the full schema and example.
Response:
{
"status": 200,
"message": "Credential Type Updated Successfully!",
"data": {
"id": "ck1...",
"name": "GitHub Enterprise",
"fields": [...]
},
"successful": true
}
Delete Credential Type
DELETE /credential-type/:id
Delete a credential type. This will cascade delete all credentials of this type.
Authentication Required: Yes
Required Permissions: credential_type.delete
URL Parameters:
id- Credential Type ID
Response:
{
"status": 200,
"message": "Credential Type Deleted Successfully!",
"data": null,
"successful": true
}
Get Credentials by Type
GET /credential-type/:id/credentials
Fetch all credentials that use a specific credential type.
Authentication Required: Yes
Required Permissions: credential_type.fetch, credential.fetch.many
Field-Level Gating: obj.credential — see Object Type Field-Level Gating
URL Parameters:
id- Credential Type ID
Response:
{
"status": 200,
"message": "Credentials Fetched Successfully!",
"data": [
{
"id": "ckx...",
"name": "Production AWS",
"typeId": "cky...",
"companyId": "ckz...",
"fields": {...}
},
{
"id": "ck2...",
"name": "Staging AWS",
"typeId": "cky...",
"companyId": "ckz...",
"fields": {...}
}
],
"successful": true
}
Role Routes
Create Role
POST /role
Create a new role with a title, moniker, and optional permissions.
Authentication Required: Yes
Required Permissions: role.create
Request Body:
{
"title": "System Administrator",
"moniker": "system_admin",
"permissions": [
"user.read",
"user.write",
"company.fetch",
"credential.create"
]
}
Response:
{
"status": 201,
"message": "Role Created Successfully!",
"data": {
"id": "ckx...",
"title": "System Administrator",
"moniker": "system_admin",
"permissions": [
"user.read",
"user.write",
"company.fetch",
"credential.create"
],
"createdAt": "2026-02-17T00:00:00.000Z",
"updatedAt": "2026-02-17T00:00:00.000Z"
},
"successful": true
}
Get Role by ID or Moniker
GET /role/:identifier
Fetch a single role by its ID or moniker.
Authentication Required: Yes
Required Permissions: role.read
Field-Level Gating: obj.role — see Object Type Field-Level Gating
URL Parameters:
identifier- Role ID or moniker
Response:
{
"status": 200,
"message": "Role Fetched Successfully!",
"data": {
"id": "ckx...",
"title": "System Administrator",
"moniker": "system_admin",
"permissions": [
"user.read",
"user.write",
"company.fetch",
"credential.create"
],
"createdAt": "2026-02-17T00:00:00.000Z",
"updatedAt": "2026-02-17T00:00:00.000Z"
},
"successful": true
}
Get All Roles
GET /role
Fetch all roles in the system.
Authentication Required: Yes
Required Permissions: role.read, role.list
Field-Level Gating: obj.role — see Object Type Field-Level Gating
Response:
{
"status": 200,
"message": "Roles Fetched Successfully!",
"data": [
{
"id": "ckx...",
"title": "System Administrator",
"moniker": "system_admin",
"permissions": ["user.read", "user.write"],
"createdAt": "2026-02-17T00:00:00.000Z",
"updatedAt": "2026-02-17T00:00:00.000Z"
},
{
"id": "cky...",
"title": "Viewer",
"moniker": "viewer",
"permissions": ["user.read", "company.fetch"],
"createdAt": "2026-02-17T00:00:00.000Z",
"updatedAt": "2026-02-17T00:00:00.000Z"
}
],
"successful": true
}
Update Role
PATCH /role/:identifier
Update a role's title, moniker, or permissions.
Authentication Required: Yes
Required Permissions: role.modify
URL Parameters:
identifier- Role ID or moniker
Request Body:
{
"title": "Super Administrator",
"moniker": "super_admin",
"permissions": ["*"]
}
Response:
{
"status": 200,
"message": "Role Updated Successfully!",
"data": {
"id": "ckx...",
"title": "Super Administrator",
"moniker": "super_admin",
"permissions": ["*"],
"createdAt": "2026-02-17T00:00:00.000Z",
"updatedAt": "2026-02-17T12:00:00.000Z"
},
"successful": true
}
Delete Role
DELETE /role/:identifier
Delete a role. This will remove the role from all users that have it assigned.
Authentication Required: Yes
Required Permissions: role.delete
URL Parameters:
identifier- Role ID or moniker
Response:
{
"status": 200,
"message": "Role Deleted Successfully!",
"data": {
"id": "ckx...",
"title": "System Administrator",
"moniker": "system_admin",
"createdAt": "2026-02-17T00:00:00.000Z",
"updatedAt": "2026-02-17T12:00:00.000Z"
},
"successful": true
}
Add Permissions to Role
POST /role/:identifier/permissions
Add one or more permissions to an existing role. The new permissions will be merged with existing permissions.
Authentication Required: Yes
Required Permissions: role.modify
URL Parameters:
identifier- Role ID or moniker
Request Body:
{
"permissions": ["credential.update", "credential.delete"]
}
Response:
{
"status": 200,
"message": "Permissions Added Successfully!",
"data": {
"id": "ckx...",
"title": "System Administrator",
"moniker": "system_admin",
"permissions": [
"user.read",
"user.write",
"company.fetch",
"credential.create",
"credential.update",
"credential.delete"
],
"createdAt": "2026-02-17T00:00:00.000Z",
"updatedAt": "2026-02-17T12:30:00.000Z"
},
"successful": true
}
Remove Permissions from Role
DELETE /role/:identifier/permissions
Remove one or more permissions from an existing role.
Authentication Required: Yes
Required Permissions: role.modify
URL Parameters:
identifier- Role ID or moniker
Request Body:
{
"permissions": ["credential.delete"]
}
Response:
{
"status": 200,
"message": "Permissions Removed Successfully!",
"data": {
"id": "ckx...",
"title": "System Administrator",
"moniker": "system_admin",
"permissions": [
"user.read",
"user.write",
"company.fetch",
"credential.create",
"credential.update"
],
"createdAt": "2026-02-17T00:00:00.000Z",
"updatedAt": "2026-02-17T12:45:00.000Z"
},
"successful": true
}
Get Users with Role
GET /role/:identifier/users
Fetch all users that have been assigned a specific role.
Authentication Required: Yes
Required Permissions: role.read, user.read
Field-Level Gating: obj.user — see Object Type Field-Level Gating
URL Parameters:
identifier- Role ID or moniker
Response:
{
"status": 200,
"message": "Users Fetched Successfully!",
"data": [
{
"id": "cku...",
"name": "John Doe",
"login": "john.doe",
"roles": ["ckx..."],
"createdAt": "2026-01-15T00:00:00.000Z",
"updatedAt": "2026-02-10T00:00:00.000Z"
},
{
"id": "ckv...",
"name": "Jane Smith",
"login": "jane.smith",
"roles": ["ckx...", "cky..."],
"createdAt": "2026-01-20T00:00:00.000Z",
"updatedAt": "2026-02-12T00:00:00.000Z"
}
],
"successful": true
}
Permission Routes
Get All Permission Nodes (Categorized)
GET /permissions
Fetch all permission nodes organized by category. Returns the full permission node definition object with categories as keys.
Authentication Required: Yes
Required Permissions: role.read
Response:
{
"status": 200,
"message": "Permission Nodes Fetched Successfully!",
"data": {
"global": {
"name": "Global Permissions",
"description": "Global wildcard permissions that grant access to all resources",
"permissions": [
{
"node": "*",
"description": "Full access to all resources and actions (administrator role)",
"usedIn": []
}
]
},
"company": { "..." },
"credential": { "..." },
"...additional categories": { "..." }
},
"successful": true
}
Get All Permission Nodes (Flat)
GET /permissions/nodes
Fetch a flat array of all permission nodes across all categories.
Authentication Required: Yes
Required Permissions: role.read
Response:
{
"status": 200,
"message": "All Permission Nodes Fetched Successfully!",
"data": [
{
"node": "*",
"description": "Full access to all resources and actions (administrator role)",
"usedIn": []
},
{
"node": "company.fetch",
"description": "Fetch a single company",
"usedIn": ["src/api/companies/[id]/fetch.ts"]
},
"...additional nodes"
],
"successful": true
}
Get Permission Nodes by Category
GET /permissions/:category
Fetch all permission nodes for a specific category.
Authentication Required: Yes
Required Permissions: role.read
Path Parameters:
category- The category key (e.g.,global,company,credential,credentialType,role,user,permission,uiNavigation,adminUI)
Response (Success):
{
"status": 200,
"message": "Permission Category Fetched Successfully!",
"data": {
"name": "Company Permissions",
"description": "Permissions for accessing and managing company resources",
"permissions": [
{
"node": "company.fetch",
"description": "Fetch a single company",
"usedIn": ["src/api/companies/[id]/fetch.ts"]
},
{
"node": "company.fetch.address",
"description": "View company address information",
"usedIn": ["src/api/companies/[id]/fetch.ts"],
"dependencies": ["company.fetch"]
}
]
},
"successful": true
}
Response (Not Found):
{
"status": 404,
"message": "Permission category \"invalidCategory\" not found",
"error": "NotFound",
"successful": false
}
Utility Routes
Teapot
GET /teapot
A fun Easter egg endpoint that returns HTTP 418 (I'm a teapot).
Authentication Required: No
Response:
{
"status": 418,
"message": "I'm a teapot",
"successful": false
}
Procurement Routes
Get All Catalog Items
GET /procurement/items
Fetch a paginated list of catalog items. Supports search.
Authentication Required: Yes
Required Permissions: procurement.catalog.fetch.many
Field-Level Gating: obj.catalogItem — see Object Type Field-Level Gating
Query Parameters:
page(optional, default1) — Page numberrpp(optional, default30) — Records per pagesearch(optional) — Search by name, description, part number, vendor SKU, or manufacturerincludeInactive(optional, defaultfalse) — Include inactive catalog items in resultscategory(optional) — Filter by CW category name or CW ID (e.g.Technologyor18)subcategory(optional) — Filter by CW subcategory name or CW ID (e.g.Network-Switchor112)group(optional) — Filter by umbrella group name (e.g.Network,AlarmBurg,Cables). When used withcategory, returns items whose subcategory belongs to that group within the category.manufacturer(optional) — Filter by manufacturer name (case-insensitive contains match)ecosystem(optional) — Filter by ecosystem name (e.g.Networking,Video Surveillance,Burg/Alarm). Applies manufacturer + category + subcategory-prefix matching rules.inStock(optional, defaultfalse) — Whentrue, only return items withonHand > 0minPrice(optional) — Minimum price filtermaxPrice(optional) — Maximum price filter
Response:
{
"status": 200,
"message": "Catalog items fetched successfully!",
"data": [
{
"id": "clx...",
"cwCatalogId": 123,
"name": "Dell OptiPlex 7020",
"description": "Dell OptiPlex 7020 SFF Desktop",
"customerDescription": "Business Desktop Computer",
"internalNotes": null,
"category": "Technology",
"categoryCwId": 18,
"subcategory": "Computer-Desktop",
"subcategoryCwId": 106,
"manufacturer": "Dell",
"manufactureCwId": 45,
"partNumber": "OPT7020-SFF",
"vendorName": "Dell Direct",
"vendorSku": "DELL-OPT7020",
"vendorCwId": 12,
"price": 899.99,
"cost": 650.0,
"inactive": false,
"salesTaxable": true,
"onHand": 5,
"cwLastUpdated": "2026-02-25T10:00:00.000Z",
"createdAt": "2026-01-15T00:00:00.000Z",
"updatedAt": "2026-02-25T10:00:00.000Z"
}
],
"meta": {
"pagination": {
"previousPage": null,
"currentPage": 1,
"nextPage": 2,
"totalPages": 10,
"totalRecords": 300,
"listedRecords": 30
}
},
"successful": true
}
Get Catalog Item
GET /procurement/items/:identifier
Fetch a single catalog item by its internal ID or ConnectWise catalog ID.
Authentication Required: Yes
Required Permissions: procurement.catalog.fetch
Field-Level Gating: obj.catalogItem — see Object Type Field-Level Gating
Path Parameters:
identifier— Internal ID (cuid) or ConnectWise catalog ID (numeric)
Query Parameters:
includeLinkedItems(optional, defaultfalse) — Include linked catalog items in the response
Response:
{
"status": 200,
"message": "Catalog item fetched successfully!",
"data": {
"id": "clx...",
"cwCatalogId": 123,
"name": "Dell OptiPlex 7020",
"description": "Dell OptiPlex 7020 SFF Desktop",
"customerDescription": "Business Desktop Computer",
"internalNotes": null,
"manufacturer": "Dell",
"manufactureCwId": 45,
"partNumber": "OPT7020-SFF",
"vendorName": "Dell Direct",
"vendorSku": "DELL-OPT7020",
"vendorCwId": 12,
"price": 899.99,
"cost": 650.0,
"inactive": false,
"salesTaxable": true,
"onHand": 5,
"cwLastUpdated": "2026-02-25T10:00:00.000Z",
"linkedItems": [
{
"id": "clx...",
"cwCatalogId": 456,
"name": "Dell Warranty - 3 Year"
}
],
"createdAt": "2026-01-15T00:00:00.000Z",
"updatedAt": "2026-02-25T10:00:00.000Z"
},
"successful": true
}
Get Catalog Item Count
GET /procurement/count
Get the total number of catalog items.
Authentication Required: Yes
Required Permissions: procurement.catalog.fetch.many
Query Parameters:
activeOnly(optional, defaultfalse) — Only count active (non-inactive) items
Response:
{
"status": 200,
"message": "Catalog item count fetched successfully!",
"data": {
"count": 300
},
"successful": true
}
Refresh Catalog Item Inventory
POST /procurement/items/:identifier/refresh-inventory
Refresh the on-hand inventory count for a catalog item by fetching the latest data from ConnectWise.
Authentication Required: Yes
Required Permissions: procurement.catalog.inventory.refresh
Path Parameters:
identifier— Internal ID (cuid) or ConnectWise catalog ID (numeric)
Response:
{
"status": 200,
"message": "Inventory refreshed successfully!",
"data": {
"id": "clx...",
"cwCatalogId": 123,
"name": "Dell OptiPlex 7020",
"onHand": 7,
"price": 899.99,
"cost": 650.0,
"inactive": false,
"createdAt": "2026-01-15T00:00:00.000Z",
"updatedAt": "2026-02-26T12:00:00.000Z"
},
"successful": true
}
Get Linked Catalog Items
GET /procurement/items/:identifier/linked
Fetch all catalog items linked to a specific item.
Authentication Required: Yes
Required Permissions: procurement.catalog.fetch
Field-Level Gating: obj.catalogItem — see Object Type Field-Level Gating
Path Parameters:
identifier— Internal ID (cuid), CW identifier string, or CW catalog ID (numeric)
Response:
{
"status": 200,
"message": "Linked catalog items fetched successfully!",
"data": [
{
"id": "clx...",
"cwCatalogId": 456,
"identifier": "DELL-WAR-3YR",
"name": "Dell Warranty - 3 Year",
"description": "Dell 3 Year ProSupport Warranty",
"price": 199.99,
"cost": 120.0,
"inactive": false,
"onHand": 0,
"createdAt": "2026-01-15T00:00:00.000Z",
"updatedAt": "2026-02-25T10:00:00.000Z"
}
],
"successful": true
}
Link Catalog Items
POST /procurement/items/:identifier/link
Link a target catalog item to the specified source item. The source item is identified by the URL parameter and the target by the request body.
Authentication Required: Yes
Required Permissions: procurement.catalog.link
Path Parameters:
identifier— Internal ID (cuid), CW identifier string, or CW catalog ID (numeric) of the source item
Request Body:
{
"targetId": "clx..."
}
Response:
{
"status": 200,
"message": "Catalog item linked successfully!",
"data": {
"id": "clx...",
"cwCatalogId": 123,
"identifier": "OPT7020-SFF",
"name": "Dell OptiPlex 7020",
"linkedItems": [
{
"id": "clx...",
"cwCatalogId": 456,
"identifier": "DELL-WAR-3YR",
"name": "Dell Warranty - 3 Year"
}
],
"createdAt": "2026-01-15T00:00:00.000Z",
"updatedAt": "2026-02-26T12:00:00.000Z"
},
"successful": true
}
Unlink Catalog Items
POST /procurement/items/:identifier/unlink
Remove the link between a source catalog item and a target catalog item.
Authentication Required: Yes
Required Permissions: procurement.catalog.link
Path Parameters:
identifier— Internal ID (cuid), CW identifier string, or CW catalog ID (numeric) of the source item
Request Body:
{
"targetId": "clx..."
}
Response:
{
"status": 200,
"message": "Catalog item unlinked successfully!",
"data": {
"id": "clx...",
"cwCatalogId": 123,
"identifier": "OPT7020-SFF",
"name": "Dell OptiPlex 7020",
"linkedItems": [],
"createdAt": "2026-01-15T00:00:00.000Z",
"updatedAt": "2026-02-26T12:00:00.000Z"
},
"successful": true
}
Get Categories & Ecosystems
GET /procurement/categories
Fetch the full category tree and ecosystem tree. The category tree defines the three-level hierarchy (Category → Group/Subcategory → Subcategory) used for organizing catalog items. The ecosystem tree defines cross-cutting product groupings by manufacturer + category + subcategory-prefix rules.
Authentication Required: Yes
Required Permissions: procurement.catalog.fetch.many
Response:
{
"status": 200,
"message": "Category and ecosystem data fetched successfully!",
"data": {
"categories": [
{
"name": "Technology",
"cwId": 18,
"entries": [
{
"type": "subcategory",
"name": "GeneralEquip",
"cwId": 57
},
{
"type": "group",
"name": "Network",
"subcategories": [
{ "name": "Network-Other", "cwId": 174 },
{ "name": "Network-Router", "cwId": 119 },
{ "name": "Network-Switch", "cwId": 112 },
{ "name": "Network-Wireless", "cwId": 111 }
]
}
]
}
],
"ecosystems": [
{
"name": "Networking",
"manufacturers": [
{
"name": "Ubiquiti",
"cwId": 248,
"category": "Technology",
"subcategoryPrefix": "Network-"
},
{
"name": "TP-Link",
"cwId": 259,
"category": "Technology",
"subcategoryPrefix": "Network-"
}
]
}
]
},
"successful": true
}
Get Filter Values
GET /procurement/filters
Fetch the distinct values available for filter dropdowns (categories, subcategories, manufacturers) in the current dataset. Optionally scope the results with category/subcategory filters to cascade dependent dropdowns.
Authentication Required: Yes
Required Permissions: procurement.catalog.fetch.many
Query Parameters:
category(optional) — Scope subcategories and manufacturers to items in this category (accepts CW category name or CW ID)subcategory(optional) — Scope manufacturers to items in this subcategory (accepts CW subcategory name or CW ID)includeInactive(optional, defaultfalse) — Include inactive catalog items
Response:
{
"status": 200,
"message": "Available filter values fetched successfully!",
"data": {
"categories": ["Field", "General", "Technology"],
"subcategories": [
"Network-Other",
"Network-Router",
"Network-Switch",
"Network-Wireless"
],
"manufacturers": ["TP-Link", "Ubiquiti"]
},
"successful": true
}
Sales Routes
Sales routes serve opportunity data stored locally and synced from ConnectWise. All opportunity responses include hydrated company data (address, contacts) and an activities array. Single-opportunity fetches additionally include full site details (address, phone, flags). Sub-resource routes (products, notes, contacts) return data keyed by the opportunity's CW ID.
Caching: Most CW data for opportunities is served from a Redis cache that is proactively warmed by a background refresh cycle (every 30 seconds). This includes opportunity CW data, activities, notes, contacts, products, and company data. Cache TTLs are adaptive — recently updated opportunities have shorter TTLs (30s–60s) for fresher data, while inactive opportunities use longer TTLs (5–15 minutes). If a cache miss occurs at request time, data is fetched live from CW and cached. See CACHING.md for full details on TTL algorithms, background refresh mechanics, and debugging tools.
Get Opportunity Types
GET /sales/opportunity-types
Fetch the list of all opportunity quote statuses (types). Returns a static list of canonical quote statuses with their ConnectWise IDs and legacy Optima equivalency mappings.
Authentication Required: Yes
Required Permissions: sales.opportunity.fetch.many
Response:
{
"status": 200,
"message": "Opportunity Types Fetched Successfully!",
"data": [
{
"id": 51,
"name": "00. FutureLead",
"wonFlag": false,
"lostFlag": false,
"closedFlag": false,
"inactiveFlag": false,
"defaultFlag": false,
"enteredBy": "crobinso",
"dateEntered": "2023-07-11T23:13:19Z",
"_info": {
"lastUpdated": "2024-04-28T15:03:57Z",
"updatedBy": "crobinso"
},
"connectWiseId": "070f72a3-70d0-ef11-b2e0-000c29c55070",
"optimaEquivalency": [35, 36]
}
],
"successful": true
}
Get All Opportunities
GET /sales/opportunities
Fetch a paginated list of opportunities. Supports search. Each opportunity includes hydrated company data (with address and contacts from ConnectWise) when a linked company exists.
Authentication Required: Yes
Required Permissions: sales.opportunity.fetch.many
Field-Level Gating: obj.opportunity — see Object Type Field-Level Gating
Query Parameters:
page(optional, default1) — Page numberrpp(optional, default30) — Records per pagesearch(optional) — Search by opportunity nameincludeClosed(optional, defaultfalse) — Include closed opportunities in results
Response:
{
"status": 200,
"message": "Opportunities fetched successfully!",
"data": [
{
"id": "clx...",
"cwOpportunityId": 456,
"name": "Acme Corp Network Refresh",
"notes": "Full network redesign and hardware refresh",
"type": { "id": 1, "name": "New" },
"stage": { "id": 3, "name": "Proposal" },
"status": { "id": 1, "name": "Open" },
"priority": { "id": 2, "name": "High" },
"rating": { "id": 1, "name": "Hot" },
"source": "Referral",
"campaign": null,
"primarySalesRep": {
"id": 10,
"identifier": "JDoe",
"name": "John Doe"
},
"secondarySalesRep": null,
"company": {
"id": "clx...",
"name": "Acme Corp",
"cw_Identifier": "AcmeCorp",
"cw_CompanyId": 100,
"cw_Data": {
"address": {
"line1": "123 Main St",
"line2": null,
"city": "Murray",
"state": "Kentucky",
"zip": "42071",
"country": "United States"
},
"allContacts": [
{
"firstName": "Jane",
"lastName": "Smith",
"cwId": 200,
"inactive": false,
"title": "IT Manager",
"phone": "555-0100",
"email": "jane.smith@acme.com"
}
]
}
},
"contact": { "id": 200, "name": "Jane Smith" },
"site": { "id": 50, "name": "Main Office" },
"customerPO": null,
"totalSalesTax": 0,
"probability": 50,
"location": { "id": 1, "name": "Murray" },
"department": { "id": 5, "name": "Sales" },
"expectedCloseDate": "2026-04-15T00:00:00.000Z",
"pipelineChangeDate": "2026-02-20T00:00:00.000Z",
"dateBecameLead": "2026-01-10T00:00:00.000Z",
"closedDate": null,
"closedFlag": false,
"closedBy": null,
"companyId": "clx...",
"cwLastUpdated": "2026-02-26T10:00:00.000Z",
"productSequence": [31848, 31846, 31847],
"createdAt": "2026-02-01T00:00:00.000Z",
"updatedAt": "2026-02-26T10:00:00.000Z",
"customFields": [],
"activities": [
{
"cwActivityId": 789,
"name": "Follow-up Call",
"notes": "Discuss proposal details",
"type": { "id": 1, "name": "Call" },
"status": { "id": 1, "name": "Open" },
"company": {
"id": 100,
"identifier": "AcmeCorp",
"name": "Acme Corp"
},
"contact": { "id": 200, "name": "Jane Smith" },
"phoneNumber": "555-0100",
"email": "jane.smith@acme.com",
"opportunity": { "id": 456, "name": "Acme Corp Network Refresh" },
"ticket": null,
"agreement": null,
"campaign": null,
"assignTo": { "id": 10, "identifier": "JDoe", "name": "John Doe" },
"scheduleStatus": null,
"reminder": null,
"where": null,
"dateStart": "2026-03-01T10:00:00.000Z",
"dateEnd": "2026-03-01T10:30:00.000Z",
"notifyFlag": false,
"currency": null,
"mobileGuid": null,
"customFields": [],
"cwLastUpdated": "2026-02-26T10:00:00.000Z",
"cwDateEntered": "2026-02-20T09:00:00.000Z",
"cwEnteredBy": "JDoe",
"cwUpdatedBy": "JDoe"
}
]
}
],
"meta": {
"pagination": {
"previousPage": null,
"currentPage": 1,
"nextPage": 2,
"totalPages": 5,
"totalRecords": 150,
"listedRecords": 30
}
},
"successful": true
}
Get Opportunity Count
GET /sales/opportunities/count
Get the total number of opportunities.
Authentication Required: Yes
Required Permissions: sales.opportunity.fetch.many
Query Parameters:
openOnly(optional, defaultfalse) — Only count open (non-closed) opportunities
Response:
{
"status": 200,
"message": "Opportunity count fetched successfully!",
"data": {
"count": 150
},
"successful": true
}
Get Opportunity
GET /sales/opportunities/:identifier
Fetch a single opportunity by its internal ID or ConnectWise opportunity ID. The response includes hydrated company data (with address and contacts) and full site details (with address) when available. CW data (activities, company, site) is served from the Redis cache when available; on cache miss, data is fetched live from CW and cached with an adaptive TTL.
Authentication Required: Yes
Required Permissions: sales.opportunity.fetch
Field-Level Gating: obj.opportunity — see Object Type Field-Level Gating
Path Parameters:
identifier— Internal ID (cuid) or ConnectWise opportunity ID (numeric)
Query Parameters:
include(optional) — Comma-separated list of sub-resources to embed in the response. Supported values:notes,contacts,products,quotes. Example:?include=notes,contacts,products,quotes. Sub-resources are fetched in parallel and added as top-level keys on the response object. Whennotesis included,data.notesis returned as an array of note objects and the original opportunity text note is preserved underdata.opportunityNoteText. Whenquotesis included,data.quotesis returned as an array of committed quote metadata objects (PDF file data is excluded).includeRegenData(optional) — When"true", includesquoteRegenDataon each quote object (applies to?include=quotes). Default:false.includeRegenParams(optional) — When"true", includesquoteRegenParamson each quote object (applies to?include=quotes). Default:false.
Response:
{
"status": 200,
"message": "Opportunity fetched successfully!",
"data": {
"id": "clx...",
"cwOpportunityId": 456,
"name": "Acme Corp Network Refresh",
"notes": "Full network redesign and hardware refresh",
"type": { "id": 1, "name": "New" },
"stage": { "id": 3, "name": "Proposal" },
"status": { "id": 1, "name": "Open" },
"priority": { "id": 2, "name": "High" },
"rating": { "id": 1, "name": "Hot" },
"source": "Referral",
"campaign": null,
"primarySalesRep": {
"id": 10,
"identifier": "JDoe",
"name": "John Doe"
},
"secondarySalesRep": null,
"company": {
"id": "clx...",
"name": "Acme Corp",
"cw_Identifier": "AcmeCorp",
"cw_CompanyId": 100,
"cw_Data": {
"address": {
"line1": "123 Main St",
"line2": null,
"city": "Murray",
"state": "Kentucky",
"zip": "42071",
"country": "United States"
},
"allContacts": [
{
"firstName": "Jane",
"lastName": "Smith",
"cwId": 200,
"inactive": false,
"title": "IT Manager",
"phone": "555-0100",
"email": "jane.smith@acme.com"
}
]
}
},
"contact": { "id": 200, "name": "Jane Smith" },
"site": {
"id": 50,
"name": "Main Office",
"address": {
"line1": "123 Main St",
"line2": null,
"city": "Murray",
"state": "Kentucky",
"zip": "42071",
"country": "United States"
},
"phoneNumber": "555-0100",
"faxNumber": null,
"primaryAddressFlag": true,
"defaultShippingFlag": true,
"defaultBillingFlag": true,
"defaultMailingFlag": true
},
"customerPO": null,
"totalSalesTax": 0,
"probability": 50,
"location": { "id": 1, "name": "Murray" },
"department": { "id": 5, "name": "Sales" },
"expectedCloseDate": "2026-04-15T00:00:00.000Z",
"pipelineChangeDate": "2026-02-20T00:00:00.000Z",
"dateBecameLead": "2026-01-10T00:00:00.000Z",
"closedDate": null,
"closedFlag": false,
"closedBy": null,
"companyId": "clx...",
"cwLastUpdated": "2026-02-26T10:00:00.000Z",
"productSequence": [31848, 31846, 31847],
"createdAt": "2026-02-01T00:00:00.000Z",
"updatedAt": "2026-02-26T10:00:00.000Z",
"customFields": [],
"activities": [
{
"cwActivityId": 789,
"name": "Follow-up Call",
"notes": "Discuss proposal details",
"type": { "id": 1, "name": "Call" },
"status": { "id": 1, "name": "Open" },
"company": { "id": 100, "identifier": "AcmeCorp", "name": "Acme Corp" },
"contact": { "id": 200, "name": "Jane Smith" },
"phoneNumber": "555-0100",
"email": "jane.smith@acme.com",
"opportunity": { "id": 456, "name": "Acme Corp Network Refresh" },
"ticket": null,
"agreement": null,
"campaign": null,
"assignTo": { "id": 10, "identifier": "JDoe", "name": "John Doe" },
"scheduleStatus": null,
"reminder": null,
"where": null,
"dateStart": "2026-03-01T10:00:00.000Z",
"dateEnd": "2026-03-01T10:30:00.000Z",
"notifyFlag": false,
"currency": null,
"mobileGuid": null,
"customFields": [],
"cwLastUpdated": "2026-02-26T10:00:00.000Z",
"cwDateEntered": "2026-02-20T09:00:00.000Z",
"cwEnteredBy": "JDoe",
"cwUpdatedBy": "JDoe"
}
]
},
"successful": true
}
Refresh Opportunity
POST /sales/opportunities/:identifier/refresh
Refresh an opportunity's local data by fetching the latest from ConnectWise. The response includes hydrated company data and site details.
Authentication Required: Yes
Required Permissions: sales.opportunity.refresh
Path Parameters:
identifier— Internal ID (cuid) or ConnectWise opportunity ID (numeric)
Response:
{
"status": 200,
"message": "Opportunity refreshed from ConnectWise successfully!",
"data": {
"id": "clx...",
"cwOpportunityId": 456,
"name": "Acme Corp Network Refresh",
"notes": "Updated notes from CW",
"type": { "id": 1, "name": "New" },
"stage": { "id": 4, "name": "Negotiation" },
"status": { "id": 1, "name": "Open" },
"priority": { "id": 2, "name": "High" },
"rating": { "id": 1, "name": "Hot" },
"source": "Referral",
"campaign": null,
"primarySalesRep": {
"id": 10,
"identifier": "JDoe",
"name": "John Doe"
},
"secondarySalesRep": null,
"company": {
"id": "clx...",
"name": "Acme Corp",
"cw_Identifier": "AcmeCorp",
"cw_CompanyId": 100,
"cw_Data": {
"address": {
"line1": "123 Main St",
"line2": null,
"city": "Murray",
"state": "Kentucky",
"zip": "42071",
"country": "United States"
},
"allContacts": [
{
"firstName": "Jane",
"lastName": "Smith",
"cwId": 200,
"inactive": false,
"title": "IT Manager",
"phone": "555-0100",
"email": "jane.smith@acme.com"
}
]
}
},
"contact": { "id": 200, "name": "Jane Smith" },
"site": { "id": 50, "name": "Main Office" },
"customerPO": null,
"totalSalesTax": 0,
"probability": 50,
"location": { "id": 1, "name": "Murray" },
"department": { "id": 5, "name": "Sales" },
"expectedCloseDate": "2026-04-15T00:00:00.000Z",
"pipelineChangeDate": "2026-02-25T00:00:00.000Z",
"dateBecameLead": "2026-01-10T00:00:00.000Z",
"closedDate": null,
"closedFlag": false,
"closedBy": null,
"companyId": "clx...",
"cwLastUpdated": "2026-02-26T14:00:00.000Z",
"createdAt": "2026-02-01T00:00:00.000Z",
"updatedAt": "2026-02-26T14:00:00.000Z",
"customFields": [],
"activities": [
{
"cwActivityId": 789,
"name": "Follow-up Call",
"notes": "Discuss proposal details",
"type": { "id": 1, "name": "Call" },
"status": { "id": 1, "name": "Open" },
"company": { "id": 100, "identifier": "AcmeCorp", "name": "Acme Corp" },
"contact": { "id": 200, "name": "Jane Smith" },
"phoneNumber": "555-0100",
"email": "jane.smith@acme.com",
"opportunity": { "id": 456, "name": "Acme Corp Network Refresh" },
"ticket": null,
"agreement": null,
"campaign": null,
"assignTo": { "id": 10, "identifier": "JDoe", "name": "John Doe" },
"scheduleStatus": null,
"reminder": null,
"where": null,
"dateStart": "2026-03-01T10:00:00.000Z",
"dateEnd": "2026-03-01T10:30:00.000Z",
"notifyFlag": false,
"currency": null,
"mobileGuid": null,
"customFields": [],
"cwLastUpdated": "2026-02-26T10:00:00.000Z",
"cwDateEntered": "2026-02-20T09:00:00.000Z",
"cwEnteredBy": "JDoe",
"cwUpdatedBy": "JDoe"
}
]
},
"successful": true
}
Create Opportunity
POST /sales/opportunities
Create a new opportunity in ConnectWise. The created opportunity is synced to the local database and returned in the response. name and expectedCloseDate are required; all other fields are optional.
Authentication Required: Yes
Required Permissions: sales.opportunity.create
Request Body:
{
"name": "Acme Corp Network Refresh",
"expectedCloseDate": "2026-06-01",
"notes": "Initial scoping phase",
"rating": { "id": 1 },
"type": { "id": 2 },
"stage": { "id": 1 },
"status": { "id": 1 },
"priority": { "id": 2 },
"campaign": { "id": 5 },
"primarySalesRep": { "id": 10 },
"secondarySalesRep": { "id": 12 },
"company": { "id": 100 },
"contact": { "id": 200 },
"site": { "id": 50 },
"source": "Referral",
"customerPO": "PO-12345",
"locationId": 1,
"businessUnitId": 5
}
| Field | Type | Required | Description |
|---|---|---|---|
name |
string |
Yes | Opportunity name |
expectedCloseDate |
string |
Yes | Expected close date (date string, e.g. 2026-06-01) |
primarySalesRep |
{ id: number } |
Yes | CW member reference for primary sales rep |
company |
{ id: number } |
Yes | CW company reference |
contact |
{ id: number } |
Yes | CW contact reference |
notes |
string |
No | Opportunity description / notes |
rating |
{ id: number } |
No | CW rating reference |
type |
{ id: number } |
No | CW opportunity type reference |
stage |
{ id: number } |
No | CW pipeline stage reference |
status |
{ id: number } |
No | CW status reference |
priority |
{ id: number } |
No | CW priority reference |
campaign |
{ id: number } |
No | CW campaign reference |
secondarySalesRep |
{ id: number } | null |
No | CW member reference for secondary sales rep (null clears) |
site |
{ id: number } | null |
No | CW site reference (null clears) |
source |
string | null |
No | Opportunity source (null clears) |
customerPO |
string | null |
No | Customer PO number (null clears) |
locationId |
number |
No | CW location ID |
businessUnitId |
number |
No | CW business unit ID |
Response (201):
{
"status": 201,
"message": "Opportunity created successfully!",
"data": {
"id": "clx...",
"cwOpportunityId": 789,
"name": "Acme Corp Network Refresh",
"notes": "Initial scoping phase",
"type": { "id": 2, "name": "Existing" },
"stage": { "id": 1, "name": "Prospect" },
"status": { "id": 1, "name": "Open" },
"priority": { "id": 2, "name": "High" },
"rating": { "id": 1, "name": "Hot" },
"source": "Referral",
"campaign": { "id": 5, "name": "Q2 Push" },
"primarySalesRep": {
"id": 10,
"identifier": "JDoe",
"name": "John Doe"
},
"secondarySalesRep": {
"id": 12,
"identifier": "ASmith",
"name": "Alice Smith"
},
"company": { "id": 100, "name": "Acme Corp" },
"contact": { "id": 200, "name": "Jane Smith" },
"site": { "id": 50, "name": "Main Office" },
"customerPO": "PO-12345",
"totalSalesTax": 0,
"probability": 0,
"location": { "id": 1, "name": "Murray" },
"department": null,
"expectedCloseDate": "2026-06-01T00:00:00.000Z",
"pipelineChangeDate": null,
"dateBecameLead": null,
"closedDate": null,
"closedFlag": false,
"closedBy": null,
"companyId": "clx...",
"cwLastUpdated": "2026-03-07T10:00:00.000Z",
"createdAt": "2026-03-07T10:00:00.000Z",
"updatedAt": "2026-03-07T10:00:00.000Z",
"customFields": [],
"activities": []
},
"successful": true
}
Error Responses:
| Status | Scenario |
|---|---|
| 400 | Zod validation failure (missing name/expectedCloseDate) |
| 401 | Missing or invalid auth token |
| 403 | User lacks sales.opportunity.create permission |
| 4xx/5xx | ConnectWise API error (forwarded status + message) |
| 500 | Unexpected server error |
Update Opportunity
PATCH /sales/opportunities/:identifier
Update an opportunity's fields in ConnectWise (e.g. rating, sales rep, company, contact, site, description). Only the provided fields are patched; omitted fields remain unchanged. The updated opportunity is synced back to the local database and returned in the response.
Authentication Required: Yes
Required Permissions: sales.opportunity.update
Path Parameters:
identifier— Internal ID (cuid) or ConnectWise opportunity ID (numeric)
Request Body (all fields optional, at least one required):
{
"name": "Acme Corp Network Refresh — Phase 2",
"notes": "Updated project scope to include wireless",
"rating": { "id": 1 },
"type": { "id": 2 },
"stage": { "id": 4 },
"status": { "id": 1 },
"priority": { "id": 2 },
"campaign": { "id": 5 },
"primarySalesRep": { "id": 10 },
"secondarySalesRep": { "id": 12 },
"company": { "id": 100 },
"contact": { "id": 200 },
"site": { "id": 50 },
"expectedCloseDate": "2026-05-01",
"customerPO": "PO-12345",
"source": "Referral",
"locationId": 1,
"businessUnitId": 5
}
| Field | Type | Description |
|---|---|---|
name |
string |
Opportunity name |
notes |
string |
Opportunity description / notes |
rating |
{ id: number } |
CW rating reference |
type |
{ id: number } |
CW opportunity type reference |
stage |
{ id: number } |
CW pipeline stage reference |
status |
{ id: number } |
CW status reference |
priority |
{ id: number } |
CW priority reference |
campaign |
{ id: number } |
CW campaign reference |
primarySalesRep |
{ id: number } |
CW member reference for primary sales rep |
secondarySalesRep |
{ id: number } | null |
CW member reference for secondary sales rep (null clears) |
company |
{ id: number } |
CW company reference |
contact |
{ id: number } | null |
CW contact reference (null clears) |
site |
{ id: number } | null |
CW site reference (null clears) |
expectedCloseDate |
string |
Expected close date (ISO date string) |
customerPO |
string | null |
Customer PO number (null clears) |
source |
string | null |
Opportunity source (null clears) |
locationId |
number |
CW location ID |
businessUnitId |
number |
CW business unit ID |
Response:
{
"status": 200,
"message": "Opportunity updated successfully!",
"data": {
"id": "clx...",
"cwOpportunityId": 456,
"name": "Acme Corp Network Refresh — Phase 2",
"description": "Updated project scope to include wireless",
"type": { "id": 2, "name": "Existing" },
"stage": { "id": 4, "name": "Negotiation" },
"status": { "id": 1, "name": "Open" },
"priority": { "id": 2, "name": "High" },
"rating": { "id": 1, "name": "Hot" },
"source": "Referral",
"campaign": { "id": 5, "name": "Q2 Push" },
"primarySalesRep": {
"id": 10,
"identifier": "JDoe",
"name": "John Doe"
},
"secondarySalesRep": {
"id": 12,
"identifier": "ASmith",
"name": "Alice Smith"
},
"company": { "id": 100, "name": "Acme Corp" },
"contact": { "id": 200, "name": "Jane Smith" },
"site": { "id": 50, "name": "Main Office" },
"customerPO": "PO-12345",
"totalSalesTax": 0,
"probability": 50,
"location": { "id": 1, "name": "Murray" },
"department": { "id": 5, "name": "Sales" },
"expectedCloseDate": "2026-05-01T00:00:00.000Z",
"pipelineChangeDate": "2026-02-25T00:00:00.000Z",
"dateBecameLead": "2026-01-10T00:00:00.000Z",
"closedDate": null,
"closedFlag": false,
"closedBy": null,
"companyId": "clx...",
"cwLastUpdated": "2026-03-07T10:00:00.000Z",
"createdAt": "2026-02-01T00:00:00.000Z",
"updatedAt": "2026-03-07T10:00:00.000Z",
"customFields": [],
"activities": []
},
"successful": true
}
Delete Opportunity
DELETE /sales/opportunities/:identifier
Delete an opportunity from ConnectWise and the local database. All related Redis caches (activities, notes, contacts, products, CW data) are invalidated.
Authentication Required: Yes
Required Permissions: sales.opportunity.delete
Path Parameters:
identifier— Internal ID (cuid) or ConnectWise opportunity ID (numeric)
Response:
{
"status": 200,
"message": "Opportunity deleted successfully!",
"successful": true
}
| Status | Description |
|---|---|
| 200 | Opportunity deleted successfully |
| 401 | Missing or invalid auth token |
| 403 | User lacks sales.opportunity.delete permission |
| 404 | Opportunity not found |
| 4xx/5xx | ConnectWise API error (forwarded status + message) |
| 500 | Unexpected server error |
Get Opportunity Products
GET /sales/opportunities/:identifier/products
Fetch products for an opportunity, scoped to items that have a matching ConnectWise procurement product (forecastDetailId link). Data is served from the Redis cache when available; on cache miss, data is fetched live from ConnectWise and cached. Hot opportunities (updated within 3 days) have a 15-second TTL; others use a 30-minute lazy TTL. Products are returned sorted by the opportunity's local productSequence array when set; otherwise, items are sorted by their ConnectWise sequenceNumber.
Authentication Required: Yes
Required Permissions: sales.opportunity.fetch
Path Parameters:
identifier— Internal ID (cuid) or ConnectWise opportunity ID (numeric)
Response:
{
"status": 200,
"message": "Opportunity products fetched successfully!",
"data": [
{
"id": 31846,
"forecastDescription": "Service",
"opportunity": { "id": 5150, "name": "Example Opportunity" },
"quantity": 1,
"status": { "id": 24, "name": "01. New" },
"cancelled": false,
"cancellationType": null,
"quantityCancelled": 0,
"cancelledReason": null,
"cancelledDate": null,
"catalogItem": {
"id": 3756,
"identifier": "Labor & Installation - Field"
},
"productDescription": "Labor & Installation - Field",
"productClass": "Service",
"forecastType": "Service",
"revenue": 650000,
"cost": 0,
"margin": 650000,
"profit": 650000,
"percentage": 100,
"includeFlag": true,
"linkFlag": true,
"recurringFlag": false,
"taxableFlag": true,
"recurringRevenue": 0,
"recurringCost": 0,
"cycles": 0,
"sequenceNumber": 1,
"subNumber": 0,
"cwLastUpdated": "2026-02-28T20:57:52.000Z",
"cwUpdatedBy": "jroberts",
"onHand": 12,
"inStock": true
}
],
"successful": true
}
Cancellation Fields:
Product cancellation data is sourced from the ConnectWise procurement products endpoint (not the forecast endpoint). Each product includes:
| Field | Type | Description |
|---|---|---|
cancelled |
boolean | Whether the product has been cancelled (fully or partially) |
cancellationType |
string/null | "full" if all units cancelled, "partial" if some cancelled, null if not cancelled |
quantityCancelled |
number | Number of units cancelled |
cancelledReason |
string/null | Reason for cancellation (if provided) |
cancelledDate |
string/null | ISO 8601 timestamp of when the item was cancelled |
Inventory Fields:
Internal inventory data is sourced from the local CatalogItem database. If the product's catalog item exists locally, these fields are populated; otherwise they are null.
| Field | Type | Description |
|---|---|---|
onHand |
number/null | Number of units currently on hand in local inventory |
inStock |
boolean/null | Whether the item is in stock (onHand > 0) |
Resequence Opportunity Products
PATCH /sales/opportunities/:identifier/products/sequence
Update the display order of products (forecast items) on an opportunity. The sequence is stored locally in the database (productSequence field on the Opportunity model) — no modifications are made to ConnectWise. This means forecast item IDs remain stable and procurement product linkages are unaffected.
When a productSequence is set, GET .../products returns items in that order. Any forecast items not included in the array (e.g. newly added items) are appended at the end in CW sequenceNumber order.
Authentication Required: Yes
Required Permissions: sales.opportunity.product.update
Path Parameters:
identifier— Internal ID (cuid) or ConnectWise opportunity ID (numeric)
Request Body:
{
"orderedIds": [31848, 31846, 31847]
}
orderedIds— Array of CW forecast item IDs in the desired display order. All IDs must exist on the opportunity's forecast in ConnectWise.
Response:
{
"status": 200,
"message": "Product sequence updated successfully!",
"data": {
"products": [
{
"id": 31848,
"forecastDescription": "Hardware",
"sequenceNumber": 3,
"..."
},
{
"id": 31846,
"forecastDescription": "Service",
"sequenceNumber": 1,
"..."
},
{
"id": 31847,
"forecastDescription": "Licensing",
"sequenceNumber": 2,
"..."
}
]
},
"successful": true
}
data.products— Full product objects in the new display order. IDs are unchanged — CWsequenceNumberstill reflects the original CW order, but the array order matches the locally stored sequence.
Edit Opportunity Product
PATCH /sales/opportunities/:identifier/products/:productId/edit
Edit a product line item on an opportunity. This route supports forecast-backed fields and procurement-backed fields (including custom fields for narrative/notes).
Authentication Required: Yes
Required Permissions: sales.opportunity.product.update
Path Parameters:
identifier— Internal ID (cuid) or ConnectWise opportunity ID (numeric)productId— Forecast item ID (numeric)
Request Body:
At least one field is required.
{
"productDescription": "Labor & Installation - Field (Updated)",
"quantity": 2,
"unitPrice": 125,
"unitCost": 62.5,
"customerDescription": "Onsite labor for rack install",
"productNarrative": "Install, cable, and validate cutover",
"procurementNotes": "Coordinate site contact before arrival"
}
| Field | Type | Description |
|---|---|---|
productDescription |
string | Product description |
quantity |
number | Quantity |
unitPrice |
number | Unit price (maps to procurement price, forecast revenue) |
unitCost |
number | Unit cost (maps to procurement cost, forecast cost) |
customerDescription |
string | Customer-facing description |
productNarrative |
string | Custom field Product Narrative (id: 46) |
procurementNotes |
string | Custom field Procurement Notes (id: 29) |
Response:
{
"status": 200,
"message": "Product updated successfully!",
"data": {
"id": 32281,
"productDescription": "Labor & Installation - Field (Updated)",
"quantity": 2,
"unitPrice": 125,
"unitCost": 62.5,
"customerDescription": "Onsite labor for rack install",
"productNarrative": "Install, cable, and validate cutover",
"procurementNotes": "Coordinate site contact before arrival"
},
"successful": true
}
Cancel / Uncancel Opportunity Product
PATCH /sales/opportunities/:identifier/products/:productId/cancel
Set cancellation state for a product line item using procurement cancellation fields.
quantityCancelled = 0→ item is treated as uncancelledquantityCancelled > 0 && < quantity→ partial cancellationquantityCancelled >= quantity→ full cancellation
Authentication Required: Yes
Required Permissions: sales.opportunity.product.update
Path Parameters:
identifier— Internal ID (cuid) or ConnectWise opportunity ID (numeric)productId— Forecast item ID (numeric)
Request Body:
{
"quantityCancelled": 1,
"cancellationReason": "Out of stock"
}
| Field | Type | Required | Description |
|---|---|---|---|
quantityCancelled |
number (integer) | Yes | Number of units to cancel. Use 0 to uncancel the line item. |
cancellationReason |
string | null | No | Optional reason that is passed through to ConnectWise. |
Validation Rules:
quantityCancelledmust be >=0quantityCancelledcannot exceed the line item's quantity
Response:
{
"status": 200,
"message": "Product cancellation updated successfully!",
"data": {
"id": 32281,
"quantity": 2,
"cancelled": true,
"cancellationType": "partial",
"quantityCancelled": 1,
"cancelledReason": "Out of stock",
"cancelledDate": "2026-03-04T00:00:00.000Z"
},
"successful": true
}
Delete Product from Opportunity
DELETE /sales/opportunities/:identifier/products/:productId
Remove a forecast item (product) from an opportunity in ConnectWise. The item is also removed from the local productSequence array and the products cache is invalidated.
Authentication Required: Yes
Required Permissions: sales.opportunity.product.delete
Path Parameters:
identifier— Internal ID (cuid) or ConnectWise opportunity ID (numeric)productId— ConnectWise forecast item ID (positive integer)
Response:
{
"status": 200,
"message": "Product deleted from opportunity successfully!",
"successful": true
}
| Status | Description |
|---|---|
| 200 | Product deleted successfully |
| 400 | Invalid productId |
| 401 | Missing or invalid auth token |
| 403 | User lacks sales.opportunity.product.delete permission |
| 404 | Opportunity or forecast item not found |
| 4xx/5xx | ConnectWise API error (forwarded status + message) |
| 500 | Unexpected server error |
Add Product to Opportunity
POST /sales/opportunities/:identifier/products
Add a new product (forecast item) to an opportunity in ConnectWise. The request body is validated with Zod, then each submitted field is gated against sales.opportunity.product.field.<field> permissions — only fields the user has permission for are forwarded to ConnectWise.
After creation, the new forecast item ID is appended to the opportunity's local productSequence array in the database (if not already present) so display ordering remains stable.
Authentication Required: Yes
Required Permissions: sales.opportunity.product.add
Field-Level Permission Gating: Yes — uses processObjectValuePerms with scope sales.opportunity.product.field on the input body. See the field-level permissions table under sales.opportunity.product.add in PERMISSIONS.md.
Path Parameters:
identifier— Internal ID (cuid) or ConnectWise opportunity ID (numeric)
Request Body:
All fields are optional. Only fields the user has the corresponding sales.opportunity.product.field.<field> permission for will be sent to ConnectWise.
{
"catalogItem": { "id": 1234 },
"forecastDescription": "Managed Services",
"productDescription": "Monthly managed services agreement",
"quantity": 1,
"status": { "id": 1 },
"productClass": "Agreement",
"forecastType": "Product",
"revenue": 500.0,
"cost": 250.0,
"includeFlag": true,
"linkFlag": false,
"recurringFlag": true,
"taxableFlag": true,
"recurringRevenue": 500.0,
"recurringCost": 250.0,
"cycles": 12,
"sequenceNumber": 1
}
| Field | Type | Description |
|---|---|---|
catalogItem |
{ id: number } |
ConnectWise catalog item reference |
forecastDescription |
string | Forecast description text |
productDescription |
string | Product description text |
quantity |
number | Quantity (must be positive) |
status |
{ id: number } |
ConnectWise status reference |
productClass |
string | Product class (e.g. Product, Service, Agreement) |
forecastType |
string | Forecast type |
revenue |
number | Revenue amount |
cost |
number | Cost amount |
includeFlag |
boolean | Whether to include in forecast totals |
linkFlag |
boolean | Whether the item is linked |
recurringFlag |
boolean | Whether this is a recurring item |
taxableFlag |
boolean | Whether this item is taxable |
recurringRevenue |
number | Recurring revenue amount |
recurringCost |
number | Recurring cost amount |
cycles |
number | Number of recurring cycles (integer, min 0) |
sequenceNumber |
number | Display sequence number (integer, min 0) |
Response:
{
"status": 201,
"message": "Product added to opportunity successfully!",
"data": {
"id": 31855,
"forecastDescription": "Managed Services",
"opportunity": { "id": 5678, "name": "Example Opportunity" },
"quantity": 1,
"status": { "id": 1, "name": "Open" },
"catalogItem": { "id": 1234, "identifier": "MSP-001" },
"productDescription": "Monthly managed services agreement",
"productClass": "Agreement",
"forecastType": "Product",
"revenue": 500.0,
"cost": 250.0,
"margin": 250.0,
"profit": 250.0,
"percentage": 0,
"includeFlag": true,
"linkFlag": false,
"recurringFlag": true,
"taxableFlag": true,
"recurringRevenue": 500.0,
"recurringCost": 250.0,
"cycles": 12,
"sequenceNumber": 1,
"subNumber": 0,
"cwLastUpdated": "2026-03-01T00:00:00.000Z",
"cwUpdatedBy": "Admin1",
"cancelled": false,
"cancellationType": null,
"quantityCancelled": 0,
"cancelledReason": null,
"cancelledDate": null,
"onHand": null,
"inStock": null
},
"successful": true
}
Add SPECIAL ORDER Product
POST /sales/opportunities/:identifier/products/special-order
Add one or more products as SPECIAL ORDER procurement line items. This route creates ConnectWise procurement products (not forecast items) and enforces stable defaults for quick-entry workflows.
Authentication Required: Yes
Required Permissions: sales.opportunity.product.add.specialOrder
Path Parameters:
identifier— Internal ID (cuid) or ConnectWise opportunity ID (numeric)
Request Body:
Accepts either a single object or an array of objects.
{
"desc": "SPECIAL ORDER - Lead time confirmed",
"customerDesc": "Customer-facing special order description",
"qty": 1,
"price": 750,
"cost": 500,
"taxable": true,
"procurementNotes": "Vendor ETA pending confirmation",
"productNarrative": "Install with existing rack accessories"
}
| Field | Type | Required | Description |
|---|---|---|---|
desc |
string | Yes | Internal/sales line description |
customerDesc |
string | No | Customer-facing line description |
qty |
number | No | Quantity (defaults to 1 when omitted) |
price |
number | Yes | Revenue amount |
cost |
number | No | Cost amount |
taxable |
boolean | No | Taxable flag (defaults to the catalog item's tax setting) |
procurementNotes |
string | No | Maps to custom field id: 29 |
productNarrative |
string | No | Maps to custom field id: 46 |
Route-Enforced Defaults:
catalogItemis always set to the canonical catalog item with identifierSPECIAL ORDERdescriptionis always set fromdesccustomerDescriptionis always set fromcustomerDescwhen providedquantityis always set fromqty(default1)priceis always set frompricecostis always set fromcostwhen provideddropshipFlagis always set tofalsebillableOptionis always set toBillabletaxableFlagis set fromtaxable(ortaxableFlag), defaulting to the catalog item'ssalesTaxablevaluecustomFieldsare auto-built when notes are provided:procurementNotes→Procurement Notes(id: 29)productNarrative→Product Narrative(id: 46)
- When CW returns
forecastDetailId, it is appended to the opportunity's localproductSequencearray in the database (if not already present)
Response:
{
"status": 201,
"message": "Special-order product added successfully!",
"data": {
"id": 88340,
"forecastDetailId": 32280,
"description": "SPECIAL ORDER - Lead time confirmed",
"customerDescription": "Customer-facing special order description",
"quantity": 1,
"price": 750,
"cost": 500,
"taxableFlag": true
},
"successful": true
}
Get Labor Product Options
GET /sales/opportunities/:identifier/products/labor/options
Fetch the resolved Field and Tech labor catalog products plus default labor pricing metadata so the UI can hydrate the labor-entry form without hardcoding catalog IDs.
Authentication Required: Yes
Required Permissions: sales.opportunity.product.add.labor
Path Parameters:
identifier— Internal ID (cuid) or ConnectWise opportunity ID (numeric)
Response:
{
"status": 200,
"message": "Labor product options fetched successfully!",
"data": {
"defaults": {
"customerType": "corporate",
"rates": {
"corporate": 100,
"residential": 85
},
"cpuMultiplier": 0.5,
"quantity": 1
},
"options": {
"field": {
"cwCatalogId": 3756,
"identifier": "Labor & Installation - Field",
"name": "Labor & Installation - Field",
"taxableFlag": true
},
"tech": {
"cwCatalogId": 3757,
"identifier": "Labor & Installation - Tech",
"name": "Labor & Installation - Tech",
"taxableFlag": true
}
}
},
"successful": true
}
Add Labor Product
POST /sales/opportunities/:identifier/products/labor
Add a labor line item to an opportunity using one of the two canonical labor catalog products (Field or Tech). The route resolves both labor products from the local catalog, then picks the selected style and creates a ConnectWise procurement product.
Authentication Required: Yes
Required Permissions: sales.opportunity.product.add.labor
Path Parameters:
identifier— Internal ID (cuid) or ConnectWise opportunity ID (numeric)
Request Body:
{
"laborStyle": "field",
"customerType": "corporate",
"hours": 1,
"taxable": true,
"rate": 100,
"ppu": 100,
"cpu": 50,
"procurementNotes": "Schedule with PM before install",
"productNarrative": "Install and validate onsite",
"customerDescription": "Onsite installation labor"
}
| Field | Type | Required | Description |
|---|---|---|---|
laborStyle |
"field" | "tech" |
Yes | Chooses which labor catalog product to use |
customerType |
"corporate" | "residential" |
No | Selects default rate (corporate=100, residential=85) when rate omitted |
hours |
number | No | Quantity/hours (defaults to 1) |
rate |
number | No | Hourly labor rate used when ppu is not provided |
ppu |
number | No | Price per unit/hour (overrides rate) |
cpu |
number | No | Cost per unit/hour (defaults to 50% of selected price) |
taxable |
boolean | No | Taxable flag override |
taxableFlag |
boolean | No | Alternate taxable flag input (same behavior as taxable) |
description |
string | No | Internal line description override |
customerDescription |
string | No | Customer-facing description |
procurementNotes |
string | No | Maps to custom field Procurement Notes (id: 29) |
productNarrative |
string | No | Maps to custom field Product Narrative (id: 46) |
Route-Enforced Defaults / Behavior:
- Resolves both labor products from local catalog and uses
laborStyleto select one - Uses
customerTypedefaults whenrate/ppu/cpuare not supplied - Sets
quantityfromhours(default1) - Sets
pricefrom selectedppu,costfrom selectedcpu - Sets
dropshipFlagtofalseandbillableOptiontoBillable - Sets taxable flag from
taxable/taxableFlag, falling back to the selected catalog item's tax setting - When CW returns
forecastDetailId, it is appended to the opportunity's localproductSequencearray in the database (if not already present)
Response:
{
"status": 201,
"message": "Labor added to opportunity successfully!",
"data": {
"id": 88341,
"forecastDetailId": 32281,
"laborStyle": "field",
"customerType": "corporate",
"catalogItem": {
"id": 3756,
"identifier": "Labor & Installation - Field",
"name": "Labor & Installation - Field"
},
"description": "Labor & Installation - Field",
"customerDescription": "Onsite installation labor",
"quantity": 1,
"rate": 100,
"ppu": 100,
"cpu": 50,
"revenue": 100,
"cost": 50,
"taxableFlag": true,
"procurementNotes": "Schedule with PM before install",
"productNarrative": "Install and validate onsite"
},
"successful": true
}
Fetch Committed Quotes
GET /sales/opportunities/:identifier/quotes
Fetch all committed (finalized) quotes for an opportunity, ordered by most recent first.
Authentication Required: Yes
Required Permissions: sales.opportunity.quote.fetch
Path Parameters:
identifier— Internal ID (cuid) or ConnectWise opportunity ID (numeric)
Query Parameters:
includeRegenData(optional) — When"true", includesquoteRegenDataon each quote object. Default:false.includeRegenParams(optional) — When"true", includesquoteRegenParamson each quote object. Default:false.
Response:
{
"status": 200,
"message": "Committed quotes fetched successfully!",
"data": [
{
"id": "clx9abc123...",
"quoteFileName": "OPP-12345-2026-03-06T12-00-00-000Z.pdf",
"quoteRegenHash": "a1b2c3d4e5f6...",
"opportunityId": "clx9xyz789...",
"createdById": "clx9user456...",
"quoteRegenData": "(included when ?includeRegenData=true)",
"quoteRegenParams": "(included when ?includeRegenParams=true)",
"createdAt": "2026-03-06T12:00:00.000Z",
"updatedAt": "2026-03-06T12:00:00.000Z"
}
],
"successful": true
}
Commit Quote
POST /sales/opportunities/:identifier/quote/commit
Generate a finalized (non-preview) quote PDF for an opportunity and store it in the database with regeneration metadata and creator attribution.
Authentication Required: Yes
Required Permissions: sales.opportunity.quote.commit
Path Parameters:
identifier— Internal ID (cuid) or ConnectWise opportunity ID (numeric)
Request Body (optional):
{
"lineItemPricing": true,
"includeQuoteNarrative": true,
"includeItemNarratives": true
}
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
lineItemPricing |
boolean | No | true |
Include per-line-item pricing in the quote PDF |
includeQuoteNarrative |
boolean | No | true |
Include the quote-level narrative section |
includeItemNarratives |
boolean | No | true |
Include per-item narrative sections |
Response:
{
"status": 201,
"message": "Quote committed successfully!",
"data": {
"id": "clx9abc123...",
"quoteFileName": "OPP-12345-2026-03-06T12-00-00-000Z.pdf",
"quoteRegenHash": "a1b2c3d4e5f6...",
"opportunityId": "clx9xyz789...",
"createdById": "clx9user456...",
"quoteRegenData": {
"options": {
"lineItemPricing": true,
"includeQuoteNarrative": true,
"includeItemNarratives": true
},
"opportunity": {
"id": "clx9xyz789...",
"cwOpportunityId": 12345,
"name": "Network Refresh – Acme Corp",
"totalSalesTax": 245.5,
"contactName": "Jane Smith",
"companyName": "Acme Corp"
},
"customer": {
"preparedFor": "Jane Smith",
"companyName": "Acme Corp",
"primaryContact": {
"firstName": "Jane",
"lastName": "Smith",
"email": "jane@acme.com",
"phone": "801-555-1234"
},
"siteAddress": ["123 Main St", "Salt Lake City UT 84101"],
"companyAddress": ["456 Corporate Blvd", "Murray UT 84107"]
},
"salesRep": {
"name": "John Roberts",
"email": "jroberts@example.com"
},
"quoteNarrative": "Full network infrastructure refresh including...",
"products": [
{
"cwForecastId": 101,
"forecastDescription": "UniFi U6-Pro AP",
"productDescription": "UniFi U6-Pro Access Point",
"customerDescription": "Wireless access point",
"productNarrative": "Install and validate onsite",
"productClass": "Product",
"forecastType": "Products",
"catalogItem": { "id": 500, "identifier": "U6-PRO" },
"quantity": 4,
"effectiveQuantity": 4,
"revenue": 800.0,
"cost": 520.0,
"margin": 280.0,
"percentage": 35.0,
"includeFlag": true,
"taxableFlag": true,
"recurringFlag": false,
"recurringRevenue": 0,
"recurringCost": 0,
"sequenceNumber": 1,
"cancelledFlag": false,
"cancellationType": null,
"quantityCancelled": 0,
"cancelledReason": null,
"cancelledDate": null
}
],
"snapshotTimestamp": "2026-03-06T12:00:00.000Z"
},
"quoteRegenParams": {
"opportunityId": "clx9xyz789...",
"cwOpportunityId": 12345
},
"createdAt": "2026-03-06T12:00:00.000Z",
"updatedAt": "2026-03-06T12:00:00.000Z"
},
"successful": true
}
Preview Quote
GET /sales/opportunities/:identifier/quote/:quoteId/preview
Regenerate a preview-stamped version of an existing committed quote PDF using its stored generation parameters. The PDF is not persisted — it is returned as a base64-encoded string.
Authentication Required: Yes
Required Permissions: sales.opportunity.quote.preview
Path Parameters:
identifier— Internal ID (cuid) or ConnectWise opportunity ID (numeric)quoteId— The generated quote's internal ID (uuid)
Response:
{
"status": 200,
"message": "Quote preview generated successfully!",
"data": {
"mimeType": "application/pdf",
"contentBase64": "JVBERi0xLjQK... (base64-encoded PDF)"
},
"successful": true
}
Download Quote
GET /sales/opportunities/:identifier/quote/:quoteId/download
Download a committed quote PDF by its ID. Returns the PDF file as a base64-encoded string. Each call automatically records a download entry with the timestamp, user info, and fetch action in the quote's downloads array. Download-time metadata (downloadedAt, downloadedBy) is also injected into the PDF's document properties.
Authentication Required: Yes
Required Permissions: sales.opportunity.quote.download
Path Parameters:
identifier— Internal ID (cuid) or ConnectWise opportunity ID (numeric)quoteId— The generated quote's internal ID (uuid)
Query Parameters:
fetchAction(required) — The action being performed. Must be one of:download,print. Tracked in the download record for audit purposes.
Response:
{
"status": 200,
"message": "Quote downloaded successfully!",
"data": {
"id": "a1b2c3d4-e5f6-...",
"quoteFileName": "OPP-12345-2026-03-06T12-00-00-000Z.pdf",
"mimeType": "application/pdf",
"contentBase64": "JVBERi0xLjQK... (base64-encoded PDF)"
},
"successful": true
}
Download Record Shape (stored in downloads JSON array):
{
"downloadedAt": "2026-03-06T15:30:00.000Z",
"fetchAction": "download",
"userId": "clx9user456...",
"userName": "John Roberts",
"userEmail": "jroberts@example.com"
}
Error Responses:
400— Missing or invalidfetchActionquery parameter.404— Generated quote not found.
Fetch Quote Download History
GET /sales/opportunities/:identifier/quotes/downloads
Fetch download/print history for all committed quotes on an opportunity. Returns an array of quote summaries, each containing the full downloads array with every download/print record. This is an admin-level route intended for audit and tracking purposes.
Authentication Required: Yes
Required Permissions: sales.opportunity.quote.fetch_downloads
Path Parameters:
identifier— Internal ID (cuid) or ConnectWise opportunity ID (numeric)
Response:
{
"status": 200,
"message": "Download logs fetched successfully!",
"data": [
{
"quoteId": "a1b2c3d4-e5f6-...",
"quoteFileName": "OPP-12345-2026-03-06T12-00-00-000Z.pdf",
"createdById": "clx9user123...",
"createdAt": "2026-03-06T12:00:00.000Z",
"downloads": [
{
"downloadedAt": "2026-03-06T15:30:00.000Z",
"fetchAction": "download",
"userId": "clx9user456...",
"userName": "John Roberts",
"userEmail": "jroberts@example.com"
},
{
"downloadedAt": "2026-03-06T16:00:00.000Z",
"fetchAction": "print",
"userId": "clx9user789...",
"userName": "Jane Smith",
"userEmail": "jsmith@example.com"
}
]
}
],
"successful": true
}
Get Opportunity Notes
GET /sales/opportunities/:identifier/notes
Fetch notes for an opportunity. Data is served from the Redis cache when available; on cache miss, data is fetched live from ConnectWise and cached. Cache is invalidated automatically when notes are created, updated, or deleted.
Authentication Required: Yes
Required Permissions: sales.opportunity.fetch
Path Parameters:
identifier— Internal ID (cuid) or ConnectWise opportunity ID (numeric)
Response:
{
"status": 200,
"message": "Opportunity notes fetched successfully!",
"data": [
{
"id": 1,
"text": "Client expressed interest in a full network refresh.",
"type": { "id": 2, "name": "Discussion" },
"flagged": false,
"enteredBy": {
"id": "clx1abc123",
"identifier": "jdoe",
"name": "John Doe",
"cwMemberId": 10
}
}
],
"successful": true
}
Get Single Opportunity Note
GET /sales/opportunities/:identifier/notes/:noteId
Fetch a single note by its ConnectWise note ID for an opportunity.
Authentication Required: Yes
Required Permissions: sales.opportunity.fetch
Path Parameters:
identifier— Internal ID (cuid) or ConnectWise opportunity ID (numeric)noteId— The ConnectWise note ID (numeric)
Response:
{
"status": 200,
"message": "Opportunity note fetched successfully!",
"data": {
"id": 1,
"text": "Client expressed interest in a full network refresh.",
"type": { "id": 2, "name": "Discussion" },
"flagged": false,
"enteredBy": {
"id": "clx1abc123",
"identifier": "jdoe",
"name": "John Doe",
"cwMemberId": 10
}
},
"successful": true
}
Create Opportunity Note
POST /sales/opportunities/:identifier/notes
Create a new note on an opportunity in ConnectWise.
Authentication Required: Yes
Required Permissions: sales.opportunity.note.create
Path Parameters:
identifier— Internal ID (cuid) or ConnectWise opportunity ID (numeric)
Request Body:
{
"text": "Follow up with client about pricing.",
"flagged": false
}
| Field | Type | Required | Description |
|---|---|---|---|
text |
string | Yes | The note text (min 1 character) |
flagged |
boolean | No | Whether the note is flagged |
Response:
{
"status": 201,
"message": "Opportunity note created successfully!",
"data": {
"id": 42,
"text": "Follow up with client about pricing.",
"type": null,
"flagged": false,
"enteredBy": {
"id": "clx2def456",
"identifier": "jroberts",
"name": "John Roberts",
"cwMemberId": 15
}
},
"successful": true
}
Update Opportunity Note
PATCH /sales/opportunities/:identifier/notes/:noteId
Update an existing note on an opportunity in ConnectWise.
Authentication Required: Yes
Required Permissions: sales.opportunity.note.update
Path Parameters:
identifier— Internal ID (cuid) or ConnectWise opportunity ID (numeric)noteId— The ConnectWise note ID (numeric)
Request Body:
{
"text": "Updated note text.",
"flagged": true
}
| Field | Type | Required | Description |
|---|---|---|---|
text |
string | No | Updated note text (min 1 character) |
flagged |
boolean | No | Updated flagged state |
At least one of
textorflaggedmust be provided.
Response:
{
"status": 200,
"message": "Opportunity note updated successfully!",
"data": {
"id": 42,
"text": "Updated note text.",
"type": null,
"flagged": true,
"enteredBy": {
"id": "clx2def456",
"identifier": "jroberts",
"name": "John Roberts",
"cwMemberId": 15
}
},
"successful": true
}
Delete Opportunity Note
DELETE /sales/opportunities/:identifier/notes/:noteId
Delete a note from an opportunity in ConnectWise.
Authentication Required: Yes
Required Permissions: sales.opportunity.note.delete
Path Parameters:
identifier— Internal ID (cuid) or ConnectWise opportunity ID (numeric)noteId— The ConnectWise note ID (numeric)
Response:
{
"status": 200,
"message": "Opportunity note deleted successfully!",
"successful": true
}
Get Opportunity Contacts
GET /sales/opportunities/:identifier/contacts
Fetch contacts associated with an opportunity. Data is served from the Redis cache when available; on cache miss, data is fetched live from ConnectWise and cached. Cache is invalidated automatically when contacts are created, updated, or deleted.
Authentication Required: Yes
Required Permissions: sales.opportunity.fetch
Path Parameters:
identifier— Internal ID (cuid) or ConnectWise opportunity ID (numeric)
Response:
{
"status": 200,
"message": "Opportunity contacts fetched successfully!",
"data": [
{
"id": 1,
"contact": { "id": 200, "name": "Jane Smith" },
"company": {
"id": 100,
"identifier": "AcmeCorp",
"name": "Acme Corp"
},
"role": { "id": 1, "name": "Decision Maker" },
"notes": "Primary point of contact for this deal",
"referralFlag": false
}
],
"successful": true
}
Opportunity Workflow (Internal Engine)
The opportunity workflow system is an internal engine that manages the lifecycle of opportunities through a defined set of statuses and transitions. Workflow actions are invoked programmatically via processOpportunityAction() from src/workflows/wf.opportunity.ts, and are exposed to the UI via the HTTP API routes documented below.
Stage gate: All workflow actions require the opportunity's stageName to be "Optima". Actions on opportunities in any other stage are rejected.
Statuses
| Enum Key | CW Status ID | CW Name | Terminal | Notes |
|---|---|---|---|---|
| PendingNew | 37 | 00. Pending New | No | Default status before acceptance |
| New | 24 | 01. New | No | Setup phase — assembling line items, discounts, etc. |
| InternalReview | 56 | 02. Internal Review | No | Flagged for internal review (manual or cold-detection automation) |
| QuoteSent | 43 | 03. Quote Sent | No | Quote has been sent to the customer |
| ConfirmedQuote | 57 | 04. Confirmed Quote | No | Customer acknowledged receipt |
| Active | 58 | 05. Active | No | Quote in revision/flux |
| PendingSent | 60 | Pending Sent | No | Review approved, awaiting rep to send |
| PendingRevision | 61 | Pending Revision | No | Review rejected, awaiting rep revision |
| PendingWon | 49 | 91. Pending Won | No | Won pending finalization by authorized user |
| Won | 29 | 95. Won | Yes | Final positive outcome — immutable |
| PendingLost | 50 | 98. Pending Lost | No | Lost pending finalization or resurrection |
| Lost | 53 | 99. Lost | Yes | Final negative outcome — immutable |
| Canceled | 59 | Canceled | No* | Not pursued; can be re-opened to Active |
Transition Map
| From | Allowed Targets |
|---|---|
| PendingNew | New |
| New | InternalReview, QuoteSent, Canceled |
| InternalReview | PendingSent (approve), PendingRevision (reject), QuoteSent (send), Canceled |
| PendingSent | QuoteSent |
| PendingRevision | Active |
| QuoteSent | ConfirmedQuote, Won/PendingWon, PendingLost, Active, InternalReview (cold auto) |
| ConfirmedQuote | Won/PendingWon, PendingLost, Active, InternalReview (cold auto) |
| Active | QuoteSent, InternalReview, Canceled |
| PendingWon | Won |
| PendingLost | Lost, Active (resurrection) |
| Won | (terminal — no transitions) |
| Lost | (terminal — no transitions) |
| Canceled | Active (re-open) |
Workflow Actions
| Action | Description | Required Permission | Note Required |
|---|---|---|---|
| acceptNew | PendingNew → New | — | No |
| requestReview | → InternalReview (manual) | — | Yes |
| reviewDecision | InternalReview → approve/reject/send/cancel | sales.opportunity.cancel (cancel only) |
Yes |
| sendQuote | → QuoteSent (with compound flags: won, lost, needsRevision, quoteConfirmed) | sales.opportunity.finalize (won flag only) |
No |
| confirmQuote | QuoteSent → ConfirmedQuote | — | No |
| finalize | → Won/Lost (or PendingWon/PendingLost without finalize permission) | sales.opportunity.finalize |
Yes |
| resurrect | PendingLost → Active | — | Yes |
| beginRevision | PendingRevision → Active | — | No |
| resendQuote | Active → QuoteSent (re-send after revision) | — | No |
| cancel | → Canceled | sales.opportunity.cancel |
Yes |
| reopen | Canceled → Active | — | Yes |
CW Activity Custom Field — Optima_Type
Every workflow activity is tagged with an Optima_Type custom field (CW field ID 45) to identify its type without parsing notes:
| Value | Used For |
|---|---|
| Opportunity Created | Initial opportunity creation |
| Opportunity Setup | New/setup phase activities (stays open until next transition) |
| Opportunity Review | Review submissions (stays open until next transition) |
| Quote Sent | Quote-sent activities |
| Quote Confirmed | Standalone quote confirmation activities |
| Quote Sent & Confirmed | Combined send + confirm in a single action |
| Quote Generated | Quote commit/generation activities |
| Revision | Revision activities (stays open until next transition) |
| Finalized | PendingWon, PendingLost, Lost, and cancel activities |
| Converted | Won (finalized) activities |
Cold Detection
The cold-detection algorithm (src/modules/algorithms/algo.coldThreshold.ts) evaluates stall thresholds:
- QuoteSent: 14 days with no activity → auto-transition to InternalReview
- ConfirmedQuote: 30 days with no activity → auto-transition to InternalReview
Triggered via triggerColdDetection() (intended for schedulers/automation, not user actions).
Supporting Modules
| File | Purpose |
|---|---|
| src/modules/algorithms/algo.coldThreshold.ts | Cold-detection config and checkColdStatus() |
| src/modules/algorithms/algo.followUpScheduler.ts | Follow-up scheduling (placeholder: next business day at 10am) |
| src/services/cw.opportunityService.ts | CW integration stubs: submitTimeEntry(), createScheduleEntry(), syncOpportunityStatus() |
Workflow API Routes
These routes expose the opportunity workflow to the UI. They live under /v1/sales/opportunities/:identifier/workflow.
GET /v1/sales/opportunities/:identifier/workflow
Fetch the current workflow state for an opportunity — current status, stage, available actions, cold-detection result, and whether each action is permitted for the authenticated user.
Auth: Bearer token required
Permission: sales.opportunity.fetch
Source: src/api/sales/opportunities/[id]/workflow/status.ts
Response (200):
{
"message": "Workflow status fetched successfully.",
"status": 200,
"data": {
"currentStatusId": 43,
"currentStatus": "QuoteSent",
"stageName": "Optima",
"isOptimaStage": true,
"isTerminal": false,
"availableActions": [
{
"action": "confirmQuote",
"label": "Confirm Quote Receipt",
"targetStatuses": [{ "key": "ConfirmedQuote", "id": 57 }],
"requiresNote": false,
"requiresPermission": null,
"permitted": true
},
{
"action": "finalize",
"label": "Mark as Won",
"targetStatuses": [
{ "key": "Won", "id": 29 },
{ "key": "PendingWon", "id": 49 }
],
"requiresNote": true,
"requiresPermission": null,
"payloadHints": { "outcome": "\"won\"" },
"permitted": true
}
],
"coldCheck": null
}
}
When
isOptimaStageisfalse,availableActionsis always an empty array — no workflow actions are permitted outside the Optima stage.
POST /v1/sales/opportunities/:identifier/workflow
Execute a workflow action. Accepts a discriminated union of { action, payload } and routes it through the workflow engine. The body is validated with Zod.
Auth: Bearer token required
Permission: sales.opportunity.workflow (base gate). Additionally:
sales.opportunity.finalize— forfinalizeaction producing Won/Lostsales.opportunity.cancel— forcancelaction andreviewDecisionwithdecision: "cancel"
Source: src/api/sales/opportunities/[id]/workflow/dispatch.ts
Request body:
{
"action": "sendQuote",
"payload": {
"note": "Sending quote to customer after review.",
"timeSpent": 30,
"quoteConfirmed": false,
"won": false,
"lost": false,
"needsRevision": false
}
}
All action types and their payloads
| Action | Required Payload Fields | Optional Payload Fields | Description |
|---|---|---|---|
acceptNew |
— | note, timeSpent |
PendingNew → New |
requestReview |
note |
timeSpent |
New/Active → InternalReview |
reviewDecision |
note, decision |
timeSpent |
InternalReview → PendingSent/PendingRevision/QuoteSent/Canceled |
sendQuote |
— | note, timeSpent, quoteConfirmed, won, lost, needsRevision |
PendingSent/New → QuoteSent (+ compound transitions) |
confirmQuote |
— | note, timeSpent |
QuoteSent → ConfirmedQuote |
finalize |
note, outcome ("won" or "lost") |
timeSpent |
Pending → Won/Lost (or direct if permitted) |
resurrect |
note |
timeSpent |
PendingLost → Active |
beginRevision |
— | note, timeSpent |
PendingRevision → Active |
resendQuote |
— | note, timeSpent, quoteConfirmed, won, lost, needsRevision |
Active → QuoteSent (+ compound transitions) |
cancel |
note |
timeSpent |
New/Active → Canceled (requires cancel perm) |
reopen |
note |
timeSpent |
Canceled → Active |
decision values for reviewDecision: "approve", "reject", "send", "cancel"
Response (200) — success:
{
"message": "Workflow action completed successfully.",
"status": 200,
"data": {
"previousStatusId": 60,
"previousStatus": "PendingSent",
"newStatusId": 43,
"newStatus": "QuoteSent",
"activitiesCreated": [ { "...activity JSON..." } ],
"coldCheck": null
}
}
Response (422) — transition rejected:
{
"message": "Workflow action failed.",
"status": 422,
"name": "WorkflowTransitionFailed",
"data": {
"previousStatusId": 29,
"previousStatus": "Won",
"newStatusId": null,
"newStatus": null
}
}
GET /v1/sales/opportunities/:identifier/workflow/history
Fetch the workflow activity history for an opportunity. Returns only CW activities that have a valid Optima_Type custom field set, sorted newest first.
Auth: Bearer token required
Permission: sales.opportunity.fetch
Source: src/api/sales/opportunities/[id]/workflow/history.ts
Query parameters:
| Param | Type | Description |
|---|---|---|
type |
string | Filter by Optima_Type value (e.g. "Quote Sent") |
Response (200):
{
"message": "Workflow history fetched successfully.",
"status": 200,
"data": {
"opportunityId": "clx...",
"cwOpportunityId": 12345,
"totalActivities": 3,
"activities": [
{
"activity": { "...activity JSON..." },
"optimaType": "Quote Sent",
"quoteId": "QUO-12345",
"closed": true,
"closedAt": "2026-03-09T05:40:00.000Z"
}
]
}
}
| Field | Type | Description |
|---|---|---|
activity |
object | Full CW activity JSON from ActivityController.toJson() |
optimaType |
string | The Optima_Type custom field value (e.g. "Quote Sent", "Opportunity Setup") |
quoteId |
string | null | The QuoteID custom field value (CW field id 48), or null if not set |
closed |
boolean | true when the CW activity status is Closed (status id 2) |
closedAt |
string | null | ISO-8601 timestamp from the Close Date custom field (CW field id 49), or null if not set |
UniFi Routes
All UniFi routes require the unifi.access permission in addition to their route-specific permission. This acts as a gate for the entire UniFi API.
Get All UniFi Sites
GET /unifi/sites
Fetch all UniFi site records from the database.
Authentication Required: Yes
Required Permissions: unifi.access, unifi.sites.fetch.many
Field-Level Gating: obj.unifiSite — see Object Type Field-Level Gating
Response:
{
"status": 200,
"message": "UniFi Sites Fetched Successfully!",
"data": [
{
"id": "ckx...",
"name": "Total Tech - Murray Office",
"siteId": "km9b1v8i",
"companyId": "ckx...",
"company": {
"id": "ckx...",
"name": "Acme Corp"
},
"createdAt": "2025-01-01T00:00:00.000Z",
"updatedAt": "2025-01-01T00:00:00.000Z"
}
],
"successful": true
}
Sync UniFi Sites
POST /unifi/sites/sync
Synchronize sites from the UniFi controller into the database. Creates new records for sites not yet tracked and updates names for existing ones.
Authentication Required: Yes
Required Permissions: unifi.access, unifi.sites.sync
Response:
{
"status": 200,
"message": "UniFi Sites Synced Successfully!",
"data": [
{
"id": "ckx...",
"name": "Total Tech - Murray Office",
"siteId": "km9b1v8i",
"companyId": null,
"createdAt": "2025-01-01T00:00:00.000Z",
"updatedAt": "2025-01-01T00:00:00.000Z"
}
],
"successful": true
}
Create UniFi Site
POST /unifi/sites/create
Create a new site on the UniFi controller and track it in the database.
Authentication Required: Yes
Required Permissions: unifi.access, unifi.sites.create
Request Body:
{
"description": "New Office Site"
}
| Field | Type | Required | Description |
|---|---|---|---|
description |
string | Yes | Human-readable name / description for the site |
Response:
{
"status": 200,
"message": "UniFi Site Created Successfully!",
"data": {
"id": "ckx...",
"name": "New Office Site",
"siteId": "abc123",
"companyId": null,
"createdAt": "2025-01-01T00:00:00.000Z",
"updatedAt": "2025-01-01T00:00:00.000Z"
},
"successful": true
}
Get UniFi Site
GET /unifi/site/:id
Fetch a single UniFi site record from the database by its internal ID.
Authentication Required: Yes
Required Permissions: unifi.access, unifi.sites.fetch
Field-Level Gating: obj.unifiSite — see Object Type Field-Level Gating
URL Parameters:
id- Internal UniFi site ID (database ID)
Response:
{
"status": 200,
"message": "UniFi Site Fetched Successfully!",
"data": {
"id": "ckx...",
"name": "Total Tech - Murray Office",
"siteId": "km9b1v8i",
"companyId": "ckx...",
"createdAt": "2025-01-01T00:00:00.000Z",
"updatedAt": "2025-01-01T00:00:00.000Z"
},
"successful": true
}
Get Site Overview
GET /unifi/site/:id/overview
Fetch live site overview data from the UniFi controller, including health status, system info, and site information.
Authentication Required: Yes
Required Permissions: unifi.access, unifi.site.overview
URL Parameters:
id- Internal UniFi site ID (database ID)
Response:
{
"status": 200,
"message": "UniFi Site Overview Fetched Successfully!",
"data": {
"health": [
{
"subsystem": "wan",
"status": "ok",
"numAdopted": 1,
"numGateway": 1
}
],
"sysInfo": {
"timezone": "America/Denver",
"hostname": "UniFi-Controller",
"version": "8.x.x"
},
"siteInfo": {
"description": "Total Tech - Murray Office",
"name": "km9b1v8i"
}
},
"successful": true
}
Get Site Devices
GET /unifi/site/:id/devices
Fetch live device list from the UniFi controller for a specific site.
Authentication Required: Yes
Required Permissions: unifi.access, unifi.site.devices
URL Parameters:
id- Internal UniFi site ID (database ID)
Response:
{
"status": 200,
"message": "UniFi Devices Fetched Successfully!",
"data": [
{
"id": "abc123...",
"mac": "00:11:22:33:44:55",
"model": "U6-Pro",
"name": "Office AP",
"type": "uap",
"state": "connected",
"ip": "192.168.1.10",
"version": "6.x.x",
"uptime": 123456,
"radios": [],
"uplink": {}
}
],
"successful": true
}
Get Site WiFi Networks
GET /unifi/site/:id/wifi
Fetch live WiFi network (WLAN) configurations from the UniFi controller for a specific site.
Authentication Required: Yes
Required Permissions: unifi.access, unifi.site.wifi
Field-Level Gating: unifi.site.wifi.read.<field>
This route uses processObjectValuePerms to filter each WLAN object on a per-field basis. Only fields whose corresponding unifi.site.wifi.read.<field> permission the user holds are included in the response. For example, a user with unifi.site.wifi.read.name and unifi.site.wifi.read.enabled but without unifi.site.wifi.read.passphrase will receive objects containing only name and enabled. Use unifi.site.wifi.read.* to grant access to all fields.
All available field-level permission nodes
| Permission Node | Field |
|---|---|
unifi.site.wifi.read.id |
id |
unifi.site.wifi.read.name |
name |
unifi.site.wifi.read.siteId |
siteId |
unifi.site.wifi.read.enabled |
enabled |
unifi.site.wifi.read.security |
security |
unifi.site.wifi.read.wpaMode |
wpaMode |
unifi.site.wifi.read.wpaEnc |
wpaEnc |
unifi.site.wifi.read.wpa3Support |
wpa3Support |
unifi.site.wifi.read.wpa3Transition |
wpa3Transition |
unifi.site.wifi.read.wpa3FastRoaming |
wpa3FastRoaming |
unifi.site.wifi.read.wpa3Enhanced192 |
wpa3Enhanced192 |
unifi.site.wifi.read.passphrase |
passphrase |
unifi.site.wifi.read.passphraseAutogenerated |
passphraseAutogenerated |
unifi.site.wifi.read.hideSSID |
hideSSID |
unifi.site.wifi.read.isGuest |
isGuest |
unifi.site.wifi.read.band |
band |
unifi.site.wifi.read.bands |
bands |
unifi.site.wifi.read.networkconfId |
networkconfId |
unifi.site.wifi.read.usergroupId |
usergroupId |
unifi.site.wifi.read.apGroupIds |
apGroupIds |
unifi.site.wifi.read.apGroupMode |
apGroupMode |
unifi.site.wifi.read.pmfMode |
pmfMode |
unifi.site.wifi.read.groupRekey |
groupRekey |
unifi.site.wifi.read.dtimMode |
dtimMode |
unifi.site.wifi.read.dtimNg |
dtimNg |
unifi.site.wifi.read.dtimNa |
dtimNa |
unifi.site.wifi.read.dtim6e |
dtim6e |
unifi.site.wifi.read.l2Isolation |
l2Isolation |
unifi.site.wifi.read.fastRoamingEnabled |
fastRoamingEnabled |
unifi.site.wifi.read.bssTransition |
bssTransition |
unifi.site.wifi.read.uapsdEnabled |
uapsdEnabled |
unifi.site.wifi.read.iappEnabled |
iappEnabled |
unifi.site.wifi.read.proxyArp |
proxyArp |
unifi.site.wifi.read.mcastenhanceEnabled |
mcastenhanceEnabled |
unifi.site.wifi.read.macFilterEnabled |
macFilterEnabled |
unifi.site.wifi.read.macFilterPolicy |
macFilterPolicy |
unifi.site.wifi.read.macFilterList |
macFilterList |
unifi.site.wifi.read.radiusDasEnabled |
radiusDasEnabled |
unifi.site.wifi.read.radiusMacAuthEnabled |
radiusMacAuthEnabled |
unifi.site.wifi.read.radiusMacaclFormat |
radiusMacaclFormat |
unifi.site.wifi.read.minrateSettingPreference |
minrateSettingPreference |
unifi.site.wifi.read.minrateNgEnabled |
minrateNgEnabled |
unifi.site.wifi.read.minrateNgDataRateKbps |
minrateNgDataRateKbps |
unifi.site.wifi.read.minrateNgAdvertisingRates |
minrateNgAdvertisingRates |
unifi.site.wifi.read.minrateNaEnabled |
minrateNaEnabled |
unifi.site.wifi.read.minrateNaDataRateKbps |
minrateNaDataRateKbps |
unifi.site.wifi.read.minrateNaAdvertisingRates |
minrateNaAdvertisingRates |
unifi.site.wifi.read.settingPreference |
settingPreference |
unifi.site.wifi.read.no2ghzOui |
no2ghzOui |
unifi.site.wifi.read.privatePreSharedKeysEnabled |
privatePreSharedKeysEnabled |
unifi.site.wifi.read.privatePreSharedKeys |
privatePreSharedKeys |
unifi.site.wifi.read.saeGroups |
saeGroups |
unifi.site.wifi.read.saePsk |
saePsk |
unifi.site.wifi.read.schedule |
schedule |
unifi.site.wifi.read.scheduleWithDuration |
scheduleWithDuration |
unifi.site.wifi.read.bcFilterList |
bcFilterList |
unifi.site.wifi.read.externalId |
externalId |
URL Parameters:
id- Internal UniFi site ID (database ID)
Response:
{
"status": 200,
"message": "UniFi WiFi Networks Fetched Successfully!",
"data": [
{
"id": "66eb36e54bb53f0ae3fb82bd",
"name": "TTAD",
"siteId": "61ae856a8eae567c3905c54f",
"enabled": true,
"security": "wpapsk",
"wpaMode": "wpa2",
"wpaEnc": "ccmp",
"wpa3Support": false,
"wpa3Transition": false,
"wpa3FastRoaming": false,
"wpa3Enhanced192": false,
"passphrase": "S3cur3Alarm!",
"passphraseAutogenerated": false,
"hideSSID": false,
"isGuest": false,
"band": "2g",
"bands": ["2g"],
"networkconfId": "66eb368d4bb53f0ae3fb7e0d",
"usergroupId": "61ae856a8eae567c3905c55c",
"apGroupIds": ["66eb36e44bb53f0ae3fb82bc"],
"apGroupMode": "devices",
"pmfMode": "disabled",
"groupRekey": 0,
"dtimMode": "default",
"dtimNg": 1,
"dtimNa": 3,
"dtim6e": 3,
"l2Isolation": false,
"fastRoamingEnabled": false,
"bssTransition": true,
"uapsdEnabled": false,
"iappEnabled": true,
"proxyArp": false,
"mcastenhanceEnabled": false,
"macFilterEnabled": false,
"macFilterPolicy": "allow",
"macFilterList": [],
"radiusDasEnabled": false,
"radiusMacAuthEnabled": false,
"radiusMacaclFormat": "none_lower",
"minrateSettingPreference": "auto",
"minrateNgEnabled": true,
"minrateNgDataRateKbps": 1000,
"minrateNgAdvertisingRates": false,
"minrateNaEnabled": false,
"minrateNaDataRateKbps": 6000,
"minrateNaAdvertisingRates": false,
"settingPreference": "manual",
"no2ghzOui": true,
"privatePreSharedKeysEnabled": false,
"privatePreSharedKeys": [],
"saeGroups": [],
"saePsk": [],
"schedule": [],
"scheduleWithDuration": [],
"bcFilterList": [],
"externalId": "ba0f579c-6b21-4dfc-8d03-5a1a09243328"
}
],
"successful": true
}
Update WiFi Network
PATCH /unifi/site/:id/wifi/:wlanId
Update a WiFi network configuration on the UniFi controller.
Authentication Required: Yes
Required Permissions: unifi.access, unifi.site.wifi, unifi.site.wifi.update
URL Parameters:
id- Internal UniFi site ID (database ID)wlanId- UniFi WLAN ID
Request Body:
{
"name": "NewSSIDName",
"x_passphrase": "NewPassword123",
"enabled": true,
"security": "wpapsk",
"wpa_mode": "wpa2",
"hide_ssid": false,
"is_guest": false,
"band": "both"
}
All fields are optional. Valid values:
security:"wpapsk","wpaeap","open"wpa_mode:"wpa2","wpa3","wpa2wpa3"band:"both","2g","5g"
Response:
{
"status": 200,
"message": "UniFi WiFi Network Updated Successfully!",
"data": {
"id": "66eb36e54bb53f0ae3fb82bd",
"name": "NewSSIDName",
"siteId": "61ae856a8eae567c3905c54f",
"enabled": true,
"security": "wpapsk",
"wpaMode": "wpa2",
"wpaEnc": "ccmp",
"wpa3Support": false,
"wpa3Transition": false,
"wpa3FastRoaming": false,
"wpa3Enhanced192": false,
"passphrase": "NewPassword123",
"passphraseAutogenerated": false,
"hideSSID": false,
"isGuest": false,
"band": "both",
"bands": ["2g", "5g"],
"networkconfId": "66eb368d4bb53f0ae3fb7e0d",
"usergroupId": "61ae856a8eae567c3905c55c",
"apGroupIds": ["66eb36e44bb53f0ae3fb82bc"],
"apGroupMode": "devices",
"pmfMode": "disabled",
"groupRekey": 0,
"dtimMode": "default",
"dtimNg": 1,
"dtimNa": 3,
"dtim6e": 3,
"l2Isolation": false,
"fastRoamingEnabled": false,
"bssTransition": true,
"uapsdEnabled": false,
"iappEnabled": true,
"proxyArp": false,
"mcastenhanceEnabled": false,
"macFilterEnabled": false,
"macFilterPolicy": "allow",
"macFilterList": [],
"radiusDasEnabled": false,
"radiusMacAuthEnabled": false,
"radiusMacaclFormat": "none_lower",
"minrateSettingPreference": "auto",
"minrateNgEnabled": true,
"minrateNgDataRateKbps": 1000,
"minrateNgAdvertisingRates": false,
"minrateNaEnabled": false,
"minrateNaDataRateKbps": 6000,
"minrateNaAdvertisingRates": false,
"settingPreference": "manual",
"no2ghzOui": true,
"privatePreSharedKeysEnabled": false,
"privatePreSharedKeys": [],
"saeGroups": [],
"saePsk": [],
"schedule": [],
"scheduleWithDuration": [],
"bcFilterList": [],
"externalId": "ba0f579c-6b21-4dfc-8d03-5a1a09243328"
},
"successful": true
}
Get Site Networks
GET /unifi/site/:id/networks
Fetch live network configurations from the UniFi controller for a specific site.
Authentication Required: Yes
Required Permissions: unifi.access, unifi.site.networks
URL Parameters:
id- Internal UniFi site ID (database ID)
Response:
{
"status": 200,
"message": "UniFi Networks Fetched Successfully!",
"data": [
{
"id": "abc123...",
"name": "LAN",
"purpose": "corporate",
"subnet": "192.168.1.0/24",
"vlanId": null,
"dhcpEnabled": true,
"dhcpStart": "192.168.1.100",
"dhcpStop": "192.168.1.254",
"domainName": "localdomain",
"isNat": true,
"enabled": true
}
],
"successful": true
}
Get WLAN Groups
GET /unifi/site/:id/wlan-groups
Fetch WLAN groups (AP groups) from the UniFi controller for a specific site. WLAN groups define which WLANs are broadcast on which access points.
Authentication Required: Yes
Required Permissions: unifi.access, unifi.site.wlan-groups
URL Parameters:
id- Internal UniFi site ID (database ID)
Response:
{
"status": 200,
"message": "UniFi WLAN Groups Fetched Successfully!",
"data": [
{
"id": "61ae856a8eae567c3905c55d",
"name": "Default",
"siteId": "61ae856a8eae567c3905c550",
"noDelete": true,
"noEdit": false,
"hidden": false
}
],
"successful": true
}
Create WLAN Group
POST /unifi/site/:id/wlan-groups
Create a new WLAN group (AP broadcasting group) on the UniFi controller. WLAN groups control which WLANs are broadcast on which access points.
Authentication Required: Yes
Required Permissions: unifi.access, unifi.site.wlan-groups, unifi.site.wlan-groups.create
URL Parameters:
id- Internal UniFi site ID (database ID)
Request Body:
{
"name": "Lobby APs"
}
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | Yes | Name of the WLAN group |
Response (201):
{
"status": 201,
"message": "UniFi WLAN Group Created Successfully!",
"data": {
"id": "abc123...",
"name": "Lobby APs",
"siteId": "61ae856a8eae567c3905c550",
"noDelete": false,
"noEdit": false,
"hidden": false
},
"successful": true
}
Get AP Groups
GET /unifi/site/:id/ap-groups
Fetch AP groups from the UniFi controller for a specific site. AP groups define collections of access points — individual WLANs can target specific AP groups to control which APs broadcast a given SSID.
Authentication Required: Yes
Required Permissions: unifi.access, unifi.site.ap-groups
URL Parameters:
id- Internal UniFi site ID (database ID)
Response:
{
"status": 200,
"message": "UniFi AP Groups Fetched Successfully!",
"data": [
{
"id": "61ae856a8eae567c3905c565",
"name": "All APs",
"deviceMacs": [
"18:e8:29:59:25:bc",
"78:45:58:29:fa:87",
"68:d7:9a:73:9a:28"
],
"noDelete": true
},
{
"id": "63c5be017e957d08189ed997",
"name": "301Andrus",
"deviceMacs": ["78:45:58:29:fa:87", "18:e8:29:59:25:bc"],
"noDelete": false
}
],
"successful": true
}
Get Access Points
GET /unifi/site/:id/access-points
Fetch access points (UAPs only) from the UniFi controller for a specific site. This filters the full device list to only return wireless access points.
Authentication Required: Yes
Required Permissions: unifi.access, unifi.site.access-points
URL Parameters:
id- Internal UniFi site ID (database ID)
Response:
{
"status": 200,
"message": "UniFi Access Points Fetched Successfully!",
"data": [
{
"id": "abc123...",
"mac": "00:11:22:33:44:55",
"model": "U6-Pro",
"type": "uap",
"name": "Office AP",
"state": 1,
"adopted": true,
"ip": "192.168.1.10",
"version": "7.1.68"
}
],
"successful": true
}
Get WiFi Limits
GET /unifi/site/:id/wifi-limits
Check the WiFi SSID limits per access point per radio band. UniFi access points support a maximum of 8 SSIDs per radio. This endpoint shows how many SSIDs are active on each radio and how many more can be added.
Authentication Required: Yes
Required Permissions: unifi.access, unifi.site.wifi-limits
URL Parameters:
id- Internal UniFi site ID (database ID)
Response:
{
"status": 200,
"message": "UniFi WiFi Limits Fetched Successfully!",
"data": [
{
"apId": "abc123...",
"apName": "Office AP",
"mac": "00:11:22:33:44:55",
"model": "U6-Pro",
"radios": [
{
"radio": "ng",
"band": "2g",
"activeWlans": 3,
"limit": 8,
"remaining": 5,
"wlanNames": ["Corporate", "Guest", "IoT"]
},
{
"radio": "na",
"band": "5g",
"activeWlans": 3,
"limit": 8,
"remaining": 5,
"wlanNames": ["Corporate", "Guest", "IoT"]
}
]
}
],
"successful": true
}
Get Speed Profiles
GET /unifi/site/:id/speed-profiles
Fetch speed limit profiles (user groups) from the UniFi controller for a specific site. Speed profiles define bandwidth limits that can be applied to WiFi networks.
Authentication Required: Yes
Required Permissions: unifi.access, unifi.site.speed-profiles
URL Parameters:
id- Internal UniFi site ID (database ID)
Response:
{
"status": 200,
"message": "UniFi Speed Profiles Fetched Successfully!",
"data": [
{
"id": "61ae856a8eae567c3905c55e",
"name": "Default",
"siteId": "61ae856a8eae567c3905c550",
"noDelete": true,
"downloadLimitKbps": -1,
"uploadLimitKbps": -1
},
{
"id": "abc123...",
"name": "Guest 10Mbps",
"siteId": "61ae856a8eae567c3905c550",
"noDelete": false,
"downloadLimitKbps": 10000,
"uploadLimitKbps": 5000
}
],
"successful": true
}
Note: A value of
-1fordownloadLimitKbpsoruploadLimitKbpsmeans unlimited.
Create Speed Profile
POST /unifi/site/:id/speed-profiles
Create a new speed limit profile (user group) on the UniFi controller.
Authentication Required: Yes
Required Permissions: unifi.access, unifi.site.speed-profiles, unifi.site.speed-profiles.create
URL Parameters:
id- Internal UniFi site ID (database ID)
Request Body:
{
"name": "Guest 10Mbps",
"downloadLimitKbps": 10000,
"uploadLimitKbps": 5000
}
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | Yes | Name of the speed profile |
downloadLimitKbps |
number | No | Download limit in Kbps (-1 or omit = unlimited) |
uploadLimitKbps |
number | No | Upload limit in Kbps (-1 or omit = unlimited) |
Response (201):
{
"status": 201,
"message": "UniFi Speed Profile Created Successfully!",
"data": {
"id": "abc123...",
"name": "Guest 10Mbps",
"siteId": "61ae856a8eae567c3905c550",
"noDelete": false,
"downloadLimitKbps": 10000,
"uploadLimitKbps": 5000
},
"successful": true
}
Get Private PSKs
GET /unifi/site/:id/wifi/:wlanId/ppsk
Fetch private pre-shared keys (PPSKs) for a specific WiFi network. PPSKs allow different devices to connect to the same SSID with unique passwords.
Authentication Required: Yes
Required Permissions: unifi.access, unifi.site.wifi, unifi.site.wifi.ppsk
URL Parameters:
id- Internal UniFi site ID (database ID)wlanId- UniFi WLAN configuration ID
Response:
{
"status": 200,
"message": "UniFi Private PSKs Fetched Successfully!",
"data": [
{
"key": "mySecurePassword123",
"name": "John's Laptop",
"mac": null,
"vlanId": null
},
{
"key": "anotherPassword456",
"name": "IoT Device",
"mac": "AA:BB:CC:DD:EE:FF",
"vlanId": 100
}
],
"successful": true
}
Create Private PSK
POST /unifi/site/:id/wifi/:wlanId/ppsk
Create a private pre-shared key on a specific WiFi network. This adds a new PPSK to the WLAN's list and enables PPSK mode if not already enabled.
Authentication Required: Yes
Required Permissions: unifi.access, unifi.site.wifi, unifi.site.wifi.ppsk, unifi.site.wifi.ppsk.create
URL Parameters:
id- Internal UniFi site ID (database ID)wlanId- UniFi WLAN configuration ID
Request Body:
{
"key": "mySecurePassword123",
"name": "John's Laptop",
"mac": "AA:BB:CC:DD:EE:FF",
"vlanId": 100
}
| Field | Type | Required | Description |
|---|---|---|---|
key |
string | Yes | The pre-shared key (min 8 characters) |
name |
string | Yes | Descriptive name for this PSK |
mac |
string | No | MAC address to lock this PSK to a specific device |
vlanId |
number | No | VLAN ID to assign to clients using this PSK |
Response (201):
{
"status": 201,
"message": "UniFi Private PSK Created Successfully!",
"data": [
{
"key": "mySecurePassword123",
"name": "John's Laptop",
"mac": "AA:BB:CC:DD:EE:FF",
"vlanId": 100
}
],
"successful": true
}
Note: The response returns the full updated list of all PPSKs on the WLAN after creation.
Link Site to Company
POST /unifi/site/:id/link
Link a UniFi site to a company.
Authentication Required: Yes
Required Permissions: unifi.access, unifi.sites.link
URL Parameters:
id- Internal UniFi site ID (database ID)
Request Body:
{
"companyId": "ckx..."
}
Response:
{
"status": 200,
"message": "UniFi Site Linked to Company Successfully!",
"data": {
"id": "ckx...",
"name": "Total Tech - Murray Office",
"siteId": "km9b1v8i",
"companyId": "ckx...",
"createdAt": "2025-01-01T00:00:00.000Z",
"updatedAt": "2025-01-01T00:00:00.000Z"
},
"successful": true
}
Unlink Site from Company
POST /unifi/site/:id/unlink
Unlink a UniFi site from its associated company.
Authentication Required: Yes
Required Permissions: unifi.access, unifi.sites.link
URL Parameters:
id- Internal UniFi site ID (database ID)
Response:
{
"status": 200,
"message": "UniFi Site Unlinked from Company Successfully!",
"data": {
"id": "ckx...",
"name": "Total Tech - Murray Office",
"siteId": "km9b1v8i",
"companyId": null,
"createdAt": "2025-01-01T00:00:00.000Z",
"updatedAt": "2025-01-01T00:00:00.000Z"
},
"successful": true
}
Error Responses
All endpoints may return error responses in the following format:
400 Bad Request
{
"status": 400,
"message": "Validation error",
"errors": [
{
"path": ["field"],
"message": "Field is required"
}
],
"successful": false
}
401 Unauthorized
{
"status": 401,
"message": "Unauthorized",
"successful": false
}
403 Forbidden
{
"status": 403,
"message": "Insufficient permissions",
"successful": false
}
404 Not Found
{
"status": 404,
"message": "Resource not found",
"successful": false
}
500 Internal Server Error
{
"status": 500,
"message": "Internal server error",
"successful": false
}
Authentication
Most endpoints require authentication via an access token in the request headers:
Authorization: Bearer <access_token>
Tokens are obtained through the Microsoft OAuth flow:
- Call
GET /auth/urito get the authentication URL - Redirect user to the Microsoft login page
- User authenticates and is redirected to
/auth/redirect - Access and refresh tokens are provided via WebSocket or response
- Use the access token in subsequent API requests
When the access token expires, use POST /auth/refresh with the refresh token to obtain a new access token.
Permission System
The API uses a granular permission system. Each endpoint requires specific permissions that are checked via the authMiddleware. Permissions are granted through roles assigned to users.
Common permission patterns:
resource.fetch- Read a single resourceresource.fetch.many- Read multiple resourcesresource.create- Create a new resourceresource.update- Update a resourceresource.delete- Delete a resourceresource.field.action- Perform specific field operations
Users can have multiple roles, and permissions are accumulated from all assigned roles.