4948 lines
120 KiB
Markdown
4948 lines
120 KiB
Markdown
# 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](PERMISSIONS.md) for the full list of field-level permission nodes within each scope.
|
||
|
||
---
|
||
|
||
## Authentication Routes
|
||
|
||
## ConnectWise Callback Routes
|
||
|
||
### 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 against `CW_CALLBACK_SECRET` when configured
|
||
- `resource` — 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:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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 Microsoft
|
||
- `state` - 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:**
|
||
|
||
```json
|
||
{
|
||
"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](#object-type-field-level-gating)
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"name": "Jane Doe",
|
||
"image": "https://example.com/avatar.jpg"
|
||
}
|
||
```
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"permissions": ["user.read", "company.create", "credential.write"]
|
||
}
|
||
```
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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](#object-type-field-level-gating)
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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](#object-type-field-level-gating)
|
||
|
||
**Path Parameters:**
|
||
|
||
- `identifier` - The user's ID
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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):**
|
||
|
||
```json
|
||
{
|
||
"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 `roles` is included in the body: `user.roles.other` is also required
|
||
- If `permissions` is included in the body: `user.permissions.other` is also required
|
||
|
||
**Path Parameters:**
|
||
|
||
- `identifier` - The user's ID
|
||
|
||
**Request Body:**
|
||
|
||
All fields are optional. Include only the fields you want to update.
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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):**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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](#object-type-field-level-gating)
|
||
|
||
**Path Parameters:**
|
||
|
||
- `identifier` - The user's ID
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"permissions": ["user.read", "company.fetch", "credential.write"]
|
||
}
|
||
```
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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](#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:**
|
||
|
||
```json
|
||
{
|
||
"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 when `includeAddress=true`)
|
||
- `company.fetch.contacts` (required when `includeAllContacts=true`)
|
||
|
||
**Field-Level Gating:** `obj.company` — see [Object Type Field-Level Gating](#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. Requires `company.fetch.address` permission. (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. Requires `company.fetch.contacts` permission. (default: false)
|
||
|
||
**Response (without optional query params):**
|
||
|
||
```json
|
||
{
|
||
"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):**
|
||
|
||
```json
|
||
{
|
||
"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):**
|
||
|
||
```json
|
||
{
|
||
"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):**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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](#object-type-field-level-gating)
|
||
|
||
**URL Parameters:**
|
||
|
||
- `identifier` - Company ID
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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](#object-type-field-level-gating)
|
||
|
||
**URL Parameters:**
|
||
|
||
- `id` - Credential ID
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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](#object-type-field-level-gating)
|
||
|
||
**URL Parameters:**
|
||
|
||
- `companyId` - Company ID
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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.
|
||
|
||
```json
|
||
{
|
||
"name": "Updated Credential Name",
|
||
"notes": "Updated notes for this credential",
|
||
"fields": [
|
||
{
|
||
"fieldId": "accessKeyId",
|
||
"value": "AKIAIOSFODNN7EXAMPLE"
|
||
},
|
||
{
|
||
"fieldId": "secretAccessKey",
|
||
"value": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"fields": [
|
||
{
|
||
"fieldId": "accessKeyId",
|
||
"value": "AKIAIOSFODNN7NEWVALUE"
|
||
},
|
||
{
|
||
"fieldId": "secretAccessKey",
|
||
"value": "newSecretKeyValue123"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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 ID
|
||
- `fieldId` - The field ID of the secure value to read
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"status": 200,
|
||
"message": "Secure Value Fetched Successfully!",
|
||
"data": {
|
||
"fieldId": "secretAccessKey",
|
||
"value": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||
},
|
||
"successful": true
|
||
}
|
||
```
|
||
|
||
**Error Response (404):**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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](#object-type-field-level-gating)
|
||
|
||
**URL Parameters:**
|
||
|
||
- `id` - Parent Credential ID
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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 ID
|
||
- `subId` - Sub-Credential ID to remove
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"status": 200,
|
||
"message": "Sub-Credential Removed Successfully!",
|
||
"data": null,
|
||
"successful": true
|
||
}
|
||
```
|
||
|
||
**Error Response (404):**
|
||
|
||
```json
|
||
{
|
||
"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](#object-type-field-level-gating)
|
||
|
||
**URL Parameters:**
|
||
|
||
- `identifier` - Credential Type ID or name
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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](#object-type-field-level-gating)
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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.
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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 optional `subFields` array — see the [Create Credential Type](#create-credential-type) section for the full schema and example.
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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](#object-type-field-level-gating)
|
||
|
||
**URL Parameters:**
|
||
|
||
- `id` - Credential Type ID
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"title": "System Administrator",
|
||
"moniker": "system_admin",
|
||
"permissions": [
|
||
"user.read",
|
||
"user.write",
|
||
"company.fetch",
|
||
"credential.create"
|
||
]
|
||
}
|
||
```
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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](#object-type-field-level-gating)
|
||
|
||
**URL Parameters:**
|
||
|
||
- `identifier` - Role ID or moniker
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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](#object-type-field-level-gating)
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"title": "Super Administrator",
|
||
"moniker": "super_admin",
|
||
"permissions": ["*"]
|
||
}
|
||
```
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"permissions": ["credential.update", "credential.delete"]
|
||
}
|
||
```
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"permissions": ["credential.delete"]
|
||
}
|
||
```
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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](#object-type-field-level-gating)
|
||
|
||
**URL Parameters:**
|
||
|
||
- `identifier` - Role ID or moniker
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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):**
|
||
|
||
```json
|
||
{
|
||
"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):**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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](#object-type-field-level-gating)
|
||
|
||
**Query Parameters:**
|
||
|
||
- `page` (optional, default `1`) — Page number
|
||
- `rpp` (optional, default `30`) — Records per page
|
||
- `search` (optional) — Search by name, description, part number, vendor SKU, or manufacturer
|
||
- `includeInactive` (optional, default `false`) — Include inactive catalog items in results
|
||
- `category` (optional) — Filter by CW category **name or CW ID** (e.g. `Technology` or `18`)
|
||
- `subcategory` (optional) — Filter by CW subcategory **name or CW ID** (e.g. `Network-Switch` or `112`)
|
||
- `group` (optional) — Filter by umbrella group name (e.g. `Network`, `AlarmBurg`, `Cables`). When used with `category`, 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, default `false`) — When `true`, only return items with `onHand > 0`
|
||
- `minPrice` (optional) — Minimum price filter
|
||
- `maxPrice` (optional) — Maximum price filter
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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](#object-type-field-level-gating)
|
||
|
||
**Path Parameters:**
|
||
|
||
- `identifier` — Internal ID (cuid) or ConnectWise catalog ID (numeric)
|
||
|
||
**Query Parameters:**
|
||
|
||
- `includeLinkedItems` (optional, default `false`) — Include linked catalog items in the response
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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, default `false`) — Only count active (non-inactive) items
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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](#object-type-field-level-gating)
|
||
|
||
**Path Parameters:**
|
||
|
||
- `identifier` — Internal ID (cuid), CW identifier string, or CW catalog ID (numeric)
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"targetId": "clx..."
|
||
}
|
||
```
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"targetId": "clx..."
|
||
}
|
||
```
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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, default `false`) — Include inactive catalog items
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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](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:**
|
||
|
||
```json
|
||
{
|
||
"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](#object-type-field-level-gating)
|
||
|
||
**Query Parameters:**
|
||
|
||
- `page` (optional, default `1`) — Page number
|
||
- `rpp` (optional, default `30`) — Records per page
|
||
- `search` (optional) — Search by opportunity name
|
||
- `includeClosed` (optional, default `false`) — Include closed opportunities in results
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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",
|
||
"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, default `false`) — Only count open (non-closed) opportunities
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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](#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`. Example: `?include=notes,contacts,products`. Sub-resources are fetched in parallel and added as top-level keys on the response object. When `notes` is included, `data.notes` is returned as an array of note objects and the original opportunity text note is preserved under `data.opportunityNoteText`.
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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",
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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 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:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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 — CW `sequenceNumber` still reflects the original CW order, but the array order matches the locally stored sequence.
|
||
|
||
---
|
||
|
||
### 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.
|
||
|
||
**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.
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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.
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
- `catalogItem` is always set to the canonical catalog item with identifier `SPECIAL ORDER`
|
||
- `description` is always set from `desc`
|
||
- `customerDescription` is always set from `customerDesc` when provided
|
||
- `quantity` is always set from `qty` (default `1`)
|
||
- `price` is always set from `price`
|
||
- `cost` is always set from `cost` when provided
|
||
- `dropshipFlag` is always set to `false`
|
||
- `billableOption` is always set to `Billable`
|
||
- `taxableFlag` is set from `taxable` (or `taxableFlag`), defaulting to the catalog item's `salesTaxable` value
|
||
- `customFields` are auto-built when notes are provided:
|
||
- `procurementNotes` → `Procurement Notes` (`id: 29`)
|
||
- `productNarrative` → `Product Narrative` (`id: 46`)
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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 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:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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 `text` or `flagged` must be provided.
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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](#object-type-field-level-gating)
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"description": "New Office Site"
|
||
}
|
||
```
|
||
|
||
| Field | Type | Required | Description |
|
||
| ------------- | ------ | -------- | ---------------------------------------------- |
|
||
| `description` | string | Yes | Human-readable name / description for the site |
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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](#object-type-field-level-gating)
|
||
|
||
**URL Parameters:**
|
||
|
||
- `id` - Internal UniFi site ID (database ID)
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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.
|
||
|
||
<details>
|
||
<summary>All available field-level permission nodes</summary>
|
||
|
||
| 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` |
|
||
|
||
</details>
|
||
|
||
**URL Parameters:**
|
||
|
||
- `id` - Internal UniFi site ID (database ID)
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"name": "Lobby APs"
|
||
}
|
||
```
|
||
|
||
| Field | Type | Required | Description |
|
||
| ------ | ------ | -------- | ---------------------- |
|
||
| `name` | string | Yes | Name of the WLAN group |
|
||
|
||
**Response (201):**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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 `-1` for `downloadLimitKbps` or `uploadLimitKbps` means 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:**
|
||
|
||
```json
|
||
{
|
||
"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):**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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):**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"companyId": "ckx..."
|
||
}
|
||
```
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"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:**
|
||
|
||
```json
|
||
{
|
||
"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
|
||
|
||
```json
|
||
{
|
||
"status": 400,
|
||
"message": "Validation error",
|
||
"errors": [
|
||
{
|
||
"path": ["field"],
|
||
"message": "Field is required"
|
||
}
|
||
],
|
||
"successful": false
|
||
}
|
||
```
|
||
|
||
### 401 Unauthorized
|
||
|
||
```json
|
||
{
|
||
"status": 401,
|
||
"message": "Unauthorized",
|
||
"successful": false
|
||
}
|
||
```
|
||
|
||
### 403 Forbidden
|
||
|
||
```json
|
||
{
|
||
"status": 403,
|
||
"message": "Insufficient permissions",
|
||
"successful": false
|
||
}
|
||
```
|
||
|
||
### 404 Not Found
|
||
|
||
```json
|
||
{
|
||
"status": 404,
|
||
"message": "Resource not found",
|
||
"successful": false
|
||
}
|
||
```
|
||
|
||
### 500 Internal Server Error
|
||
|
||
```json
|
||
{
|
||
"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:
|
||
|
||
1. Call `GET /auth/uri` to get the authentication URL
|
||
2. Redirect user to the Microsoft login page
|
||
3. User authenticates and is redirected to `/auth/redirect`
|
||
4. Access and refresh tokens are provided via WebSocket or response
|
||
5. 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 resource
|
||
- `resource.fetch.many` - Read multiple resources
|
||
- `resource.create` - Create a new resource
|
||
- `resource.update` - Update a resource
|
||
- `resource.delete` - Delete a resource
|
||
- `resource.field.action` - Perform specific field operations
|
||
|
||
Users can have multiple roles, and permissions are accumulated from all assigned roles.
|