New features: - ActivityController and manager for CW sales activities (CRUD) - ForecastProductController for opportunity forecast/product lines - CW member cache with dual-layer (in-memory + Redis) resolution - Catalog category/subcategory/ecosystem taxonomy module - Quote statuses type definitions with CW mapping - User-defined fields (UDF) module with cache and event refresh - Company sites CW module with serialization - Procurement manager filters (category, ecosystem, manufacturer, price, stock) - Opportunity notes CRUD and product line management via CW API - Opportunity type definitions endpoint Updates: - OpportunityController: CW refresh, company hydration, activities, custom fields - UserController: cwIdentifier field for CW member linking - CatalogItemController: category/subcategory fields from CW - PermissionNodes: sales note/product CRUD nodes, subCategories, collectPermissions - API routes: procurement categories/filters, sales notes/products, opportunity types - Global events: UDF and member refresh intervals on startup Tests (414 passing): - ActivityController, ForecastProductController, OpportunityController unit tests - UserController cwIdentifier tests - catalogCategories, companySites, memberCache, procurement module tests - activityTypes, opportunityTypes, quoteStatuses type tests - permissionNodes subCategories and getAllPermissionNodes tests - Updated test setup with redis mock, API method mocks, and builder helpers
109 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
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
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 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 (e.g.Technology,Field,General)subcategory(optional) — Filter by CW subcategory name (e.g.Network-Switch,AlarmBurg-Panels)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 categorysubcategory(optional) — Scope manufacturers to items in this subcategoryincludeInactive(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) fetched from ConnectWise when a linked company exists, as well as an activities array containing all ConnectWise activities linked to the opportunity (fetched live from CW at request time). Single-opportunity fetches additionally include full site details (address, phone, flags). Sub-resource routes (products, notes, contacts) fetch live data from ConnectWise using the opportunity's CW ID.
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,
"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",
"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 from ConnectWise) and full site details (with address) when available.
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)
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,
"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",
"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,
"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
}
Get Opportunity Products
GET /sales/opportunities/:identifier/products
Fetch products (forecast/revenue line items) for an opportunity. Data is fetched live from ConnectWise using the opportunity's CW ID.
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 sequence order of products (forecast items) on an opportunity. Sends a sequenceNumber PATCH to each forecast item in ConnectWise.
Authentication Required: Yes
Required Permissions: sales.opportunity.product.update
Path Parameters:
identifier— Internal ID (cuid) or ConnectWise opportunity ID (numeric)
Request Body:
{
"orderedIds": [31846, 31847, 31848]
}
orderedIds— Array of forecast item IDs in the desired sequence order. Position in the array determines thesequenceNumber(1-based).
Response:
{
"status": 200,
"message": "Product sequence updated successfully!",
"data": {
"products": [
{
"id": 31850,
"forecastDescription": "Service",
"sequenceNumber": 1,
"..."
}
],
"idMap": {
"31846": 31850,
"31847": 31851,
"31848": 31852
}
},
"successful": true
}
data.products— Full updated product objects (IDs may change after PUT to ConnectWise).data.idMap— Maps each original forecast item ID (from the request) to the new ID returned by ConnectWise. Use this to update references in the UI.
Get Opportunity Notes
GET /sales/opportunities/:identifier/notes
Fetch notes for an opportunity. Data is fetched live from ConnectWise using the opportunity's CW ID.
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 fetched live from ConnectWise using the opportunity's CW ID.
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
}
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.