Files
optima/API_ROUTES.md
T

151 KiB
Raw Blame History

Optima API Routes Documentation

This document provides a comprehensive overview of all API routes available in the Optima API.

Base URL

http://localhost:3000/v1

WebSocket Base URL

ws://localhost:3000/socket.io/

WebSocket Events

Secure Namespace

Namespace: /secure

Authentication Required: Yes

Provide authorization during Socket.IO handshake using one of:

  • handshake.headers.authorization as Bearer <accessToken> or Key <accessToken>
  • handshake.auth.authorization as Bearer <accessToken> or Key <accessToken>
  • handshake.auth.token as raw access token

When connected, server emits secure:connected with { userId }.

If the linked session expires, is invalidated, or no longer exists, server emits secure:session:expired and disconnects.

Register Live Quote Preview Channel

Client → Server Event: opp:live_quote_preview

Registers a per-opportunity live preview channel.

Required Permissions: sales.opportunity.fetch

Payload:

{
  "id": "<opportunity-id>"
}

Ack Response (success):

{
  "ok": true,
  "event": "opp:live_quote_preview:<id>:data"
}

Ack Response (error):

{
  "ok": false,
  "error": "Missing opportunity id"
}

Server may also emit opp:live_quote_preview:ready and opp:live_quote_preview:error.

Live Quote Preview Data Channel

Client → Server Event: opp:live_quote_preview:<id>:data

After registration, clients send live quote preview options on this dynamic event.

The server will:

  1. Relay the incoming ...:data payload to other sockets in the same preview room.
  2. Fetch the target opportunity.
  3. Generate a preview PDF using generateQuote with showPreview: true.
  4. Emit the generated preview on opp:live_quote_preview:<id>:preview.

Payload (all optional):

{
  "lineItemPricing": true,
  "includeQuoteNarrative": true,
  "includeItemNarratives": true,
  "logoPath": "/absolute/or/runtime/path/to/logo.png"
}

Server → Client Event: opp:live_quote_preview:<id>:preview

{
  "id": "<opportunity-id>",
  "mimeType": "application/pdf",
  "contentBase64": "JVBERi0xLjQKJ..."
}

Server → Client Event (error): opp:live_quote_preview:error

{
  "id": "<opportunity-id>",
  "message": "Failed to generate live quote preview"
}

Object Type Field-Level Gating

All fetch and fetchAll endpoints gate response object keys via processObjectValuePerms. Each key on the returned object is checked against <scope>.<field> — only fields the user has permission for are included in the response. Grant <scope>.* to see all fields.

Object Type Scope Affected Routes
Company obj.company GET /company/companies, GET /company/companies/:identifier
Credential obj.credential GET /credential/credentials/:id, GET /credential/credentials/company/:companyId, GET /credential/credentials/:id/sub-credentials, GET /credential-type/:id/credentials
Credential Type obj.credentialType GET /credential-type/:identifier, GET /credential-type/
User obj.user GET /user/@me, GET /user/users/:identifier, GET /user/users, GET /role/:identifier/users
Role obj.role GET /role/:identifier, GET /role/, GET /user/users/:identifier/roles
Catalog Item obj.catalogItem GET /procurement/items, GET /procurement/items/:identifier, GET /procurement/items/:identifier/linked
Opportunity obj.opportunity GET /sales/opportunities, GET /sales/opportunities/:identifier
UniFi Site obj.unifiSite GET /unifi/sites, GET /unifi/site/:id, GET /company/companies/:identifier/unifi/sites
WiFi Network unifi.site.wifi.read GET /unifi/site/:id/wifi

See PERMISSIONS.md for the full list of field-level permission nodes within each scope.


Authentication Routes

ConnectWise Routes

Fetch CW Members

GET /cw/members

Returns all ConnectWise members from the server-side member cache, sorted alphabetically by name. By default only active members are returned.

Authentication Required: Yes (any authenticated user)

Permissions Required: None

Query Parameters:

Parameter Type Default Description
active string true Set to false to include inactive members

Response:

{
  "status": 200,
  "message": "CW members fetched successfully!",
  "data": [
    {
      "id": 250,
      "identifier": "jroberts",
      "firstName": "John",
      "lastName": "Roberts",
      "name": "John Roberts",
      "officeEmail": "jroberts@totaltech.net",
      "inactive": false
    }
  ],
  "successful": true
}

Receive ConnectWise Callback

POST /cw/callback/:secret/:resource

Receives ConnectWise callback/webhook payloads for supported resources and returns a normalized success response.

Authentication Required: No

Path Parameters:

  • secret — Shared callback secret, validated 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:

{
  "status": 200,
  "message": "CW callback received.",
  "data": {
    "resource": "ticket",
    "summary": {
      "resource": "ticket",
      "messageId": "1bec7421-204a-4b30-8b06-465915e9a0f5",
      "action": "updated",
      "type": "ticket",
      "id": 36073,
      "memberId": "jroberts",
      "entityStatus": "In Progress",
      "entitySummary": "Onsite Installation: Rough-In",
      "entityUpdatedBy": "cirvine",
      "entityLastUpdated": "2026-03-03T21:43:29.903"
    },
    "bodyParsed": {},
    "receivedAt": "2026-03-04T00:00:00.000Z"
  },
  "successful": true
}

Get Authentication URI

GET /auth/uri

Get the Microsoft OAuth authentication URI for user login.

Authentication Required: No

Response:

{
  "status": 200,
  "message": "Successfully fetch Auth URI",
  "data": {
    "uri": "https://login.microsoftonline.com/...",
    "callbackKey": "ck123..."
  },
  "successful": true
}

OAuth Redirect Handler

GET /auth/redirect

Handles the OAuth redirect callback from Microsoft. This endpoint processes the authorization code and creates a user session.

Authentication Required: No

Query Parameters:

  • code - Authorization code from 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:

{
  "status": 201,
  "message": "Token refreshed successfully!",
  "data": {
    "accessToken": "eyJhbGc...",
    "refreshToken": "eyJhbGc..."
  },
  "successful": true
}

User Routes

Get Current User

GET /user/@me

Fetch the currently authenticated user's information.

Authentication Required: Yes

Required Scopes: user.read

Field-Level Gating: obj.user — see Object Type Field-Level Gating

Response:

{
  "status": 200,
  "message": "Fetched user.",
  "data": {
    "id": "ckx...",
    "name": "John Doe",
    "email": "john.doe@example.com",
    "login": "john.doe",
    "image": "https://...",
    "roles": ["admin"],
    "createdAt": "2026-01-01T00:00:00.000Z",
    "updatedAt": "2026-02-14T00:00:00.000Z"
  },
  "successful": true
}

Update Current User

PATCH /user/@me

Update the currently authenticated user's information.

Authentication Required: Yes

Required Scopes: user.write

Request Body:

{
  "name": "Jane Doe",
  "image": "https://example.com/avatar.jpg"
}

Response:

{
  "status": 200,
  "message": "Successfully updated user.",
  "data": {
    "id": "ckx...",
    "name": "Jane Doe",
    "email": "jane.doe@example.com",
    "image": "https://example.com/avatar.jpg"
  },
  "successful": true
}

Check User Permissions

POST /user/@me/check-permission

Check if the currently authenticated user has specific permissions. Accepts an array of permission nodes and returns the status for each.

Authentication Required: Yes

Required Scopes: user.read

Request Body:

{
  "permissions": ["user.read", "company.create", "credential.write"]
}

Response:

{
  "status": 200,
  "message": "Permission check completed.",
  "data": {
    "results": [
      {
        "permission": "user.read",
        "hasPermission": true
      },
      {
        "permission": "company.create",
        "hasPermission": false
      },
      {
        "permission": "credential.write",
        "hasPermission": true
      }
    ]
  },
  "successful": true
}

Other User Routes

Get All Users

GET /user/users

Fetch a list of all users.

Authentication Required: Yes

Required Permissions: user.read.other, user.list.other

Field-Level Gating: obj.user — see Object Type Field-Level Gating

Response:

{
  "status": 200,
  "message": "Users Fetched Successfully!",
  "data": [
    {
      "id": "ckx...",
      "name": "John Doe",
      "email": "john.doe@example.com",
      "login": "john.doe",
      "image": "https://...",
      "roles": ["admin"]
    }
  ],
  "successful": true
}

Get User by ID

GET /user/users/:identifier

Fetch a specific user by their ID.

Authentication Required: Yes

Required Permissions: user.read.other

Field-Level Gating: obj.user — see Object Type Field-Level Gating

Path Parameters:

  • identifier - The user's ID

Response:

{
  "status": 200,
  "message": "User Fetched Successfully!",
  "data": {
    "id": "ckx...",
    "name": "John Doe",
    "email": "john.doe@example.com",
    "login": "john.doe",
    "image": "https://...",
    "roles": ["admin"]
  },
  "successful": true
}

Error Response (404):

{
  "status": 404,
  "message": "User with identifier 'ckx...' was not found.",
  "error": "UserNotFound",
  "successful": false
}

Update User by ID

PATCH /user/users/:identifier

Update a specific user's information. Supports updating profile fields, roles, and direct permissions.

Authentication Required: Yes

Required Permissions: user.write.other

Conditional Permissions:

  • If 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.

{
  "name": "Jane Doe",
  "image": "https://example.com/avatar.jpg",
  "roles": ["admin", "moderator"],
  "permissions": ["credential.fetch", "company.fetch"]
}
Field Type Description
name string The user's display name
image string URL to the user's avatar image
roles string[] Array of role ids or monikers to assign (replaces all roles)
permissions string[] Array of permission nodes to assign (replaces all permissions)

Response:

{
  "status": 200,
  "message": "User Updated Successfully!",
  "data": {
    "id": "ckx...",
    "name": "Jane Doe",
    "email": "jane.doe@example.com",
    "login": "jane.doe",
    "image": "https://example.com/avatar.jpg",
    "roles": ["admin", "moderator"]
  },
  "successful": true
}

Error Response (403 - Missing role permission):

{
  "status": 403,
  "message": "You do not have permission to modify roles on another user.",
  "error": "InsufficientPermission",
  "successful": false
}

Delete User by ID

DELETE /user/users/:identifier

Delete a specific user.

Authentication Required: Yes

Required Permissions: user.delete.other

Path Parameters:

  • identifier - The user's ID

Response:

{
  "status": 200,
  "message": "User Deleted Successfully!",
  "data": {
    "id": "ckx...",
    "name": "John Doe",
    "email": "john.doe@example.com",
    "login": "john.doe",
    "image": "https://...",
    "roles": ["admin"]
  },
  "successful": true
}

Get User Roles

GET /user/users/:identifier/roles

Fetch all roles assigned to a specific user.

Authentication Required: Yes

Required Permissions: user.read.other, role.read

Field-Level Gating: obj.role — see Object Type Field-Level Gating

Path Parameters:

  • identifier - The user's ID

Response:

{
  "status": 200,
  "message": "User Roles Fetched Successfully!",
  "data": [
    {
      "id": "uuid...",
      "title": "Administrator",
      "moniker": "admin",
      "permissions": ["*"]
    }
  ],
  "successful": true
}

Check User Permissions (Other User)

POST /user/users/:identifier/check-permission

Check if a specific user has certain permissions.

Authentication Required: Yes

Required Permissions: user.read.other

Path Parameters:

  • identifier - The user's ID

Request Body:

{
  "permissions": ["user.read", "company.fetch", "credential.write"]
}

Response:

{
  "status": 200,
  "message": "Permission check completed.",
  "data": {
    "results": [
      {
        "permission": "user.read",
        "hasPermission": true
      },
      {
        "permission": "company.fetch",
        "hasPermission": false
      },
      {
        "permission": "credential.write",
        "hasPermission": true
      }
    ]
  },
  "successful": true
}

Company Routes

Get All Companies

GET /company/companies

Fetch a paginated list of all companies with optional search functionality.

Authentication Required: Yes

Required Permissions: company.fetch.many

Field-Level Gating: obj.company — see Object Type Field-Level Gating

Query Parameters:

  • page (optional) - Page number (default: 1)
  • rpp (optional) - Records per page (default: 30)
  • search (optional) - Search query to filter companies

Response:

{
  "status": 200,
  "message": "Companies Fetched Successfully!",
  "data": [
    {
      "id": "ckx...",
      "name": "Acme Corp",
      "cw_CompanyId": 12345,
      "cw_Identifier": "AcmeCorp"
    }
  ],
  "pagination": {
    "previousPage": null,
    "currentPage": 1,
    "nextPage": 2,
    "totalPages": 10,
    "totalRecords": 300,
    "listedRecords": 30
  },
  "successful": true
}

Get Company by ID

GET /company/companies/:identifier

Fetch a single company by its ID. Automatically fetches fresh data from ConnectWise and returns it along with internal company data.

Authentication Required: Yes

Required Permissions:

  • company.fetch (base permission)
  • company.fetch.address (required when includeAddress=true)
  • company.fetch.contacts (required when includeAllContacts=true)

Field-Level Gating: obj.company — see Object Type Field-Level Gating

URL Parameters:

  • identifier - Company ID (internal database ID)

Query Parameters:

  • includeAddress (optional) - Set to "true" to include full address information. 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):

{
  "status": 200,
  "message": "Company Fetched Successfully!",
  "data": {
    "id": "ckx...",
    "name": "Acme Corp",
    "cw_CompanyId": 12345,
    "cw_Identifier": "AcmeCorp",
    "cw_Data": {}
  },
  "successful": true
}

Response (with includeAddress=true):

{
  "status": 200,
  "message": "Company Fetched Successfully!",
  "data": {
    "id": "ckx...",
    "name": "Acme Corp",
    "cw_CompanyId": 12345,
    "cw_Identifier": "AcmeCorp",
    "cw_Data": {
      "address": {
        "line1": "123 Main St",
        "line2": null,
        "city": "Springfield",
        "state": "IL",
        "zip": "62701",
        "country": "United States"
      }
    }
  },
  "successful": true
}

Response (with includePrimaryContact=true):

{
  "status": 200,
  "message": "Company Fetched Successfully!",
  "data": {
    "id": "ckx...",
    "name": "Acme Corp",
    "cw_CompanyId": 12345,
    "cw_Identifier": "AcmeCorp",
    "cw_Data": {
      "primaryContact": {
        "firstName": "John",
        "lastName": "Doe",
        "cwId": 456,
        "inactive": false,
        "title": "IT Manager",
        "phone": "555-0123",
        "email": "john.doe@acmecorp.com"
      }
    }
  },
  "successful": true
}

Response (with includeAllContacts=true):

{
  "status": 200,
  "message": "Company Fetched Successfully!",
  "data": {
    "id": "ckx...",
    "name": "Acme Corp",
    "cw_CompanyId": 12345,
    "cw_Identifier": "AcmeCorp",
    "cw_Data": {
      "allContacts": [
        {
          "firstName": "John",
          "lastName": "Doe",
          "cwId": 456,
          "inactive": false,
          "title": "IT Manager",
          "phone": "555-0123",
          "email": "john.doe@acmecorp.com"
        },
        {
          "firstName": "Jane",
          "lastName": "Smith",
          "cwId": 789,
          "inactive": false,
          "title": "CTO",
          "phone": "555-0456",
          "email": "jane.smith@acmecorp.com"
        }
      ]
    }
  },
  "successful": true
}

Get Company Configurations

GET /company/companies/:identifier/configurations

Fetch configurations for a specific company from ConnectWise.

Authentication Required: Yes

Required Permissions: company.fetch, company.fetch.configurations

URL Parameters:

  • identifier - Company ID

Response:

{
  "status": 200,
  "message": "Company Configurations Fetched Successfully!",
  "data": {
    // ConnectWise configuration data
  },
  "successful": true
}

Get Company UniFi Sites

GET /company/companies/:identifier/unifi/sites

Fetch all UniFi sites linked to a specific company.

Authentication Required: Yes

Required Permissions: unifi.access, company.fetch

Field-Level Gating: obj.unifiSite — see Object Type Field-Level Gating

URL Parameters:

  • identifier - Company ID

Response:

{
  "status": 200,
  "message": "Company UniFi Sites Fetched Successfully!",
  "data": [
    {
      "id": "ckx...",
      "name": "Main Office",
      "siteId": "abc123",
      "companyId": "ckx...",
      "createdAt": "2025-01-01T00:00:00.000Z",
      "updatedAt": "2025-01-01T00:00:00.000Z"
    }
  ],
  "successful": true
}

Credential Routes

Get Value Types

GET /credential/valuetypes

Returns all available field value types for credential type fields.

Authentication Required: Yes

Response:

{
  "status": 200,
  "message": "Value Types Fetched Successfully!",
  "data": [
    "plain_text",
    "license_key",
    "ip_address",
    "generic_secret",
    "bitlocker_key",
    "password",
    "multi_credential"
  ],
  "successful": true
}

Get Credential by ID

GET /credential/credentials/:id

Fetch a single credential by its ID.

Authentication Required: Yes

Required Permissions: credential.fetch

Field-Level Gating: obj.credential — see Object Type Field-Level Gating

URL Parameters:

  • id - Credential ID

Response:

{
  "status": 200,
  "message": "Credential Fetched Successfully!",
  "data": {
    "id": "ckx...",
    "name": "AWS Credentials",
    "notes": null,
    "typeId": "cky...",
    "companyId": "ckz...",
    "fields": [
      {
        "id": "accessKeyId",
        "name": "Access Key ID",
        "secure": false,
        "required": true,
        "valueType": "plain_text",
        "value": "AKIAIOSFODNN7EXAMPLE"
      },
      {
        "id": "secretAccessKey",
        "name": "Secret Access Key",
        "secure": true,
        "required": true,
        "valueType": "password",
        "value": null
      }
    ],
    "type": {
      "id": "cky...",
      "name": "AWS",
      "fields": [...],
      "permissionScope": "aws.credentials"
    },
    "company": {
      "id": "ckz...",
      "name": "Acme Corp"
    },
    "createdAt": "2026-01-01T00:00:00.000Z",
    "updatedAt": "2026-02-14T00:00:00.000Z"
  },
  "successful": true
}

Get Credentials by Company

GET /credential/credentials/company/:companyId

Fetch all credentials associated with a specific company.

Authentication Required: Yes

Required Permissions: credential.fetch.many

Field-Level Gating: obj.credential — see Object Type Field-Level Gating

URL Parameters:

  • companyId - Company ID

Response:

{
  "status": 200,
  "message": "Company Credentials Fetched Successfully!",
  "data": [
    {
      "id": "ckx...",
      "name": "AWS Credentials",
      "notes": null,
      "typeId": "cky...",
      "companyId": "ckz...",
      "fields": [
        {
          "id": "accessKeyId",
          "name": "Access Key ID",
          "secure": false,
          "required": true,
          "valueType": "plain_text",
          "value": "AKIAIOSFODNN7EXAMPLE"
        },
        {
          "id": "secretAccessKey",
          "name": "Secret Access Key",
          "secure": true,
          "required": true,
          "valueType": "password",
          "value": null
        }
      ],
      "type": {...},
      "company": {...}
    }
  ],
  "successful": true
}

Create Credential

POST /credential/credentials

Create a new credential with validated and encrypted fields.

Authentication Required: Yes

Required Permissions: credential.create

Request Body:

{
  "name": "Production AWS Credentials",
  "notes": "Used for production S3 access",
  "typeId": "cky...",
  "companyId": "ckz...",
  "fields": [
    {
      "id": "ckx1...",
      "fieldId": "accessKeyId",
      "value": "AKIAIOSFODNN7EXAMPLE"
    },
    {
      "id": "ckx2...",
      "fieldId": "secretAccessKey",
      "value": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
    }
  ],
  "subCredentials": {
    "tunnels": [
      {
        "name": "Tunnel 1",
        "fields": [
          { "fieldId": "server", "value": "vpn1.example.com" },
          { "fieldId": "port", "value": "443" }
        ]
      }
    ]
  }
}
Field Type Required Description
name string Yes Display name for the credential
notes string No Optional notes
typeId string Yes The credential type ID
companyId string Yes The company ID this credential belongs to
fields array Yes Array of field values ({ fieldId, value })
subCredentials object No Keyed by multi-credential field ID. Each value is an array of { name, fields } objects for inline sub-credentials

**Response:**

```json
{
  "status": 201,
  "message": "Credential Created Successfully!",
  "data": {
    "id": "ckx...",
    "name": "Production AWS Credentials",
    "typeId": "cky...",
    "companyId": "ckz...",
    "fields": [
      {
        "id": "accessKeyId",
        "name": "Access Key ID",
        "secure": false,
        "required": true,
        "valueType": "plain_text",
        "value": "AKIAIOSFODNN7EXAMPLE"
      },
      {
        "id": "secretAccessKey",
        "name": "Secret Access Key",
        "secure": true,
        "required": true,
        "valueType": "password",
        "value": null
      }
    ],
    "type": {...},
    "company": {...}
  },
  "successful": true
}

Update Credential

PATCH /credential/credentials/:id

Update a credential's basic properties (name, notes) and/or field values. Secure fields are automatically encrypted.

Authentication Required: Yes

Required Permissions: credential.update

URL Parameters:

  • id - Credential ID

Request Body:

All properties are optional. Include only the properties you want to update.

{
  "name": "Updated Credential Name",
  "notes": "Updated notes for this credential",
  "fields": [
    {
      "fieldId": "accessKeyId",
      "value": "AKIAIOSFODNN7EXAMPLE"
    },
    {
      "fieldId": "secretAccessKey",
      "value": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
    }
  ]
}

Response:

{
  "status": 200,
  "message": "Credential Updated Successfully!",
  "data": {
    "id": "ckx...",
    "name": "Updated Credential Name",
    "notes": "Updated notes for this credential",
    "typeId": "cky...",
    "companyId": "ckz...",
    "fields": [
      {
        "id": "accessKeyId",
        "name": "Access Key ID",
        "secure": false,
        "required": true,
        "valueType": "plain_text",
        "value": "AKIAIOSFODNN7EXAMPLE"
      }
    ],
    "type": {...},
    "company": {...}
  },
  "successful": true
}

Update Credential Fields

PUT /credential/credentials/:id/fields

Validate and update credential field values. Secure fields are automatically encrypted.

Authentication Required: Yes

Required Permissions: credential.update, credential.fields.update

URL Parameters:

  • id - Credential ID

Request Body:

{
  "fields": [
    {
      "fieldId": "accessKeyId",
      "value": "AKIAIOSFODNN7NEWVALUE"
    },
    {
      "fieldId": "secretAccessKey",
      "value": "newSecretKeyValue123"
    }
  ]
}

Response:

{
  "status": 200,
  "message": "Credential Fields Updated Successfully!",
  "data": {
    "id": "ckx...",
    "name": "Production AWS Credentials",
    "notes": null,
    "typeId": "cky...",
    "companyId": "ckz...",
    "fields": [
      {
        "id": "accessKeyId",
        "name": "Access Key ID",
        "secure": false,
        "required": true,
        "valueType": "plain_text",
        "value": "AKIAIOSFODNN7NEWVALUE"
      },
      {
        "id": "secretAccessKey",
        "name": "Secret Access Key",
        "secure": true,
        "required": true,
        "valueType": "password",
        "value": null
      }
    ],
    "type": {...},
    "company": {...}
  },
  "successful": true
}

Get Credential Fields

GET /credential/credentials/:id/fields

Fetch all field values for a credential (secure fields returned encrypted).

Authentication Required: Yes

Required Permissions: credential.fetch, credential.fields.fetch

URL Parameters:

  • id - Credential ID

Response:

{
  "status": 200,
  "message": "Credential Fields Fetched Successfully!",
  "data": [
    {
      "id": "ckx-accessKeyId",
      "fieldId": "accessKeyId",
      "value": "AKIAIOSFODNN7EXAMPLE"
    },
    {
      "id": "ckx1...",
      "fieldId": "secretAccessKey",
      "value": "base64EncryptedValue=="
    }
  ],
  "successful": true
}

Read Secure Values

GET /credential/credentials/:id/secure-values

Decrypt and return all secure field values for a credential.

Authentication Required: Yes

Required Permissions: credential.fetch, credential.secure_values.read

URL Parameters:

  • id - Credential ID

Response:

{
  "status": 200,
  "message": "Secure Values Fetched Successfully!",
  "data": {
    "secretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
    "apiKey": "sk_live_123456789abcdef"
  },
  "successful": true
}

Read Single Secure Value

GET /credential/credentials/:id/secure-values/:fieldId

Decrypt and return a single secure field value for a credential.

Authentication Required: Yes

Required Permissions: credential.fetch, credential.secure_values.read

URL Parameters:

  • id - Credential ID
  • fieldId - The field ID of the secure value to read

Response:

{
  "status": 200,
  "message": "Secure Value Fetched Successfully!",
  "data": {
    "fieldId": "secretAccessKey",
    "value": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
  },
  "successful": true
}

Error Response (404):

{
  "status": 404,
  "message": "Secure field not found: unknownField",
  "error": "SecureFieldNotFound",
  "successful": false
}

Delete Credential

DELETE /credential/credentials/:id

Delete a credential and all associated secure values. Sub-credentials are cascade-deleted automatically.

Authentication Required: Yes

Required Permissions: credential.delete

URL Parameters:

  • id - Credential ID

Response:

{
  "status": 200,
  "message": "Credential Deleted Successfully!",
  "data": null,
  "successful": true
}

Get Sub-Credentials

GET /credential/credentials/:id/sub-credentials

Fetch all sub-credentials that belong to a specific parent credential.

Authentication Required: Yes

Required Permissions: credential.fetch, credential.sub_credentials.fetch

Field-Level Gating: obj.credential — see Object Type Field-Level Gating

URL Parameters:

  • id - Parent Credential ID

Response:

{
  "status": 200,
  "message": "Sub-Credentials Fetched Successfully!",
  "data": [
    {
      "id": "ckx1...",
      "name": "Tunnel 1",
      "notes": null,
      "typeId": "cky...",
      "companyId": "ckz...",
      "subCredentialOfId": "ckx...",
      "fields": [
        { "id": "server", "value": "vpn1.example.com", "secure": false },
        { "id": "port", "value": "443", "secure": false }
      ],
      "type": { "..." },
      "company": { "..." },
      "createdAt": "2026-02-20T00:00:00.000Z",
      "updatedAt": "2026-02-20T00:00:00.000Z"
    }
  ],
  "successful": true
}

Add Sub-Credential

POST /credential/credentials/:id/sub-credentials

Create a new sub-credential under an existing parent credential for a specific multi-credential field.

Authentication Required: Yes

Required Permissions: credential.fetch, credential.sub_credentials.create

URL Parameters:

  • id - Parent Credential ID

Request Body:

{
  "fieldId": "tunnels",
  "name": "Tunnel 2",
  "fields": [
    { "fieldId": "server", "value": "vpn2.example.com" },
    { "fieldId": "port", "value": "1194" }
  ]
}
Field Type Required Description
fieldId string Yes The multi-credential field ID on the parent credential type
name string Yes Display name for the sub-credential
fields array Yes Array of field values matching the multi-credential's subFields

Response:

{
  "status": 201,
  "message": "Sub-Credential Created Successfully!",
  "data": {
    "id": "ckx2...",
    "name": "Tunnel 2",
    "typeId": "cky...",
    "companyId": "ckz...",
    "subCredentialOfId": "ckx...",
    "fields": [
      { "id": "server", "value": "vpn2.example.com", "secure": false },
      { "id": "port", "value": "1194", "secure": false }
    ],
    "type": { "..." },
    "company": { "..." },
    "createdAt": "2026-02-20T00:00:00.000Z",
    "updatedAt": "2026-02-20T00:00:00.000Z"
  },
  "successful": true
}

Remove Sub-Credential

DELETE /credential/credentials/:id/sub-credentials/:subId

Delete a sub-credential and remove its reference from the parent credential's multi-credential field.

Authentication Required: Yes

Required Permissions: credential.fetch, credential.sub_credentials.delete

URL Parameters:

  • id - Parent Credential ID
  • subId - Sub-Credential ID to remove

Response:

{
  "status": 200,
  "message": "Sub-Credential Removed Successfully!",
  "data": null,
  "successful": true
}

Error Response (404):

{
  "status": 404,
  "message": "Sub-credential not found",
  "error": "SubCredentialNotFound",
  "successful": false
}

Credential Type Routes

Get Credential Type by ID or Name

GET /credential-type/:identifier

Fetch a single credential type by its ID or name.

Authentication Required: Yes

Required Permissions: credential_type.fetch

Field-Level Gating: obj.credentialType — see Object Type Field-Level Gating

URL Parameters:

  • identifier - Credential Type ID or name

Response:

{
  "status": 200,
  "message": "Credential Type Fetched Successfully!",
  "data": {
    "id": "cky...",
    "name": "AWS",
    "permissionScope": "aws.credentials",
    "icon": "https://aws.amazon.com/favicon.ico",
    "fields": [
      {
        "id": "accessKeyId",
        "name": "Access Key ID",
        "required": true,
        "secure": false,
        "valueType": "plain_text"
      },
      {
        "id": "secretAccessKey",
        "name": "Secret Access Key",
        "required": true,
        "secure": true,
        "valueType": "password"
      }
    ],
    "credentialCount": 5,
    "createdAt": "2026-01-01T00:00:00.000Z",
    "updatedAt": "2026-02-14T00:00:00.000Z"
  },
  "successful": true
}

Get All Credential Types

GET /credential-type

Fetch all credential types in the system.

Authentication Required: Yes

Required Permissions: credential_type.fetch.many

Field-Level Gating: obj.credentialType — see Object Type Field-Level Gating

Response:

{
  "status": 200,
  "message": "Credential Types Fetched Successfully!",
  "data": [
    {
      "id": "cky...",
      "name": "AWS",
      "permissionScope": "aws.credentials",
      "icon": "https://aws.amazon.com/favicon.ico",
      "fields": [...],
      "credentialCount": 5
    },
    {
      "id": "ckz...",
      "name": "Azure",
      "permissionScope": "azure.credentials",
      "icon": "https://azure.microsoft.com/favicon.ico",
      "fields": [...],
      "credentialCount": 3
    }
  ],
  "successful": true
}

Create Credential Type

POST /credential-type

Create a new credential type with field definitions.

Authentication Required: Yes

Required Permissions: credential_type.create

Request Body:

{
  "name": "GitHub",
  "permissionScope": "github.credentials",
  "icon": "https://github.com/favicon.ico",
  "fields": [
    {
      "id": "username",
      "name": "Username",
      "required": true,
      "secure": false,
      "valueType": "plain_text"
    },
    {
      "id": "personalAccessToken",
      "name": "Personal Access Token",
      "required": true,
      "secure": true,
      "valueType": "password"
    }
  ]
}

Multi-Credential Example:

Fields with valueType: "multi_credential" support an optional subFields array that defines the field structure for each sub-credential entry. Sub-fields use the same schema and can be nested recursively.

{
  "name": "VPN Config",
  "permissionScope": "vpn.credentials",
  "icon": "https://example.com/vpn.ico",
  "fields": [
    {
      "id": "hostname",
      "name": "Hostname",
      "required": true,
      "secure": false,
      "valueType": "plain_text"
    },
    {
      "id": "tunnels",
      "name": "Tunnels",
      "required": false,
      "secure": false,
      "valueType": "multi_credential",
      "subFields": [
        {
          "id": "server",
          "name": "Server",
          "required": true,
          "secure": false,
          "valueType": "plain_text"
        },
        {
          "id": "psk",
          "name": "Pre-Shared Key",
          "required": true,
          "secure": true,
          "valueType": "password"
        }
      ]
    }
  ]
}
Field Type Required Description
fields[].id string Yes Unique identifier for the field
fields[].name string Yes Display name for the field
fields[].required bool Yes Whether the field is required when creating a credential
fields[].secure bool Yes Whether the field value should be encrypted at rest
fields[].valueType string Yes One of the supported value types (see GET /credential/valuetypes)
fields[].subFields array No Only for multi_credential fields. Defines the field structure for each nested sub-credential

Response:

{
  "status": 201,
  "message": "Credential Type Created Successfully!",
  "data": {
    "id": "ck1...",
    "name": "GitHub",
    "permissionScope": "github.credentials",
    "icon": "https://github.com/favicon.ico",
    "fields": [...]
  },
  "successful": true
}

Update Credential Type

PATCH /credential-type/:id

Update a credential type's properties or field definitions.

Authentication Required: Yes

Required Permissions: credential_type.update

URL Parameters:

  • id - Credential Type ID

Request Body:

{
  "name": "GitHub Enterprise",
  "icon": "https://github.enterprise.com/favicon.ico",
  "fields": [
    {
      "id": "username",
      "name": "Username",
      "required": true,
      "secure": false,
      "valueType": "plain_text"
    },
    {
      "id": "personalAccessToken",
      "name": "Personal Access Token",
      "required": true,
      "secure": true,
      "valueType": "password"
    },
    {
      "id": "enterpriseUrl",
      "name": "Enterprise URL",
      "required": true,
      "secure": false,
      "valueType": "plain_text"
    }
  ]
}

Note: Fields with valueType: "multi_credential" support an optional subFields array — see the Create Credential Type section for the full schema and example.

Response:

{
  "status": 200,
  "message": "Credential Type Updated Successfully!",
  "data": {
    "id": "ck1...",
    "name": "GitHub Enterprise",
    "fields": [...]
  },
  "successful": true
}

Delete Credential Type

DELETE /credential-type/:id

Delete a credential type. This will cascade delete all credentials of this type.

Authentication Required: Yes

Required Permissions: credential_type.delete

URL Parameters:

  • id - Credential Type ID

Response:

{
  "status": 200,
  "message": "Credential Type Deleted Successfully!",
  "data": null,
  "successful": true
}

Get Credentials by Type

GET /credential-type/:id/credentials

Fetch all credentials that use a specific credential type.

Authentication Required: Yes

Required Permissions: credential_type.fetch, credential.fetch.many

Field-Level Gating: obj.credential — see Object Type Field-Level Gating

URL Parameters:

  • id - Credential Type ID

Response:

{
  "status": 200,
  "message": "Credentials Fetched Successfully!",
  "data": [
    {
      "id": "ckx...",
      "name": "Production AWS",
      "typeId": "cky...",
      "companyId": "ckz...",
      "fields": {...}
    },
    {
      "id": "ck2...",
      "name": "Staging AWS",
      "typeId": "cky...",
      "companyId": "ckz...",
      "fields": {...}
    }
  ],
  "successful": true
}

Role Routes

Create Role

POST /role

Create a new role with a title, moniker, and optional permissions.

Authentication Required: Yes

Required Permissions: role.create

Request Body:

{
  "title": "System Administrator",
  "moniker": "system_admin",
  "permissions": [
    "user.read",
    "user.write",
    "company.fetch",
    "credential.create"
  ]
}

Response:

{
  "status": 201,
  "message": "Role Created Successfully!",
  "data": {
    "id": "ckx...",
    "title": "System Administrator",
    "moniker": "system_admin",
    "permissions": [
      "user.read",
      "user.write",
      "company.fetch",
      "credential.create"
    ],
    "createdAt": "2026-02-17T00:00:00.000Z",
    "updatedAt": "2026-02-17T00:00:00.000Z"
  },
  "successful": true
}

Get Role by ID or Moniker

GET /role/:identifier

Fetch a single role by its ID or moniker.

Authentication Required: Yes

Required Permissions: role.read

Field-Level Gating: obj.role — see Object Type Field-Level Gating

URL Parameters:

  • identifier - Role ID or moniker

Response:

{
  "status": 200,
  "message": "Role Fetched Successfully!",
  "data": {
    "id": "ckx...",
    "title": "System Administrator",
    "moniker": "system_admin",
    "permissions": [
      "user.read",
      "user.write",
      "company.fetch",
      "credential.create"
    ],
    "createdAt": "2026-02-17T00:00:00.000Z",
    "updatedAt": "2026-02-17T00:00:00.000Z"
  },
  "successful": true
}

Get All Roles

GET /role

Fetch all roles in the system.

Authentication Required: Yes

Required Permissions: role.read, role.list

Field-Level Gating: obj.role — see Object Type Field-Level Gating

Response:

{
  "status": 200,
  "message": "Roles Fetched Successfully!",
  "data": [
    {
      "id": "ckx...",
      "title": "System Administrator",
      "moniker": "system_admin",
      "permissions": ["user.read", "user.write"],
      "createdAt": "2026-02-17T00:00:00.000Z",
      "updatedAt": "2026-02-17T00:00:00.000Z"
    },
    {
      "id": "cky...",
      "title": "Viewer",
      "moniker": "viewer",
      "permissions": ["user.read", "company.fetch"],
      "createdAt": "2026-02-17T00:00:00.000Z",
      "updatedAt": "2026-02-17T00:00:00.000Z"
    }
  ],
  "successful": true
}

Update Role

PATCH /role/:identifier

Update a role's title, moniker, or permissions.

Authentication Required: Yes

Required Permissions: role.modify

URL Parameters:

  • identifier - Role ID or moniker

Request Body:

{
  "title": "Super Administrator",
  "moniker": "super_admin",
  "permissions": ["*"]
}

Response:

{
  "status": 200,
  "message": "Role Updated Successfully!",
  "data": {
    "id": "ckx...",
    "title": "Super Administrator",
    "moniker": "super_admin",
    "permissions": ["*"],
    "createdAt": "2026-02-17T00:00:00.000Z",
    "updatedAt": "2026-02-17T12:00:00.000Z"
  },
  "successful": true
}

Delete Role

DELETE /role/:identifier

Delete a role. This will remove the role from all users that have it assigned.

Authentication Required: Yes

Required Permissions: role.delete

URL Parameters:

  • identifier - Role ID or moniker

Response:

{
  "status": 200,
  "message": "Role Deleted Successfully!",
  "data": {
    "id": "ckx...",
    "title": "System Administrator",
    "moniker": "system_admin",
    "createdAt": "2026-02-17T00:00:00.000Z",
    "updatedAt": "2026-02-17T12:00:00.000Z"
  },
  "successful": true
}

Add Permissions to Role

POST /role/:identifier/permissions

Add one or more permissions to an existing role. The new permissions will be merged with existing permissions.

Authentication Required: Yes

Required Permissions: role.modify

URL Parameters:

  • identifier - Role ID or moniker

Request Body:

{
  "permissions": ["credential.update", "credential.delete"]
}

Response:

{
  "status": 200,
  "message": "Permissions Added Successfully!",
  "data": {
    "id": "ckx...",
    "title": "System Administrator",
    "moniker": "system_admin",
    "permissions": [
      "user.read",
      "user.write",
      "company.fetch",
      "credential.create",
      "credential.update",
      "credential.delete"
    ],
    "createdAt": "2026-02-17T00:00:00.000Z",
    "updatedAt": "2026-02-17T12:30:00.000Z"
  },
  "successful": true
}

Remove Permissions from Role

DELETE /role/:identifier/permissions

Remove one or more permissions from an existing role.

Authentication Required: Yes

Required Permissions: role.modify

URL Parameters:

  • identifier - Role ID or moniker

Request Body:

{
  "permissions": ["credential.delete"]
}

Response:

{
  "status": 200,
  "message": "Permissions Removed Successfully!",
  "data": {
    "id": "ckx...",
    "title": "System Administrator",
    "moniker": "system_admin",
    "permissions": [
      "user.read",
      "user.write",
      "company.fetch",
      "credential.create",
      "credential.update"
    ],
    "createdAt": "2026-02-17T00:00:00.000Z",
    "updatedAt": "2026-02-17T12:45:00.000Z"
  },
  "successful": true
}

Get Users with Role

GET /role/:identifier/users

Fetch all users that have been assigned a specific role.

Authentication Required: Yes

Required Permissions: role.read, user.read

Field-Level Gating: obj.user — see Object Type Field-Level Gating

URL Parameters:

  • identifier - Role ID or moniker

Response:

{
  "status": 200,
  "message": "Users Fetched Successfully!",
  "data": [
    {
      "id": "cku...",
      "name": "John Doe",
      "login": "john.doe",
      "roles": ["ckx..."],
      "createdAt": "2026-01-15T00:00:00.000Z",
      "updatedAt": "2026-02-10T00:00:00.000Z"
    },
    {
      "id": "ckv...",
      "name": "Jane Smith",
      "login": "jane.smith",
      "roles": ["ckx...", "cky..."],
      "createdAt": "2026-01-20T00:00:00.000Z",
      "updatedAt": "2026-02-12T00:00:00.000Z"
    }
  ],
  "successful": true
}

Permission Routes

Get All Permission Nodes (Categorized)

GET /permissions

Fetch all permission nodes organized by category. Returns the full permission node definition object with categories as keys.

Authentication Required: Yes

Required Permissions: role.read

Response:

{
  "status": 200,
  "message": "Permission Nodes Fetched Successfully!",
  "data": {
    "global": {
      "name": "Global Permissions",
      "description": "Global wildcard permissions that grant access to all resources",
      "permissions": [
        {
          "node": "*",
          "description": "Full access to all resources and actions (administrator role)",
          "usedIn": []
        }
      ]
    },
    "company": { "..." },
    "credential": { "..." },
    "...additional categories": { "..." }
  },
  "successful": true
}

Get All Permission Nodes (Flat)

GET /permissions/nodes

Fetch a flat array of all permission nodes across all categories.

Authentication Required: Yes

Required Permissions: role.read

Response:

{
  "status": 200,
  "message": "All Permission Nodes Fetched Successfully!",
  "data": [
    {
      "node": "*",
      "description": "Full access to all resources and actions (administrator role)",
      "usedIn": []
    },
    {
      "node": "company.fetch",
      "description": "Fetch a single company",
      "usedIn": ["src/api/companies/[id]/fetch.ts"]
    },
    "...additional nodes"
  ],
  "successful": true
}

Get Permission Nodes by Category

GET /permissions/:category

Fetch all permission nodes for a specific category.

Authentication Required: Yes

Required Permissions: role.read

Path Parameters:

  • category - The category key (e.g., global, company, credential, credentialType, role, user, permission, uiNavigation, adminUI)

Response (Success):

{
  "status": 200,
  "message": "Permission Category Fetched Successfully!",
  "data": {
    "name": "Company Permissions",
    "description": "Permissions for accessing and managing company resources",
    "permissions": [
      {
        "node": "company.fetch",
        "description": "Fetch a single company",
        "usedIn": ["src/api/companies/[id]/fetch.ts"]
      },
      {
        "node": "company.fetch.address",
        "description": "View company address information",
        "usedIn": ["src/api/companies/[id]/fetch.ts"],
        "dependencies": ["company.fetch"]
      }
    ]
  },
  "successful": true
}

Response (Not Found):

{
  "status": 404,
  "message": "Permission category \"invalidCategory\" not found",
  "error": "NotFound",
  "successful": false
}

Utility Routes

Teapot

GET /teapot

A fun Easter egg endpoint that returns HTTP 418 (I'm a teapot).

Authentication Required: No

Response:

{
  "status": 418,
  "message": "I'm a teapot",
  "successful": false
}

Procurement Routes

Get All Catalog Items

GET /procurement/items

Fetch a paginated list of catalog items. Supports search.

Authentication Required: Yes

Required Permissions: procurement.catalog.fetch.many

Field-Level Gating: obj.catalogItem — see Object Type Field-Level Gating

Query Parameters:

  • page (optional, 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:

{
  "status": 200,
  "message": "Catalog items fetched successfully!",
  "data": [
    {
      "id": "clx...",
      "cwCatalogId": 123,
      "name": "Dell OptiPlex 7020",
      "description": "Dell OptiPlex 7020 SFF Desktop",
      "customerDescription": "Business Desktop Computer",
      "internalNotes": null,
      "category": "Technology",
      "categoryCwId": 18,
      "subcategory": "Computer-Desktop",
      "subcategoryCwId": 106,
      "manufacturer": "Dell",
      "manufactureCwId": 45,
      "partNumber": "OPT7020-SFF",
      "vendorName": "Dell Direct",
      "vendorSku": "DELL-OPT7020",
      "vendorCwId": 12,
      "price": 899.99,
      "cost": 650.0,
      "inactive": false,
      "salesTaxable": true,
      "onHand": 5,
      "cwLastUpdated": "2026-02-25T10:00:00.000Z",
      "createdAt": "2026-01-15T00:00:00.000Z",
      "updatedAt": "2026-02-25T10:00:00.000Z"
    }
  ],
  "meta": {
    "pagination": {
      "previousPage": null,
      "currentPage": 1,
      "nextPage": 2,
      "totalPages": 10,
      "totalRecords": 300,
      "listedRecords": 30
    }
  },
  "successful": true
}

Get Catalog Item

GET /procurement/items/:identifier

Fetch a single catalog item by its internal ID or ConnectWise catalog ID.

Authentication Required: Yes

Required Permissions: procurement.catalog.fetch

Field-Level Gating: obj.catalogItem — see Object Type Field-Level Gating

Path Parameters:

  • identifier — Internal ID (cuid) or ConnectWise catalog ID (numeric)

Query Parameters:

  • includeLinkedItems (optional, default false) — Include linked catalog items in the response

Response:

{
  "status": 200,
  "message": "Catalog item fetched successfully!",
  "data": {
    "id": "clx...",
    "cwCatalogId": 123,
    "name": "Dell OptiPlex 7020",
    "description": "Dell OptiPlex 7020 SFF Desktop",
    "customerDescription": "Business Desktop Computer",
    "internalNotes": null,
    "manufacturer": "Dell",
    "manufactureCwId": 45,
    "partNumber": "OPT7020-SFF",
    "vendorName": "Dell Direct",
    "vendorSku": "DELL-OPT7020",
    "vendorCwId": 12,
    "price": 899.99,
    "cost": 650.0,
    "inactive": false,
    "salesTaxable": true,
    "onHand": 5,
    "cwLastUpdated": "2026-02-25T10:00:00.000Z",
    "linkedItems": [
      {
        "id": "clx...",
        "cwCatalogId": 456,
        "name": "Dell Warranty - 3 Year"
      }
    ],
    "createdAt": "2026-01-15T00:00:00.000Z",
    "updatedAt": "2026-02-25T10:00:00.000Z"
  },
  "successful": true
}

Get Catalog Item Count

GET /procurement/count

Get the total number of catalog items.

Authentication Required: Yes

Required Permissions: procurement.catalog.fetch.many

Query Parameters:

  • activeOnly (optional, default false) — Only count active (non-inactive) items

Response:

{
  "status": 200,
  "message": "Catalog item count fetched successfully!",
  "data": {
    "count": 300
  },
  "successful": true
}

Refresh Catalog Item Inventory

POST /procurement/items/:identifier/refresh-inventory

Refresh the on-hand inventory count for a catalog item by fetching the latest data from ConnectWise.

Authentication Required: Yes

Required Permissions: procurement.catalog.inventory.refresh

Path Parameters:

  • identifier — Internal ID (cuid) or ConnectWise catalog ID (numeric)

Response:

{
  "status": 200,
  "message": "Inventory refreshed successfully!",
  "data": {
    "id": "clx...",
    "cwCatalogId": 123,
    "name": "Dell OptiPlex 7020",
    "onHand": 7,
    "price": 899.99,
    "cost": 650.0,
    "inactive": false,
    "createdAt": "2026-01-15T00:00:00.000Z",
    "updatedAt": "2026-02-26T12:00:00.000Z"
  },
  "successful": true
}

Get Linked Catalog Items

GET /procurement/items/:identifier/linked

Fetch all catalog items linked to a specific item.

Authentication Required: Yes

Required Permissions: procurement.catalog.fetch

Field-Level Gating: obj.catalogItem — see Object Type Field-Level Gating

Path Parameters:

  • identifier — Internal ID (cuid), CW identifier string, or CW catalog ID (numeric)

Response:

{
  "status": 200,
  "message": "Linked catalog items fetched successfully!",
  "data": [
    {
      "id": "clx...",
      "cwCatalogId": 456,
      "identifier": "DELL-WAR-3YR",
      "name": "Dell Warranty - 3 Year",
      "description": "Dell 3 Year ProSupport Warranty",
      "price": 199.99,
      "cost": 120.0,
      "inactive": false,
      "onHand": 0,
      "createdAt": "2026-01-15T00:00:00.000Z",
      "updatedAt": "2026-02-25T10:00:00.000Z"
    }
  ],
  "successful": true
}

POST /procurement/items/:identifier/link

Link a target catalog item to the specified source item. The source item is identified by the URL parameter and the target by the request body.

Authentication Required: Yes

Required Permissions: procurement.catalog.link

Path Parameters:

  • identifier — Internal ID (cuid), CW identifier string, or CW catalog ID (numeric) of the source item

Request Body:

{
  "targetId": "clx..."
}

Response:

{
  "status": 200,
  "message": "Catalog item linked successfully!",
  "data": {
    "id": "clx...",
    "cwCatalogId": 123,
    "identifier": "OPT7020-SFF",
    "name": "Dell OptiPlex 7020",
    "linkedItems": [
      {
        "id": "clx...",
        "cwCatalogId": 456,
        "identifier": "DELL-WAR-3YR",
        "name": "Dell Warranty - 3 Year"
      }
    ],
    "createdAt": "2026-01-15T00:00:00.000Z",
    "updatedAt": "2026-02-26T12:00:00.000Z"
  },
  "successful": true
}

POST /procurement/items/:identifier/unlink

Remove the link between a source catalog item and a target catalog item.

Authentication Required: Yes

Required Permissions: procurement.catalog.link

Path Parameters:

  • identifier — Internal ID (cuid), CW identifier string, or CW catalog ID (numeric) of the source item

Request Body:

{
  "targetId": "clx..."
}

Response:

{
  "status": 200,
  "message": "Catalog item unlinked successfully!",
  "data": {
    "id": "clx...",
    "cwCatalogId": 123,
    "identifier": "OPT7020-SFF",
    "name": "Dell OptiPlex 7020",
    "linkedItems": [],
    "createdAt": "2026-01-15T00:00:00.000Z",
    "updatedAt": "2026-02-26T12:00:00.000Z"
  },
  "successful": true
}

Get Categories & Ecosystems

GET /procurement/categories

Fetch the full category tree and ecosystem tree. The category tree defines the three-level hierarchy (Category → Group/Subcategory → Subcategory) used for organizing catalog items. The ecosystem tree defines cross-cutting product groupings by manufacturer + category + subcategory-prefix rules.

Authentication Required: Yes

Required Permissions: procurement.catalog.fetch.many

Response:

{
  "status": 200,
  "message": "Category and ecosystem data fetched successfully!",
  "data": {
    "categories": [
      {
        "name": "Technology",
        "cwId": 18,
        "entries": [
          {
            "type": "subcategory",
            "name": "GeneralEquip",
            "cwId": 57
          },
          {
            "type": "group",
            "name": "Network",
            "subcategories": [
              { "name": "Network-Other", "cwId": 174 },
              { "name": "Network-Router", "cwId": 119 },
              { "name": "Network-Switch", "cwId": 112 },
              { "name": "Network-Wireless", "cwId": 111 }
            ]
          }
        ]
      }
    ],
    "ecosystems": [
      {
        "name": "Networking",
        "manufacturers": [
          {
            "name": "Ubiquiti",
            "cwId": 248,
            "category": "Technology",
            "subcategoryPrefix": "Network-"
          },
          {
            "name": "TP-Link",
            "cwId": 259,
            "category": "Technology",
            "subcategoryPrefix": "Network-"
          }
        ]
      }
    ]
  },
  "successful": true
}

Get Filter Values

GET /procurement/filters

Fetch the distinct values available for filter dropdowns (categories, subcategories, manufacturers) in the current dataset. Optionally scope the results with category/subcategory filters to cascade dependent dropdowns.

Authentication Required: Yes

Required Permissions: procurement.catalog.fetch.many

Query Parameters:

  • category (optional) — Scope subcategories and manufacturers to items in this category (accepts CW category name or CW ID)
  • subcategory (optional) — Scope manufacturers to items in this subcategory (accepts CW subcategory name or CW ID)
  • includeInactive (optional, default false) — Include inactive catalog items

Response:

{
  "status": 200,
  "message": "Available filter values fetched successfully!",
  "data": {
    "categories": ["Field", "General", "Technology"],
    "subcategories": [
      "Network-Other",
      "Network-Router",
      "Network-Switch",
      "Network-Wireless"
    ],
    "manufacturers": ["TP-Link", "Ubiquiti"]
  },
  "successful": true
}

Sales Routes

Sales routes serve opportunity data stored locally and synced from ConnectWise. All opportunity responses include hydrated company data (address, contacts) and an activities array. Single-opportunity fetches additionally include full site details (address, phone, flags). Sub-resource routes (products, notes, contacts) return data keyed by the opportunity's CW ID.

Caching: Most CW data for opportunities is served from a Redis cache that is proactively warmed by a background refresh cycle (every 30 seconds). This includes opportunity CW data, activities, notes, contacts, products, and company data. Cache TTLs are adaptive — recently updated opportunities have shorter TTLs (30s60s) for fresher data, while inactive opportunities use longer TTLs (515 minutes). If a cache miss occurs at request time, data is fetched live from CW and cached. See CACHING.md for full details on TTL algorithms, background refresh mechanics, and debugging tools.

Get Opportunity Types

GET /sales/opportunity-types

Fetch the list of all opportunity quote statuses (types). Returns a static list of canonical quote statuses with their ConnectWise IDs and legacy Optima equivalency mappings.

Authentication Required: Yes

Required Permissions: sales.opportunity.fetch.many

Response:

{
  "status": 200,
  "message": "Opportunity Types Fetched Successfully!",
  "data": [
    {
      "id": 51,
      "name": "00. FutureLead",
      "wonFlag": false,
      "lostFlag": false,
      "closedFlag": false,
      "inactiveFlag": false,
      "defaultFlag": false,
      "enteredBy": "crobinso",
      "dateEntered": "2023-07-11T23:13:19Z",
      "_info": {
        "lastUpdated": "2024-04-28T15:03:57Z",
        "updatedBy": "crobinso"
      },
      "connectWiseId": "070f72a3-70d0-ef11-b2e0-000c29c55070",
      "optimaEquivalency": [35, 36]
    }
  ],
  "successful": true
}

Get All Opportunities

GET /sales/opportunities

Fetch a paginated list of opportunities. Supports search. Each opportunity includes hydrated company data (with address and contacts from ConnectWise) when a linked company exists.

Authentication Required: Yes

Required Permissions: sales.opportunity.fetch.many

Field-Level Gating: obj.opportunity — see Object Type Field-Level Gating

Query Parameters:

  • page (optional, 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:

{
  "status": 200,
  "message": "Opportunities fetched successfully!",
  "data": [
    {
      "id": "clx...",
      "cwOpportunityId": 456,
      "name": "Acme Corp Network Refresh",
      "notes": "Full network redesign and hardware refresh",
      "type": { "id": 1, "name": "New" },
      "stage": { "id": 3, "name": "Proposal" },
      "status": { "id": 1, "name": "Open" },
      "priority": { "id": 2, "name": "High" },
      "rating": { "id": 1, "name": "Hot" },
      "source": "Referral",
      "campaign": null,
      "primarySalesRep": {
        "id": 10,
        "identifier": "JDoe",
        "name": "John Doe"
      },
      "secondarySalesRep": null,
      "company": {
        "id": "clx...",
        "name": "Acme Corp",
        "cw_Identifier": "AcmeCorp",
        "cw_CompanyId": 100,
        "cw_Data": {
          "address": {
            "line1": "123 Main St",
            "line2": null,
            "city": "Murray",
            "state": "Kentucky",
            "zip": "42071",
            "country": "United States"
          },
          "allContacts": [
            {
              "firstName": "Jane",
              "lastName": "Smith",
              "cwId": 200,
              "inactive": false,
              "title": "IT Manager",
              "phone": "555-0100",
              "email": "jane.smith@acme.com"
            }
          ]
        }
      },
      "contact": { "id": 200, "name": "Jane Smith" },
      "site": { "id": 50, "name": "Main Office" },
      "customerPO": null,
      "totalSalesTax": 0,
      "probability": 50,
      "location": { "id": 1, "name": "Murray" },
      "department": { "id": 5, "name": "Sales" },
      "expectedCloseDate": "2026-04-15T00:00:00.000Z",
      "pipelineChangeDate": "2026-02-20T00:00:00.000Z",
      "dateBecameLead": "2026-01-10T00:00:00.000Z",
      "closedDate": null,
      "closedFlag": false,
      "closedBy": null,
      "companyId": "clx...",
      "cwLastUpdated": "2026-02-26T10:00:00.000Z",
      "productSequence": [31848, 31846, 31847],
      "createdAt": "2026-02-01T00:00:00.000Z",
      "updatedAt": "2026-02-26T10:00:00.000Z",
      "customFields": [],
      "activities": [
        {
          "cwActivityId": 789,
          "name": "Follow-up Call",
          "notes": "Discuss proposal details",
          "type": { "id": 1, "name": "Call" },
          "status": { "id": 1, "name": "Open" },
          "company": {
            "id": 100,
            "identifier": "AcmeCorp",
            "name": "Acme Corp"
          },
          "contact": { "id": 200, "name": "Jane Smith" },
          "phoneNumber": "555-0100",
          "email": "jane.smith@acme.com",
          "opportunity": { "id": 456, "name": "Acme Corp Network Refresh" },
          "ticket": null,
          "agreement": null,
          "campaign": null,
          "assignTo": { "id": 10, "identifier": "JDoe", "name": "John Doe" },
          "scheduleStatus": null,
          "reminder": null,
          "where": null,
          "dateStart": "2026-03-01T10:00:00.000Z",
          "dateEnd": "2026-03-01T10:30:00.000Z",
          "notifyFlag": false,
          "currency": null,
          "mobileGuid": null,
          "customFields": [],
          "cwLastUpdated": "2026-02-26T10:00:00.000Z",
          "cwDateEntered": "2026-02-20T09:00:00.000Z",
          "cwEnteredBy": "JDoe",
          "cwUpdatedBy": "JDoe"
        }
      ]
    }
  ],
  "meta": {
    "pagination": {
      "previousPage": null,
      "currentPage": 1,
      "nextPage": 2,
      "totalPages": 5,
      "totalRecords": 150,
      "listedRecords": 30
    }
  },
  "successful": true
}

Get Opportunity Count

GET /sales/opportunities/count

Get the total number of opportunities.

Authentication Required: Yes

Required Permissions: sales.opportunity.fetch.many

Query Parameters:

  • openOnly (optional, default false) — Only count open (non-closed) opportunities

Response:

{
  "status": 200,
  "message": "Opportunity count fetched successfully!",
  "data": {
    "count": 150
  },
  "successful": true
}

Get Opportunity

GET /sales/opportunities/:identifier

Fetch a single opportunity by its internal ID or ConnectWise opportunity ID. The response includes hydrated company data (with address and contacts) and full site details (with address) when available. CW data (activities, company, site) is served from the Redis cache when available; on cache miss, data is fetched live from CW and cached with an adaptive TTL.

Authentication Required: Yes

Required Permissions: sales.opportunity.fetch

Field-Level Gating: obj.opportunity — see Object Type Field-Level Gating

Path Parameters:

  • identifier — Internal ID (cuid) or ConnectWise opportunity ID (numeric)

Query Parameters:

  • include (optional) — Comma-separated list of sub-resources to embed in the response. Supported values: notes, contacts, products, quotes. Example: ?include=notes,contacts,products,quotes. Sub-resources are fetched in parallel and added as top-level keys on the response object. 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. When quotes is included, data.quotes is returned as an array of committed quote metadata objects (PDF file data is excluded).
  • includeRegenData (optional) — When "true", includes quoteRegenData on each quote object (applies to ?include=quotes). Default: false.
  • includeRegenParams (optional) — When "true", includes quoteRegenParams on each quote object (applies to ?include=quotes). Default: false.

Response:

{
  "status": 200,
  "message": "Opportunity fetched successfully!",
  "data": {
    "id": "clx...",
    "cwOpportunityId": 456,
    "name": "Acme Corp Network Refresh",
    "notes": "Full network redesign and hardware refresh",
    "type": { "id": 1, "name": "New" },
    "stage": { "id": 3, "name": "Proposal" },
    "status": { "id": 1, "name": "Open" },
    "priority": { "id": 2, "name": "High" },
    "rating": { "id": 1, "name": "Hot" },
    "source": "Referral",
    "campaign": null,
    "primarySalesRep": {
      "id": 10,
      "identifier": "JDoe",
      "name": "John Doe"
    },
    "secondarySalesRep": null,
    "company": {
      "id": "clx...",
      "name": "Acme Corp",
      "cw_Identifier": "AcmeCorp",
      "cw_CompanyId": 100,
      "cw_Data": {
        "address": {
          "line1": "123 Main St",
          "line2": null,
          "city": "Murray",
          "state": "Kentucky",
          "zip": "42071",
          "country": "United States"
        },
        "allContacts": [
          {
            "firstName": "Jane",
            "lastName": "Smith",
            "cwId": 200,
            "inactive": false,
            "title": "IT Manager",
            "phone": "555-0100",
            "email": "jane.smith@acme.com"
          }
        ]
      }
    },
    "contact": { "id": 200, "name": "Jane Smith" },
    "site": {
      "id": 50,
      "name": "Main Office",
      "address": {
        "line1": "123 Main St",
        "line2": null,
        "city": "Murray",
        "state": "Kentucky",
        "zip": "42071",
        "country": "United States"
      },
      "phoneNumber": "555-0100",
      "faxNumber": null,
      "primaryAddressFlag": true,
      "defaultShippingFlag": true,
      "defaultBillingFlag": true,
      "defaultMailingFlag": true
    },
    "customerPO": null,
    "totalSalesTax": 0,
    "probability": 50,
    "location": { "id": 1, "name": "Murray" },
    "department": { "id": 5, "name": "Sales" },
    "expectedCloseDate": "2026-04-15T00:00:00.000Z",
    "pipelineChangeDate": "2026-02-20T00:00:00.000Z",
    "dateBecameLead": "2026-01-10T00:00:00.000Z",
    "closedDate": null,
    "closedFlag": false,
    "closedBy": null,
    "companyId": "clx...",
    "cwLastUpdated": "2026-02-26T10:00:00.000Z",
    "productSequence": [31848, 31846, 31847],
    "createdAt": "2026-02-01T00:00:00.000Z",
    "updatedAt": "2026-02-26T10:00:00.000Z",
    "customFields": [],
    "activities": [
      {
        "cwActivityId": 789,
        "name": "Follow-up Call",
        "notes": "Discuss proposal details",
        "type": { "id": 1, "name": "Call" },
        "status": { "id": 1, "name": "Open" },
        "company": { "id": 100, "identifier": "AcmeCorp", "name": "Acme Corp" },
        "contact": { "id": 200, "name": "Jane Smith" },
        "phoneNumber": "555-0100",
        "email": "jane.smith@acme.com",
        "opportunity": { "id": 456, "name": "Acme Corp Network Refresh" },
        "ticket": null,
        "agreement": null,
        "campaign": null,
        "assignTo": { "id": 10, "identifier": "JDoe", "name": "John Doe" },
        "scheduleStatus": null,
        "reminder": null,
        "where": null,
        "dateStart": "2026-03-01T10:00:00.000Z",
        "dateEnd": "2026-03-01T10:30:00.000Z",
        "notifyFlag": false,
        "currency": null,
        "mobileGuid": null,
        "customFields": [],
        "cwLastUpdated": "2026-02-26T10:00:00.000Z",
        "cwDateEntered": "2026-02-20T09:00:00.000Z",
        "cwEnteredBy": "JDoe",
        "cwUpdatedBy": "JDoe"
      }
    ]
  },
  "successful": true
}

Refresh Opportunity

POST /sales/opportunities/:identifier/refresh

Refresh an opportunity's local data by fetching the latest from ConnectWise. The response includes hydrated company data and site details.

Authentication Required: Yes

Required Permissions: sales.opportunity.refresh

Path Parameters:

  • identifier — Internal ID (cuid) or ConnectWise opportunity ID (numeric)

Response:

{
  "status": 200,
  "message": "Opportunity refreshed from ConnectWise successfully!",
  "data": {
    "id": "clx...",
    "cwOpportunityId": 456,
    "name": "Acme Corp Network Refresh",
    "notes": "Updated notes from CW",
    "type": { "id": 1, "name": "New" },
    "stage": { "id": 4, "name": "Negotiation" },
    "status": { "id": 1, "name": "Open" },
    "priority": { "id": 2, "name": "High" },
    "rating": { "id": 1, "name": "Hot" },
    "source": "Referral",
    "campaign": null,
    "primarySalesRep": {
      "id": 10,
      "identifier": "JDoe",
      "name": "John Doe"
    },
    "secondarySalesRep": null,
    "company": {
      "id": "clx...",
      "name": "Acme Corp",
      "cw_Identifier": "AcmeCorp",
      "cw_CompanyId": 100,
      "cw_Data": {
        "address": {
          "line1": "123 Main St",
          "line2": null,
          "city": "Murray",
          "state": "Kentucky",
          "zip": "42071",
          "country": "United States"
        },
        "allContacts": [
          {
            "firstName": "Jane",
            "lastName": "Smith",
            "cwId": 200,
            "inactive": false,
            "title": "IT Manager",
            "phone": "555-0100",
            "email": "jane.smith@acme.com"
          }
        ]
      }
    },
    "contact": { "id": 200, "name": "Jane Smith" },
    "site": { "id": 50, "name": "Main Office" },
    "customerPO": null,
    "totalSalesTax": 0,
    "probability": 50,
    "location": { "id": 1, "name": "Murray" },
    "department": { "id": 5, "name": "Sales" },
    "expectedCloseDate": "2026-04-15T00:00:00.000Z",
    "pipelineChangeDate": "2026-02-25T00:00:00.000Z",
    "dateBecameLead": "2026-01-10T00:00:00.000Z",
    "closedDate": null,
    "closedFlag": false,
    "closedBy": null,
    "companyId": "clx...",
    "cwLastUpdated": "2026-02-26T14:00:00.000Z",
    "createdAt": "2026-02-01T00:00:00.000Z",
    "updatedAt": "2026-02-26T14:00:00.000Z",
    "customFields": [],
    "activities": [
      {
        "cwActivityId": 789,
        "name": "Follow-up Call",
        "notes": "Discuss proposal details",
        "type": { "id": 1, "name": "Call" },
        "status": { "id": 1, "name": "Open" },
        "company": { "id": 100, "identifier": "AcmeCorp", "name": "Acme Corp" },
        "contact": { "id": 200, "name": "Jane Smith" },
        "phoneNumber": "555-0100",
        "email": "jane.smith@acme.com",
        "opportunity": { "id": 456, "name": "Acme Corp Network Refresh" },
        "ticket": null,
        "agreement": null,
        "campaign": null,
        "assignTo": { "id": 10, "identifier": "JDoe", "name": "John Doe" },
        "scheduleStatus": null,
        "reminder": null,
        "where": null,
        "dateStart": "2026-03-01T10:00:00.000Z",
        "dateEnd": "2026-03-01T10:30:00.000Z",
        "notifyFlag": false,
        "currency": null,
        "mobileGuid": null,
        "customFields": [],
        "cwLastUpdated": "2026-02-26T10:00:00.000Z",
        "cwDateEntered": "2026-02-20T09:00:00.000Z",
        "cwEnteredBy": "JDoe",
        "cwUpdatedBy": "JDoe"
      }
    ]
  },
  "successful": true
}

Create Opportunity

POST /sales/opportunities

Create a new opportunity in ConnectWise. The created opportunity is synced to the local database and returned in the response. name and expectedCloseDate are required; all other fields are optional.

Authentication Required: Yes

Required Permissions: sales.opportunity.create

Request Body:

{
  "name": "Acme Corp Network Refresh",
  "expectedCloseDate": "2026-06-01",
  "notes": "Initial scoping phase",
  "rating": { "id": 1 },
  "type": { "id": 2 },
  "stage": { "id": 1 },
  "status": { "id": 1 },
  "priority": { "id": 2 },
  "campaign": { "id": 5 },
  "primarySalesRep": { "id": 10 },
  "secondarySalesRep": { "id": 12 },
  "company": { "id": 100 },
  "contact": { "id": 200 },
  "site": { "id": 50 },
  "source": "Referral",
  "customerPO": "PO-12345",
  "locationId": 1,
  "businessUnitId": 5
}
Field Type Required Description
name string Yes Opportunity name
expectedCloseDate string Yes Expected close date (date string, e.g. 2026-06-01)
primarySalesRep { id: number } Yes CW member reference for primary sales rep
company { id: number } Yes CW company reference
contact { id: number } Yes CW contact reference
notes string No Opportunity description / notes
rating { id: number } No CW rating reference
type { id: number } No CW opportunity type reference
stage { id: number } No CW pipeline stage reference
status { id: number } No CW status reference
priority { id: number } No CW priority reference
campaign { id: number } No CW campaign reference
secondarySalesRep { id: number } | null No CW member reference for secondary sales rep (null clears)
site { id: number } | null No CW site reference (null clears)
source string | null No Opportunity source (null clears)
customerPO string | null No Customer PO number (null clears)
locationId number No CW location ID
businessUnitId number No CW business unit ID

Response (201):

{
  "status": 201,
  "message": "Opportunity created successfully!",
  "data": {
    "id": "clx...",
    "cwOpportunityId": 789,
    "name": "Acme Corp Network Refresh",
    "notes": "Initial scoping phase",
    "type": { "id": 2, "name": "Existing" },
    "stage": { "id": 1, "name": "Prospect" },
    "status": { "id": 1, "name": "Open" },
    "priority": { "id": 2, "name": "High" },
    "rating": { "id": 1, "name": "Hot" },
    "source": "Referral",
    "campaign": { "id": 5, "name": "Q2 Push" },
    "primarySalesRep": {
      "id": 10,
      "identifier": "JDoe",
      "name": "John Doe"
    },
    "secondarySalesRep": {
      "id": 12,
      "identifier": "ASmith",
      "name": "Alice Smith"
    },
    "company": { "id": 100, "name": "Acme Corp" },
    "contact": { "id": 200, "name": "Jane Smith" },
    "site": { "id": 50, "name": "Main Office" },
    "customerPO": "PO-12345",
    "totalSalesTax": 0,
    "probability": 0,
    "location": { "id": 1, "name": "Murray" },
    "department": null,
    "expectedCloseDate": "2026-06-01T00:00:00.000Z",
    "pipelineChangeDate": null,
    "dateBecameLead": null,
    "closedDate": null,
    "closedFlag": false,
    "closedBy": null,
    "companyId": "clx...",
    "cwLastUpdated": "2026-03-07T10:00:00.000Z",
    "createdAt": "2026-03-07T10:00:00.000Z",
    "updatedAt": "2026-03-07T10:00:00.000Z",
    "customFields": [],
    "activities": []
  },
  "successful": true
}

Error Responses:

Status Scenario
400 Zod validation failure (missing name/expectedCloseDate)
401 Missing or invalid auth token
403 User lacks sales.opportunity.create permission
4xx/5xx ConnectWise API error (forwarded status + message)
500 Unexpected server error

Update Opportunity

PATCH /sales/opportunities/:identifier

Update an opportunity's fields in ConnectWise (e.g. rating, sales rep, company, contact, site, description). Only the provided fields are patched; omitted fields remain unchanged. The updated opportunity is synced back to the local database and returned in the response.

Authentication Required: Yes

Required Permissions: sales.opportunity.update

Path Parameters:

  • identifier — Internal ID (cuid) or ConnectWise opportunity ID (numeric)

Request Body (all fields optional, at least one required):

{
  "name": "Acme Corp Network Refresh — Phase 2",
  "notes": "Updated project scope to include wireless",
  "rating": { "id": 1 },
  "type": { "id": 2 },
  "stage": { "id": 4 },
  "status": { "id": 1 },
  "priority": { "id": 2 },
  "campaign": { "id": 5 },
  "primarySalesRep": { "id": 10 },
  "secondarySalesRep": { "id": 12 },
  "company": { "id": 100 },
  "contact": { "id": 200 },
  "site": { "id": 50 },
  "expectedCloseDate": "2026-05-01",
  "customerPO": "PO-12345",
  "source": "Referral",
  "locationId": 1,
  "businessUnitId": 5
}
Field Type Description
name string Opportunity name
notes string Opportunity description / notes
rating { id: number } CW rating reference
type { id: number } CW opportunity type reference
stage { id: number } CW pipeline stage reference
status { id: number } CW status reference
priority { id: number } CW priority reference
campaign { id: number } CW campaign reference
primarySalesRep { id: number } CW member reference for primary sales rep
secondarySalesRep { id: number } | null CW member reference for secondary sales rep (null clears)
company { id: number } CW company reference
contact { id: number } | null CW contact reference (null clears)
site { id: number } | null CW site reference (null clears)
expectedCloseDate string Expected close date (ISO date string)
customerPO string | null Customer PO number (null clears)
source string | null Opportunity source (null clears)
locationId number CW location ID
businessUnitId number CW business unit ID

Response:

{
  "status": 200,
  "message": "Opportunity updated successfully!",
  "data": {
    "id": "clx...",
    "cwOpportunityId": 456,
    "name": "Acme Corp Network Refresh — Phase 2",
    "description": "Updated project scope to include wireless",
    "type": { "id": 2, "name": "Existing" },
    "stage": { "id": 4, "name": "Negotiation" },
    "status": { "id": 1, "name": "Open" },
    "priority": { "id": 2, "name": "High" },
    "rating": { "id": 1, "name": "Hot" },
    "source": "Referral",
    "campaign": { "id": 5, "name": "Q2 Push" },
    "primarySalesRep": {
      "id": 10,
      "identifier": "JDoe",
      "name": "John Doe"
    },
    "secondarySalesRep": {
      "id": 12,
      "identifier": "ASmith",
      "name": "Alice Smith"
    },
    "company": { "id": 100, "name": "Acme Corp" },
    "contact": { "id": 200, "name": "Jane Smith" },
    "site": { "id": 50, "name": "Main Office" },
    "customerPO": "PO-12345",
    "totalSalesTax": 0,
    "probability": 50,
    "location": { "id": 1, "name": "Murray" },
    "department": { "id": 5, "name": "Sales" },
    "expectedCloseDate": "2026-05-01T00:00:00.000Z",
    "pipelineChangeDate": "2026-02-25T00:00:00.000Z",
    "dateBecameLead": "2026-01-10T00:00:00.000Z",
    "closedDate": null,
    "closedFlag": false,
    "closedBy": null,
    "companyId": "clx...",
    "cwLastUpdated": "2026-03-07T10:00:00.000Z",
    "createdAt": "2026-02-01T00:00:00.000Z",
    "updatedAt": "2026-03-07T10:00:00.000Z",
    "customFields": [],
    "activities": []
  },
  "successful": true
}

Get Opportunity Products

GET /sales/opportunities/:identifier/products

Fetch products for an opportunity, scoped to items that have a matching ConnectWise procurement product (forecastDetailId link). Data is served from the Redis cache when available; on cache miss, data is fetched live from ConnectWise and cached. Hot opportunities (updated within 3 days) have a 15-second TTL; others use a 30-minute lazy TTL. Products are returned sorted by the opportunity's local productSequence array when set; otherwise, items are sorted by their ConnectWise sequenceNumber.

Authentication Required: Yes

Required Permissions: sales.opportunity.fetch

Path Parameters:

  • identifier — Internal ID (cuid) or ConnectWise opportunity ID (numeric)

Response:

{
  "status": 200,
  "message": "Opportunity products fetched successfully!",
  "data": [
    {
      "id": 31846,
      "forecastDescription": "Service",
      "opportunity": { "id": 5150, "name": "Example Opportunity" },
      "quantity": 1,
      "status": { "id": 24, "name": "01. New" },
      "cancelled": false,
      "cancellationType": null,
      "quantityCancelled": 0,
      "cancelledReason": null,
      "cancelledDate": null,
      "catalogItem": {
        "id": 3756,
        "identifier": "Labor & Installation - Field"
      },
      "productDescription": "Labor & Installation - Field",
      "productClass": "Service",
      "forecastType": "Service",
      "revenue": 650000,
      "cost": 0,
      "margin": 650000,
      "profit": 650000,
      "percentage": 100,
      "includeFlag": true,
      "linkFlag": true,
      "recurringFlag": false,
      "taxableFlag": true,
      "recurringRevenue": 0,
      "recurringCost": 0,
      "cycles": 0,
      "sequenceNumber": 1,
      "subNumber": 0,
      "cwLastUpdated": "2026-02-28T20:57:52.000Z",
      "cwUpdatedBy": "jroberts",
      "onHand": 12,
      "inStock": true
    }
  ],
  "successful": true
}

Cancellation Fields:

Product cancellation data is sourced from the ConnectWise procurement products endpoint (not the forecast endpoint). Each product includes:

Field Type Description
cancelled boolean Whether the product has been cancelled (fully or partially)
cancellationType string/null "full" if all units cancelled, "partial" if some cancelled, null if not cancelled
quantityCancelled number Number of units cancelled
cancelledReason string/null Reason for cancellation (if provided)
cancelledDate string/null ISO 8601 timestamp of when the item was cancelled

Inventory Fields:

Internal inventory data is sourced from the local CatalogItem database. If the product's catalog item exists locally, these fields are populated; otherwise they are null.

Field Type Description
onHand number/null Number of units currently on hand in local inventory
inStock boolean/null Whether the item is in stock (onHand > 0)

Resequence Opportunity Products

PATCH /sales/opportunities/:identifier/products/sequence

Update the display order of products (forecast items) on an opportunity. The sequence is stored locally in the database (productSequence field on the Opportunity model) — no modifications are made to ConnectWise. This means forecast item IDs remain stable and procurement product linkages are unaffected.

When a productSequence is set, GET .../products returns items in that order. Any forecast items not included in the array (e.g. newly added items) are appended at the end in CW sequenceNumber order.

Authentication Required: Yes

Required Permissions: sales.opportunity.product.update

Path Parameters:

  • identifier — Internal ID (cuid) or ConnectWise opportunity ID (numeric)

Request Body:

{
  "orderedIds": [31848, 31846, 31847]
}
  • orderedIds — Array of CW forecast item IDs in the desired display order. All IDs must exist on the opportunity's forecast in ConnectWise.

Response:

{
  "status": 200,
  "message": "Product sequence updated successfully!",
  "data": {
    "products": [
      {
        "id": 31848,
        "forecastDescription": "Hardware",
        "sequenceNumber": 3,
        "..."
      },
      {
        "id": 31846,
        "forecastDescription": "Service",
        "sequenceNumber": 1,
        "..."
      },
      {
        "id": 31847,
        "forecastDescription": "Licensing",
        "sequenceNumber": 2,
        "..."
      }
    ]
  },
  "successful": true
}
  • data.products — Full product objects in the new display order. IDs are unchanged — CW sequenceNumber still reflects the original CW order, but the array order matches the locally stored sequence.

Edit Opportunity Product

PATCH /sales/opportunities/:identifier/products/:productId/edit

Edit a product line item on an opportunity. This route supports forecast-backed fields and procurement-backed fields (including custom fields for narrative/notes).

Authentication Required: Yes

Required Permissions: sales.opportunity.product.update

Path Parameters:

  • identifier — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
  • productId — Forecast item ID (numeric)

Request Body:

At least one field is required.

{
  "productDescription": "Labor & Installation - Field (Updated)",
  "quantity": 2,
  "unitPrice": 125,
  "unitCost": 62.5,
  "customerDescription": "Onsite labor for rack install",
  "productNarrative": "Install, cable, and validate cutover",
  "procurementNotes": "Coordinate site contact before arrival"
}
Field Type Description
productDescription string Product description
quantity number Quantity
unitPrice number Unit price (maps to procurement price, forecast revenue)
unitCost number Unit cost (maps to procurement cost, forecast cost)
customerDescription string Customer-facing description
productNarrative string Custom field Product Narrative (id: 46)
procurementNotes string Custom field Procurement Notes (id: 29)

Response:

{
  "status": 200,
  "message": "Product updated successfully!",
  "data": {
    "id": 32281,
    "productDescription": "Labor & Installation - Field (Updated)",
    "quantity": 2,
    "unitPrice": 125,
    "unitCost": 62.5,
    "customerDescription": "Onsite labor for rack install",
    "productNarrative": "Install, cable, and validate cutover",
    "procurementNotes": "Coordinate site contact before arrival"
  },
  "successful": true
}

Cancel / Uncancel Opportunity Product

PATCH /sales/opportunities/:identifier/products/:productId/cancel

Set cancellation state for a product line item using procurement cancellation fields.

  • quantityCancelled = 0 → item is treated as uncancelled
  • quantityCancelled > 0 && < quantitypartial cancellation
  • quantityCancelled >= quantityfull cancellation

Authentication Required: Yes

Required Permissions: sales.opportunity.product.update

Path Parameters:

  • identifier — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
  • productId — Forecast item ID (numeric)

Request Body:

{
  "quantityCancelled": 1,
  "cancellationReason": "Out of stock"
}
Field Type Required Description
quantityCancelled number (integer) Yes Number of units to cancel. Use 0 to uncancel the line item.
cancellationReason string | null No Optional reason that is passed through to ConnectWise.

Validation Rules:

  • quantityCancelled must be >= 0
  • quantityCancelled cannot exceed the line item's quantity

Response:

{
  "status": 200,
  "message": "Product cancellation updated successfully!",
  "data": {
    "id": 32281,
    "quantity": 2,
    "cancelled": true,
    "cancellationType": "partial",
    "quantityCancelled": 1,
    "cancelledReason": "Out of stock",
    "cancelledDate": "2026-03-04T00:00:00.000Z"
  },
  "successful": true
}

Add Product to Opportunity

POST /sales/opportunities/:identifier/products

Add a new product (forecast item) to an opportunity in ConnectWise. The request body is validated with Zod, then each submitted field is gated against sales.opportunity.product.field.<field> permissions — only fields the user has permission for are forwarded to ConnectWise.

After creation, the new forecast item ID is appended to the opportunity's local productSequence array in the database (if not already present) so display ordering remains stable.

Authentication Required: Yes

Required Permissions: sales.opportunity.product.add

Field-Level Permission Gating: Yes — uses processObjectValuePerms with scope sales.opportunity.product.field on the input body. See the field-level permissions table under sales.opportunity.product.add in PERMISSIONS.md.

Path Parameters:

  • identifier — Internal ID (cuid) or ConnectWise opportunity ID (numeric)

Request Body:

All fields are optional. Only fields the user has the corresponding sales.opportunity.product.field.<field> permission for will be sent to ConnectWise.

{
  "catalogItem": { "id": 1234 },
  "forecastDescription": "Managed Services",
  "productDescription": "Monthly managed services agreement",
  "quantity": 1,
  "status": { "id": 1 },
  "productClass": "Agreement",
  "forecastType": "Product",
  "revenue": 500.0,
  "cost": 250.0,
  "includeFlag": true,
  "linkFlag": false,
  "recurringFlag": true,
  "taxableFlag": true,
  "recurringRevenue": 500.0,
  "recurringCost": 250.0,
  "cycles": 12,
  "sequenceNumber": 1
}
Field Type Description
catalogItem { id: number } ConnectWise catalog item reference
forecastDescription string Forecast description text
productDescription string Product description text
quantity number Quantity (must be positive)
status { id: number } ConnectWise status reference
productClass string Product class (e.g. Product, Service, Agreement)
forecastType string Forecast type
revenue number Revenue amount
cost number Cost amount
includeFlag boolean Whether to include in forecast totals
linkFlag boolean Whether the item is linked
recurringFlag boolean Whether this is a recurring item
taxableFlag boolean Whether this item is taxable
recurringRevenue number Recurring revenue amount
recurringCost number Recurring cost amount
cycles number Number of recurring cycles (integer, min 0)
sequenceNumber number Display sequence number (integer, min 0)

Response:

{
  "status": 201,
  "message": "Product added to opportunity successfully!",
  "data": {
    "id": 31855,
    "forecastDescription": "Managed Services",
    "opportunity": { "id": 5678, "name": "Example Opportunity" },
    "quantity": 1,
    "status": { "id": 1, "name": "Open" },
    "catalogItem": { "id": 1234, "identifier": "MSP-001" },
    "productDescription": "Monthly managed services agreement",
    "productClass": "Agreement",
    "forecastType": "Product",
    "revenue": 500.0,
    "cost": 250.0,
    "margin": 250.0,
    "profit": 250.0,
    "percentage": 0,
    "includeFlag": true,
    "linkFlag": false,
    "recurringFlag": true,
    "taxableFlag": true,
    "recurringRevenue": 500.0,
    "recurringCost": 250.0,
    "cycles": 12,
    "sequenceNumber": 1,
    "subNumber": 0,
    "cwLastUpdated": "2026-03-01T00:00:00.000Z",
    "cwUpdatedBy": "Admin1",
    "cancelled": false,
    "cancellationType": null,
    "quantityCancelled": 0,
    "cancelledReason": null,
    "cancelledDate": null,
    "onHand": null,
    "inStock": null
  },
  "successful": true
}

Add SPECIAL ORDER Product

POST /sales/opportunities/:identifier/products/special-order

Add one or more products as SPECIAL ORDER procurement line items. This route creates ConnectWise procurement products (not forecast items) and enforces stable defaults for quick-entry workflows.

Authentication Required: Yes

Required Permissions: sales.opportunity.product.add.specialOrder

Path Parameters:

  • identifier — Internal ID (cuid) or ConnectWise opportunity ID (numeric)

Request Body:

Accepts either a single object or an array of objects.

{
  "desc": "SPECIAL ORDER - Lead time confirmed",
  "customerDesc": "Customer-facing special order description",
  "qty": 1,
  "price": 750,
  "cost": 500,
  "taxable": true,
  "procurementNotes": "Vendor ETA pending confirmation",
  "productNarrative": "Install with existing rack accessories"
}
Field Type Required Description
desc string Yes Internal/sales line description
customerDesc string No Customer-facing line description
qty number No Quantity (defaults to 1 when omitted)
price number Yes Revenue amount
cost number No Cost amount
taxable boolean No Taxable flag (defaults to the catalog item's tax setting)
procurementNotes string No Maps to custom field id: 29
productNarrative string No Maps to custom field id: 46

Route-Enforced Defaults:

  • 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:
    • procurementNotesProcurement Notes (id: 29)
    • productNarrativeProduct Narrative (id: 46)
  • When CW returns forecastDetailId, it is appended to the opportunity's local productSequence array in the database (if not already present)

Response:

{
  "status": 201,
  "message": "Special-order product added successfully!",
  "data": {
    "id": 88340,
    "forecastDetailId": 32280,
    "description": "SPECIAL ORDER - Lead time confirmed",
    "customerDescription": "Customer-facing special order description",
    "quantity": 1,
    "price": 750,
    "cost": 500,
    "taxableFlag": true
  },
  "successful": true
}

Get Labor Product Options

GET /sales/opportunities/:identifier/products/labor/options

Fetch the resolved Field and Tech labor catalog products plus default labor pricing metadata so the UI can hydrate the labor-entry form without hardcoding catalog IDs.

Authentication Required: Yes

Required Permissions: sales.opportunity.product.add.labor

Path Parameters:

  • identifier — Internal ID (cuid) or ConnectWise opportunity ID (numeric)

Response:

{
  "status": 200,
  "message": "Labor product options fetched successfully!",
  "data": {
    "defaults": {
      "customerType": "corporate",
      "rates": {
        "corporate": 100,
        "residential": 85
      },
      "cpuMultiplier": 0.5,
      "quantity": 1
    },
    "options": {
      "field": {
        "cwCatalogId": 3756,
        "identifier": "Labor & Installation - Field",
        "name": "Labor & Installation - Field",
        "taxableFlag": true
      },
      "tech": {
        "cwCatalogId": 3757,
        "identifier": "Labor & Installation - Tech",
        "name": "Labor & Installation - Tech",
        "taxableFlag": true
      }
    }
  },
  "successful": true
}

Add Labor Product

POST /sales/opportunities/:identifier/products/labor

Add a labor line item to an opportunity using one of the two canonical labor catalog products (Field or Tech). The route resolves both labor products from the local catalog, then picks the selected style and creates a ConnectWise procurement product.

Authentication Required: Yes

Required Permissions: sales.opportunity.product.add.labor

Path Parameters:

  • identifier — Internal ID (cuid) or ConnectWise opportunity ID (numeric)

Request Body:

{
  "laborStyle": "field",
  "customerType": "corporate",
  "hours": 1,
  "taxable": true,
  "rate": 100,
  "ppu": 100,
  "cpu": 50,
  "procurementNotes": "Schedule with PM before install",
  "productNarrative": "Install and validate onsite",
  "customerDescription": "Onsite installation labor"
}
Field Type Required Description
laborStyle "field" | "tech" Yes Chooses which labor catalog product to use
customerType "corporate" | "residential" No Selects default rate (corporate=100, residential=85) when rate omitted
hours number No Quantity/hours (defaults to 1)
rate number No Hourly labor rate used when ppu is not provided
ppu number No Price per unit/hour (overrides rate)
cpu number No Cost per unit/hour (defaults to 50% of selected price)
taxable boolean No Taxable flag override
taxableFlag boolean No Alternate taxable flag input (same behavior as taxable)
description string No Internal line description override
customerDescription string No Customer-facing description
procurementNotes string No Maps to custom field Procurement Notes (id: 29)
productNarrative string No Maps to custom field Product Narrative (id: 46)

Route-Enforced Defaults / Behavior:

  • Resolves both labor products from local catalog and uses laborStyle to select one
  • Uses customerType defaults when rate/ppu/cpu are not supplied
  • Sets quantity from hours (default 1)
  • Sets price from selected ppu, cost from selected cpu
  • Sets dropshipFlag to false and billableOption to Billable
  • Sets taxable flag from taxable/taxableFlag, falling back to the selected catalog item's tax setting
  • When CW returns forecastDetailId, it is appended to the opportunity's local productSequence array in the database (if not already present)

Response:

{
  "status": 201,
  "message": "Labor added to opportunity successfully!",
  "data": {
    "id": 88341,
    "forecastDetailId": 32281,
    "laborStyle": "field",
    "customerType": "corporate",
    "catalogItem": {
      "id": 3756,
      "identifier": "Labor & Installation - Field",
      "name": "Labor & Installation - Field"
    },
    "description": "Labor & Installation - Field",
    "customerDescription": "Onsite installation labor",
    "quantity": 1,
    "rate": 100,
    "ppu": 100,
    "cpu": 50,
    "revenue": 100,
    "cost": 50,
    "taxableFlag": true,
    "procurementNotes": "Schedule with PM before install",
    "productNarrative": "Install and validate onsite"
  },
  "successful": true
}

Fetch Committed Quotes

GET /sales/opportunities/:identifier/quotes

Fetch all committed (finalized) quotes for an opportunity, ordered by most recent first.

Authentication Required: Yes

Required Permissions: sales.opportunity.quote.fetch

Path Parameters:

  • identifier — Internal ID (cuid) or ConnectWise opportunity ID (numeric)

Query Parameters:

  • includeRegenData (optional) — When "true", includes quoteRegenData on each quote object. Default: false.
  • includeRegenParams (optional) — When "true", includes quoteRegenParams on each quote object. Default: false.

Response:

{
  "status": 200,
  "message": "Committed quotes fetched successfully!",
  "data": [
    {
      "id": "clx9abc123...",
      "quoteFileName": "OPP-12345-2026-03-06T12-00-00-000Z.pdf",
      "quoteRegenHash": "a1b2c3d4e5f6...",
      "opportunityId": "clx9xyz789...",
      "createdById": "clx9user456...",
      "quoteRegenData": "(included when ?includeRegenData=true)",
      "quoteRegenParams": "(included when ?includeRegenParams=true)",
      "createdAt": "2026-03-06T12:00:00.000Z",
      "updatedAt": "2026-03-06T12:00:00.000Z"
    }
  ],
  "successful": true
}

Commit Quote

POST /sales/opportunities/:identifier/quote/commit

Generate a finalized (non-preview) quote PDF for an opportunity and store it in the database with regeneration metadata and creator attribution.

Authentication Required: Yes

Required Permissions: sales.opportunity.quote.commit

Path Parameters:

  • identifier — Internal ID (cuid) or ConnectWise opportunity ID (numeric)

Request Body (optional):

{
  "lineItemPricing": true,
  "includeQuoteNarrative": true,
  "includeItemNarratives": true
}
Field Type Required Default Description
lineItemPricing boolean No true Include per-line-item pricing in the quote PDF
includeQuoteNarrative boolean No true Include the quote-level narrative section
includeItemNarratives boolean No true Include per-item narrative sections

Response:

{
  "status": 201,
  "message": "Quote committed successfully!",
  "data": {
    "id": "clx9abc123...",
    "quoteFileName": "OPP-12345-2026-03-06T12-00-00-000Z.pdf",
    "quoteRegenHash": "a1b2c3d4e5f6...",
    "opportunityId": "clx9xyz789...",
    "createdById": "clx9user456...",
    "quoteRegenData": {
      "options": {
        "lineItemPricing": true,
        "includeQuoteNarrative": true,
        "includeItemNarratives": true
      },
      "opportunity": {
        "id": "clx9xyz789...",
        "cwOpportunityId": 12345,
        "name": "Network Refresh  Acme Corp",
        "totalSalesTax": 245.5,
        "contactName": "Jane Smith",
        "companyName": "Acme Corp"
      },
      "customer": {
        "preparedFor": "Jane Smith",
        "companyName": "Acme Corp",
        "primaryContact": {
          "firstName": "Jane",
          "lastName": "Smith",
          "email": "jane@acme.com",
          "phone": "801-555-1234"
        },
        "siteAddress": ["123 Main St", "Salt Lake City UT 84101"],
        "companyAddress": ["456 Corporate Blvd", "Murray UT 84107"]
      },
      "salesRep": {
        "name": "John Roberts",
        "email": "jroberts@example.com"
      },
      "quoteNarrative": "Full network infrastructure refresh including...",
      "products": [
        {
          "cwForecastId": 101,
          "forecastDescription": "UniFi U6-Pro AP",
          "productDescription": "UniFi U6-Pro Access Point",
          "customerDescription": "Wireless access point",
          "productNarrative": "Install and validate onsite",
          "productClass": "Product",
          "forecastType": "Products",
          "catalogItem": { "id": 500, "identifier": "U6-PRO" },
          "quantity": 4,
          "effectiveQuantity": 4,
          "revenue": 800.0,
          "cost": 520.0,
          "margin": 280.0,
          "percentage": 35.0,
          "includeFlag": true,
          "taxableFlag": true,
          "recurringFlag": false,
          "recurringRevenue": 0,
          "recurringCost": 0,
          "sequenceNumber": 1,
          "cancelledFlag": false,
          "cancellationType": null,
          "quantityCancelled": 0,
          "cancelledReason": null,
          "cancelledDate": null
        }
      ],
      "snapshotTimestamp": "2026-03-06T12:00:00.000Z"
    },
    "quoteRegenParams": {
      "opportunityId": "clx9xyz789...",
      "cwOpportunityId": 12345
    },
    "createdAt": "2026-03-06T12:00:00.000Z",
    "updatedAt": "2026-03-06T12:00:00.000Z"
  },
  "successful": true
}

Preview Quote

GET /sales/opportunities/:identifier/quote/:quoteId/preview

Regenerate a preview-stamped version of an existing committed quote PDF using its stored generation parameters. The PDF is not persisted — it is returned as a base64-encoded string.

Authentication Required: Yes

Required Permissions: sales.opportunity.quote.preview

Path Parameters:

  • identifier — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
  • quoteId — The generated quote's internal ID (uuid)

Response:

{
  "status": 200,
  "message": "Quote preview generated successfully!",
  "data": {
    "mimeType": "application/pdf",
    "contentBase64": "JVBERi0xLjQK... (base64-encoded PDF)"
  },
  "successful": true
}

Download Quote

GET /sales/opportunities/:identifier/quote/:quoteId/download

Download a committed quote PDF by its ID. Returns the PDF file as a base64-encoded string. Each call automatically records a download entry with the timestamp, user info, and fetch action in the quote's downloads array. Download-time metadata (downloadedAt, downloadedBy) is also injected into the PDF's document properties.

Authentication Required: Yes

Required Permissions: sales.opportunity.quote.download

Path Parameters:

  • identifier — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
  • quoteId — The generated quote's internal ID (uuid)

Query Parameters:

  • fetchAction (required) — The action being performed. Must be one of: download, print. Tracked in the download record for audit purposes.

Response:

{
  "status": 200,
  "message": "Quote downloaded successfully!",
  "data": {
    "id": "a1b2c3d4-e5f6-...",
    "quoteFileName": "OPP-12345-2026-03-06T12-00-00-000Z.pdf",
    "mimeType": "application/pdf",
    "contentBase64": "JVBERi0xLjQK... (base64-encoded PDF)"
  },
  "successful": true
}

Download Record Shape (stored in downloads JSON array):

{
  "downloadedAt": "2026-03-06T15:30:00.000Z",
  "fetchAction": "download",
  "userId": "clx9user456...",
  "userName": "John Roberts",
  "userEmail": "jroberts@example.com"
}

Error Responses:

  • 400 — Missing or invalid fetchAction query parameter.
  • 404 — Generated quote not found.

Fetch Quote Download History

GET /sales/opportunities/:identifier/quotes/downloads

Fetch download/print history for all committed quotes on an opportunity. Returns an array of quote summaries, each containing the full downloads array with every download/print record. This is an admin-level route intended for audit and tracking purposes.

Authentication Required: Yes

Required Permissions: sales.opportunity.quote.fetch_downloads

Path Parameters:

  • identifier — Internal ID (cuid) or ConnectWise opportunity ID (numeric)

Response:

{
  "status": 200,
  "message": "Download logs fetched successfully!",
  "data": [
    {
      "quoteId": "a1b2c3d4-e5f6-...",
      "quoteFileName": "OPP-12345-2026-03-06T12-00-00-000Z.pdf",
      "createdById": "clx9user123...",
      "createdAt": "2026-03-06T12:00:00.000Z",
      "downloads": [
        {
          "downloadedAt": "2026-03-06T15:30:00.000Z",
          "fetchAction": "download",
          "userId": "clx9user456...",
          "userName": "John Roberts",
          "userEmail": "jroberts@example.com"
        },
        {
          "downloadedAt": "2026-03-06T16:00:00.000Z",
          "fetchAction": "print",
          "userId": "clx9user789...",
          "userName": "Jane Smith",
          "userEmail": "jsmith@example.com"
        }
      ]
    }
  ],
  "successful": true
}

Get Opportunity Notes

GET /sales/opportunities/:identifier/notes

Fetch notes for an opportunity. Data is served from the Redis cache when available; on cache miss, data is fetched live from ConnectWise and cached. Cache is invalidated automatically when notes are created, updated, or deleted.

Authentication Required: Yes

Required Permissions: sales.opportunity.fetch

Path Parameters:

  • identifier — Internal ID (cuid) or ConnectWise opportunity ID (numeric)

Response:

{
  "status": 200,
  "message": "Opportunity notes fetched successfully!",
  "data": [
    {
      "id": 1,
      "text": "Client expressed interest in a full network refresh.",
      "type": { "id": 2, "name": "Discussion" },
      "flagged": false,
      "enteredBy": {
        "id": "clx1abc123",
        "identifier": "jdoe",
        "name": "John Doe",
        "cwMemberId": 10
      }
    }
  ],
  "successful": true
}

Get Single Opportunity Note

GET /sales/opportunities/:identifier/notes/:noteId

Fetch a single note by its ConnectWise note ID for an opportunity.

Authentication Required: Yes

Required Permissions: sales.opportunity.fetch

Path Parameters:

  • identifier — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
  • noteId — The ConnectWise note ID (numeric)

Response:

{
  "status": 200,
  "message": "Opportunity note fetched successfully!",
  "data": {
    "id": 1,
    "text": "Client expressed interest in a full network refresh.",
    "type": { "id": 2, "name": "Discussion" },
    "flagged": false,
    "enteredBy": {
      "id": "clx1abc123",
      "identifier": "jdoe",
      "name": "John Doe",
      "cwMemberId": 10
    }
  },
  "successful": true
}

Create Opportunity Note

POST /sales/opportunities/:identifier/notes

Create a new note on an opportunity in ConnectWise.

Authentication Required: Yes

Required Permissions: sales.opportunity.note.create

Path Parameters:

  • identifier — Internal ID (cuid) or ConnectWise opportunity ID (numeric)

Request Body:

{
  "text": "Follow up with client about pricing.",
  "flagged": false
}
Field Type Required Description
text string Yes The note text (min 1 character)
flagged boolean No Whether the note is flagged

Response:

{
  "status": 201,
  "message": "Opportunity note created successfully!",
  "data": {
    "id": 42,
    "text": "Follow up with client about pricing.",
    "type": null,
    "flagged": false,
    "enteredBy": {
      "id": "clx2def456",
      "identifier": "jroberts",
      "name": "John Roberts",
      "cwMemberId": 15
    }
  },
  "successful": true
}

Update Opportunity Note

PATCH /sales/opportunities/:identifier/notes/:noteId

Update an existing note on an opportunity in ConnectWise.

Authentication Required: Yes

Required Permissions: sales.opportunity.note.update

Path Parameters:

  • identifier — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
  • noteId — The ConnectWise note ID (numeric)

Request Body:

{
  "text": "Updated note text.",
  "flagged": true
}
Field Type Required Description
text string No Updated note text (min 1 character)
flagged boolean No Updated flagged state

At least one of text or flagged must be provided.

Response:

{
  "status": 200,
  "message": "Opportunity note updated successfully!",
  "data": {
    "id": 42,
    "text": "Updated note text.",
    "type": null,
    "flagged": true,
    "enteredBy": {
      "id": "clx2def456",
      "identifier": "jroberts",
      "name": "John Roberts",
      "cwMemberId": 15
    }
  },
  "successful": true
}

Delete Opportunity Note

DELETE /sales/opportunities/:identifier/notes/:noteId

Delete a note from an opportunity in ConnectWise.

Authentication Required: Yes

Required Permissions: sales.opportunity.note.delete

Path Parameters:

  • identifier — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
  • noteId — The ConnectWise note ID (numeric)

Response:

{
  "status": 200,
  "message": "Opportunity note deleted successfully!",
  "successful": true
}

Get Opportunity Contacts

GET /sales/opportunities/:identifier/contacts

Fetch contacts associated with an opportunity. Data is served from the Redis cache when available; on cache miss, data is fetched live from ConnectWise and cached. Cache is invalidated automatically when contacts are created, updated, or deleted.

Authentication Required: Yes

Required Permissions: sales.opportunity.fetch

Path Parameters:

  • identifier — Internal ID (cuid) or ConnectWise opportunity ID (numeric)

Response:

{
  "status": 200,
  "message": "Opportunity contacts fetched successfully!",
  "data": [
    {
      "id": 1,
      "contact": { "id": 200, "name": "Jane Smith" },
      "company": {
        "id": 100,
        "identifier": "AcmeCorp",
        "name": "Acme Corp"
      },
      "role": { "id": 1, "name": "Decision Maker" },
      "notes": "Primary point of contact for this deal",
      "referralFlag": false
    }
  ],
  "successful": true
}

UniFi Routes

All UniFi routes require the unifi.access permission in addition to their route-specific permission. This acts as a gate for the entire UniFi API.

Get All UniFi Sites

GET /unifi/sites

Fetch all UniFi site records from the database.

Authentication Required: Yes

Required Permissions: unifi.access, unifi.sites.fetch.many

Field-Level Gating: obj.unifiSite — see Object Type Field-Level Gating

Response:

{
  "status": 200,
  "message": "UniFi Sites Fetched Successfully!",
  "data": [
    {
      "id": "ckx...",
      "name": "Total Tech - Murray Office",
      "siteId": "km9b1v8i",
      "companyId": "ckx...",
      "company": {
        "id": "ckx...",
        "name": "Acme Corp"
      },
      "createdAt": "2025-01-01T00:00:00.000Z",
      "updatedAt": "2025-01-01T00:00:00.000Z"
    }
  ],
  "successful": true
}

Sync UniFi Sites

POST /unifi/sites/sync

Synchronize sites from the UniFi controller into the database. Creates new records for sites not yet tracked and updates names for existing ones.

Authentication Required: Yes

Required Permissions: unifi.access, unifi.sites.sync

Response:

{
  "status": 200,
  "message": "UniFi Sites Synced Successfully!",
  "data": [
    {
      "id": "ckx...",
      "name": "Total Tech - Murray Office",
      "siteId": "km9b1v8i",
      "companyId": null,
      "createdAt": "2025-01-01T00:00:00.000Z",
      "updatedAt": "2025-01-01T00:00:00.000Z"
    }
  ],
  "successful": true
}

Create UniFi Site

POST /unifi/sites/create

Create a new site on the UniFi controller and track it in the database.

Authentication Required: Yes

Required Permissions: unifi.access, unifi.sites.create

Request Body:

{
  "description": "New Office Site"
}
Field Type Required Description
description string Yes Human-readable name / description for the site

Response:

{
  "status": 200,
  "message": "UniFi Site Created Successfully!",
  "data": {
    "id": "ckx...",
    "name": "New Office Site",
    "siteId": "abc123",
    "companyId": null,
    "createdAt": "2025-01-01T00:00:00.000Z",
    "updatedAt": "2025-01-01T00:00:00.000Z"
  },
  "successful": true
}

Get UniFi Site

GET /unifi/site/:id

Fetch a single UniFi site record from the database by its internal ID.

Authentication Required: Yes

Required Permissions: unifi.access, unifi.sites.fetch

Field-Level Gating: obj.unifiSite — see Object Type Field-Level Gating

URL Parameters:

  • id - Internal UniFi site ID (database ID)

Response:

{
  "status": 200,
  "message": "UniFi Site Fetched Successfully!",
  "data": {
    "id": "ckx...",
    "name": "Total Tech - Murray Office",
    "siteId": "km9b1v8i",
    "companyId": "ckx...",
    "createdAt": "2025-01-01T00:00:00.000Z",
    "updatedAt": "2025-01-01T00:00:00.000Z"
  },
  "successful": true
}

Get Site Overview

GET /unifi/site/:id/overview

Fetch live site overview data from the UniFi controller, including health status, system info, and site information.

Authentication Required: Yes

Required Permissions: unifi.access, unifi.site.overview

URL Parameters:

  • id - Internal UniFi site ID (database ID)

Response:

{
  "status": 200,
  "message": "UniFi Site Overview Fetched Successfully!",
  "data": {
    "health": [
      {
        "subsystem": "wan",
        "status": "ok",
        "numAdopted": 1,
        "numGateway": 1
      }
    ],
    "sysInfo": {
      "timezone": "America/Denver",
      "hostname": "UniFi-Controller",
      "version": "8.x.x"
    },
    "siteInfo": {
      "description": "Total Tech - Murray Office",
      "name": "km9b1v8i"
    }
  },
  "successful": true
}

Get Site Devices

GET /unifi/site/:id/devices

Fetch live device list from the UniFi controller for a specific site.

Authentication Required: Yes

Required Permissions: unifi.access, unifi.site.devices

URL Parameters:

  • id - Internal UniFi site ID (database ID)

Response:

{
  "status": 200,
  "message": "UniFi Devices Fetched Successfully!",
  "data": [
    {
      "id": "abc123...",
      "mac": "00:11:22:33:44:55",
      "model": "U6-Pro",
      "name": "Office AP",
      "type": "uap",
      "state": "connected",
      "ip": "192.168.1.10",
      "version": "6.x.x",
      "uptime": 123456,
      "radios": [],
      "uplink": {}
    }
  ],
  "successful": true
}

Get Site WiFi Networks

GET /unifi/site/:id/wifi

Fetch live WiFi network (WLAN) configurations from the UniFi controller for a specific site.

Authentication Required: Yes

Required Permissions: unifi.access, unifi.site.wifi

Field-Level Gating: unifi.site.wifi.read.<field>

This route uses processObjectValuePerms to filter each WLAN object on a per-field basis. Only fields whose corresponding unifi.site.wifi.read.<field> permission the user holds are included in the response. For example, a user with unifi.site.wifi.read.name and unifi.site.wifi.read.enabled but without unifi.site.wifi.read.passphrase will receive objects containing only name and enabled. Use unifi.site.wifi.read.* to grant access to all fields.

All available field-level permission nodes
Permission Node Field
unifi.site.wifi.read.id id
unifi.site.wifi.read.name name
unifi.site.wifi.read.siteId siteId
unifi.site.wifi.read.enabled enabled
unifi.site.wifi.read.security security
unifi.site.wifi.read.wpaMode wpaMode
unifi.site.wifi.read.wpaEnc wpaEnc
unifi.site.wifi.read.wpa3Support wpa3Support
unifi.site.wifi.read.wpa3Transition wpa3Transition
unifi.site.wifi.read.wpa3FastRoaming wpa3FastRoaming
unifi.site.wifi.read.wpa3Enhanced192 wpa3Enhanced192
unifi.site.wifi.read.passphrase passphrase
unifi.site.wifi.read.passphraseAutogenerated passphraseAutogenerated
unifi.site.wifi.read.hideSSID hideSSID
unifi.site.wifi.read.isGuest isGuest
unifi.site.wifi.read.band band
unifi.site.wifi.read.bands bands
unifi.site.wifi.read.networkconfId networkconfId
unifi.site.wifi.read.usergroupId usergroupId
unifi.site.wifi.read.apGroupIds apGroupIds
unifi.site.wifi.read.apGroupMode apGroupMode
unifi.site.wifi.read.pmfMode pmfMode
unifi.site.wifi.read.groupRekey groupRekey
unifi.site.wifi.read.dtimMode dtimMode
unifi.site.wifi.read.dtimNg dtimNg
unifi.site.wifi.read.dtimNa dtimNa
unifi.site.wifi.read.dtim6e dtim6e
unifi.site.wifi.read.l2Isolation l2Isolation
unifi.site.wifi.read.fastRoamingEnabled fastRoamingEnabled
unifi.site.wifi.read.bssTransition bssTransition
unifi.site.wifi.read.uapsdEnabled uapsdEnabled
unifi.site.wifi.read.iappEnabled iappEnabled
unifi.site.wifi.read.proxyArp proxyArp
unifi.site.wifi.read.mcastenhanceEnabled mcastenhanceEnabled
unifi.site.wifi.read.macFilterEnabled macFilterEnabled
unifi.site.wifi.read.macFilterPolicy macFilterPolicy
unifi.site.wifi.read.macFilterList macFilterList
unifi.site.wifi.read.radiusDasEnabled radiusDasEnabled
unifi.site.wifi.read.radiusMacAuthEnabled radiusMacAuthEnabled
unifi.site.wifi.read.radiusMacaclFormat radiusMacaclFormat
unifi.site.wifi.read.minrateSettingPreference minrateSettingPreference
unifi.site.wifi.read.minrateNgEnabled minrateNgEnabled
unifi.site.wifi.read.minrateNgDataRateKbps minrateNgDataRateKbps
unifi.site.wifi.read.minrateNgAdvertisingRates minrateNgAdvertisingRates
unifi.site.wifi.read.minrateNaEnabled minrateNaEnabled
unifi.site.wifi.read.minrateNaDataRateKbps minrateNaDataRateKbps
unifi.site.wifi.read.minrateNaAdvertisingRates minrateNaAdvertisingRates
unifi.site.wifi.read.settingPreference settingPreference
unifi.site.wifi.read.no2ghzOui no2ghzOui
unifi.site.wifi.read.privatePreSharedKeysEnabled privatePreSharedKeysEnabled
unifi.site.wifi.read.privatePreSharedKeys privatePreSharedKeys
unifi.site.wifi.read.saeGroups saeGroups
unifi.site.wifi.read.saePsk saePsk
unifi.site.wifi.read.schedule schedule
unifi.site.wifi.read.scheduleWithDuration scheduleWithDuration
unifi.site.wifi.read.bcFilterList bcFilterList
unifi.site.wifi.read.externalId externalId

URL Parameters:

  • id - Internal UniFi site ID (database ID)

Response:

{
  "status": 200,
  "message": "UniFi WiFi Networks Fetched Successfully!",
  "data": [
    {
      "id": "66eb36e54bb53f0ae3fb82bd",
      "name": "TTAD",
      "siteId": "61ae856a8eae567c3905c54f",
      "enabled": true,
      "security": "wpapsk",
      "wpaMode": "wpa2",
      "wpaEnc": "ccmp",
      "wpa3Support": false,
      "wpa3Transition": false,
      "wpa3FastRoaming": false,
      "wpa3Enhanced192": false,
      "passphrase": "S3cur3Alarm!",
      "passphraseAutogenerated": false,
      "hideSSID": false,
      "isGuest": false,
      "band": "2g",
      "bands": ["2g"],
      "networkconfId": "66eb368d4bb53f0ae3fb7e0d",
      "usergroupId": "61ae856a8eae567c3905c55c",
      "apGroupIds": ["66eb36e44bb53f0ae3fb82bc"],
      "apGroupMode": "devices",
      "pmfMode": "disabled",
      "groupRekey": 0,
      "dtimMode": "default",
      "dtimNg": 1,
      "dtimNa": 3,
      "dtim6e": 3,
      "l2Isolation": false,
      "fastRoamingEnabled": false,
      "bssTransition": true,
      "uapsdEnabled": false,
      "iappEnabled": true,
      "proxyArp": false,
      "mcastenhanceEnabled": false,
      "macFilterEnabled": false,
      "macFilterPolicy": "allow",
      "macFilterList": [],
      "radiusDasEnabled": false,
      "radiusMacAuthEnabled": false,
      "radiusMacaclFormat": "none_lower",
      "minrateSettingPreference": "auto",
      "minrateNgEnabled": true,
      "minrateNgDataRateKbps": 1000,
      "minrateNgAdvertisingRates": false,
      "minrateNaEnabled": false,
      "minrateNaDataRateKbps": 6000,
      "minrateNaAdvertisingRates": false,
      "settingPreference": "manual",
      "no2ghzOui": true,
      "privatePreSharedKeysEnabled": false,
      "privatePreSharedKeys": [],
      "saeGroups": [],
      "saePsk": [],
      "schedule": [],
      "scheduleWithDuration": [],
      "bcFilterList": [],
      "externalId": "ba0f579c-6b21-4dfc-8d03-5a1a09243328"
    }
  ],
  "successful": true
}

Update WiFi Network

PATCH /unifi/site/:id/wifi/:wlanId

Update a WiFi network configuration on the UniFi controller.

Authentication Required: Yes

Required Permissions: unifi.access, unifi.site.wifi, unifi.site.wifi.update

URL Parameters:

  • id - Internal UniFi site ID (database ID)
  • wlanId - UniFi WLAN ID

Request Body:

{
  "name": "NewSSIDName",
  "x_passphrase": "NewPassword123",
  "enabled": true,
  "security": "wpapsk",
  "wpa_mode": "wpa2",
  "hide_ssid": false,
  "is_guest": false,
  "band": "both"
}

All fields are optional. Valid values:

  • security: "wpapsk", "wpaeap", "open"
  • wpa_mode: "wpa2", "wpa3", "wpa2wpa3"
  • band: "both", "2g", "5g"

Response:

{
  "status": 200,
  "message": "UniFi WiFi Network Updated Successfully!",
  "data": {
    "id": "66eb36e54bb53f0ae3fb82bd",
    "name": "NewSSIDName",
    "siteId": "61ae856a8eae567c3905c54f",
    "enabled": true,
    "security": "wpapsk",
    "wpaMode": "wpa2",
    "wpaEnc": "ccmp",
    "wpa3Support": false,
    "wpa3Transition": false,
    "wpa3FastRoaming": false,
    "wpa3Enhanced192": false,
    "passphrase": "NewPassword123",
    "passphraseAutogenerated": false,
    "hideSSID": false,
    "isGuest": false,
    "band": "both",
    "bands": ["2g", "5g"],
    "networkconfId": "66eb368d4bb53f0ae3fb7e0d",
    "usergroupId": "61ae856a8eae567c3905c55c",
    "apGroupIds": ["66eb36e44bb53f0ae3fb82bc"],
    "apGroupMode": "devices",
    "pmfMode": "disabled",
    "groupRekey": 0,
    "dtimMode": "default",
    "dtimNg": 1,
    "dtimNa": 3,
    "dtim6e": 3,
    "l2Isolation": false,
    "fastRoamingEnabled": false,
    "bssTransition": true,
    "uapsdEnabled": false,
    "iappEnabled": true,
    "proxyArp": false,
    "mcastenhanceEnabled": false,
    "macFilterEnabled": false,
    "macFilterPolicy": "allow",
    "macFilterList": [],
    "radiusDasEnabled": false,
    "radiusMacAuthEnabled": false,
    "radiusMacaclFormat": "none_lower",
    "minrateSettingPreference": "auto",
    "minrateNgEnabled": true,
    "minrateNgDataRateKbps": 1000,
    "minrateNgAdvertisingRates": false,
    "minrateNaEnabled": false,
    "minrateNaDataRateKbps": 6000,
    "minrateNaAdvertisingRates": false,
    "settingPreference": "manual",
    "no2ghzOui": true,
    "privatePreSharedKeysEnabled": false,
    "privatePreSharedKeys": [],
    "saeGroups": [],
    "saePsk": [],
    "schedule": [],
    "scheduleWithDuration": [],
    "bcFilterList": [],
    "externalId": "ba0f579c-6b21-4dfc-8d03-5a1a09243328"
  },
  "successful": true
}

Get Site Networks

GET /unifi/site/:id/networks

Fetch live network configurations from the UniFi controller for a specific site.

Authentication Required: Yes

Required Permissions: unifi.access, unifi.site.networks

URL Parameters:

  • id - Internal UniFi site ID (database ID)

Response:

{
  "status": 200,
  "message": "UniFi Networks Fetched Successfully!",
  "data": [
    {
      "id": "abc123...",
      "name": "LAN",
      "purpose": "corporate",
      "subnet": "192.168.1.0/24",
      "vlanId": null,
      "dhcpEnabled": true,
      "dhcpStart": "192.168.1.100",
      "dhcpStop": "192.168.1.254",
      "domainName": "localdomain",
      "isNat": true,
      "enabled": true
    }
  ],
  "successful": true
}

Get WLAN Groups

GET /unifi/site/:id/wlan-groups

Fetch WLAN groups (AP groups) from the UniFi controller for a specific site. WLAN groups define which WLANs are broadcast on which access points.

Authentication Required: Yes

Required Permissions: unifi.access, unifi.site.wlan-groups

URL Parameters:

  • id - Internal UniFi site ID (database ID)

Response:

{
  "status": 200,
  "message": "UniFi WLAN Groups Fetched Successfully!",
  "data": [
    {
      "id": "61ae856a8eae567c3905c55d",
      "name": "Default",
      "siteId": "61ae856a8eae567c3905c550",
      "noDelete": true,
      "noEdit": false,
      "hidden": false
    }
  ],
  "successful": true
}

Create WLAN Group

POST /unifi/site/:id/wlan-groups

Create a new WLAN group (AP broadcasting group) on the UniFi controller. WLAN groups control which WLANs are broadcast on which access points.

Authentication Required: Yes

Required Permissions: unifi.access, unifi.site.wlan-groups, unifi.site.wlan-groups.create

URL Parameters:

  • id - Internal UniFi site ID (database ID)

Request Body:

{
  "name": "Lobby APs"
}
Field Type Required Description
name string Yes Name of the WLAN group

Response (201):

{
  "status": 201,
  "message": "UniFi WLAN Group Created Successfully!",
  "data": {
    "id": "abc123...",
    "name": "Lobby APs",
    "siteId": "61ae856a8eae567c3905c550",
    "noDelete": false,
    "noEdit": false,
    "hidden": false
  },
  "successful": true
}

Get AP Groups

GET /unifi/site/:id/ap-groups

Fetch AP groups from the UniFi controller for a specific site. AP groups define collections of access points — individual WLANs can target specific AP groups to control which APs broadcast a given SSID.

Authentication Required: Yes

Required Permissions: unifi.access, unifi.site.ap-groups

URL Parameters:

  • id - Internal UniFi site ID (database ID)

Response:

{
  "status": 200,
  "message": "UniFi AP Groups Fetched Successfully!",
  "data": [
    {
      "id": "61ae856a8eae567c3905c565",
      "name": "All APs",
      "deviceMacs": [
        "18:e8:29:59:25:bc",
        "78:45:58:29:fa:87",
        "68:d7:9a:73:9a:28"
      ],
      "noDelete": true
    },
    {
      "id": "63c5be017e957d08189ed997",
      "name": "301Andrus",
      "deviceMacs": ["78:45:58:29:fa:87", "18:e8:29:59:25:bc"],
      "noDelete": false
    }
  ],
  "successful": true
}

Get Access Points

GET /unifi/site/:id/access-points

Fetch access points (UAPs only) from the UniFi controller for a specific site. This filters the full device list to only return wireless access points.

Authentication Required: Yes

Required Permissions: unifi.access, unifi.site.access-points

URL Parameters:

  • id - Internal UniFi site ID (database ID)

Response:

{
  "status": 200,
  "message": "UniFi Access Points Fetched Successfully!",
  "data": [
    {
      "id": "abc123...",
      "mac": "00:11:22:33:44:55",
      "model": "U6-Pro",
      "type": "uap",
      "name": "Office AP",
      "state": 1,
      "adopted": true,
      "ip": "192.168.1.10",
      "version": "7.1.68"
    }
  ],
  "successful": true
}

Get WiFi Limits

GET /unifi/site/:id/wifi-limits

Check the WiFi SSID limits per access point per radio band. UniFi access points support a maximum of 8 SSIDs per radio. This endpoint shows how many SSIDs are active on each radio and how many more can be added.

Authentication Required: Yes

Required Permissions: unifi.access, unifi.site.wifi-limits

URL Parameters:

  • id - Internal UniFi site ID (database ID)

Response:

{
  "status": 200,
  "message": "UniFi WiFi Limits Fetched Successfully!",
  "data": [
    {
      "apId": "abc123...",
      "apName": "Office AP",
      "mac": "00:11:22:33:44:55",
      "model": "U6-Pro",
      "radios": [
        {
          "radio": "ng",
          "band": "2g",
          "activeWlans": 3,
          "limit": 8,
          "remaining": 5,
          "wlanNames": ["Corporate", "Guest", "IoT"]
        },
        {
          "radio": "na",
          "band": "5g",
          "activeWlans": 3,
          "limit": 8,
          "remaining": 5,
          "wlanNames": ["Corporate", "Guest", "IoT"]
        }
      ]
    }
  ],
  "successful": true
}

Get Speed Profiles

GET /unifi/site/:id/speed-profiles

Fetch speed limit profiles (user groups) from the UniFi controller for a specific site. Speed profiles define bandwidth limits that can be applied to WiFi networks.

Authentication Required: Yes

Required Permissions: unifi.access, unifi.site.speed-profiles

URL Parameters:

  • id - Internal UniFi site ID (database ID)

Response:

{
  "status": 200,
  "message": "UniFi Speed Profiles Fetched Successfully!",
  "data": [
    {
      "id": "61ae856a8eae567c3905c55e",
      "name": "Default",
      "siteId": "61ae856a8eae567c3905c550",
      "noDelete": true,
      "downloadLimitKbps": -1,
      "uploadLimitKbps": -1
    },
    {
      "id": "abc123...",
      "name": "Guest 10Mbps",
      "siteId": "61ae856a8eae567c3905c550",
      "noDelete": false,
      "downloadLimitKbps": 10000,
      "uploadLimitKbps": 5000
    }
  ],
  "successful": true
}

Note: A value of -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:

{
  "name": "Guest 10Mbps",
  "downloadLimitKbps": 10000,
  "uploadLimitKbps": 5000
}
Field Type Required Description
name string Yes Name of the speed profile
downloadLimitKbps number No Download limit in Kbps (-1 or omit = unlimited)
uploadLimitKbps number No Upload limit in Kbps (-1 or omit = unlimited)

Response (201):

{
  "status": 201,
  "message": "UniFi Speed Profile Created Successfully!",
  "data": {
    "id": "abc123...",
    "name": "Guest 10Mbps",
    "siteId": "61ae856a8eae567c3905c550",
    "noDelete": false,
    "downloadLimitKbps": 10000,
    "uploadLimitKbps": 5000
  },
  "successful": true
}

Get Private PSKs

GET /unifi/site/:id/wifi/:wlanId/ppsk

Fetch private pre-shared keys (PPSKs) for a specific WiFi network. PPSKs allow different devices to connect to the same SSID with unique passwords.

Authentication Required: Yes

Required Permissions: unifi.access, unifi.site.wifi, unifi.site.wifi.ppsk

URL Parameters:

  • id - Internal UniFi site ID (database ID)
  • wlanId - UniFi WLAN configuration ID

Response:

{
  "status": 200,
  "message": "UniFi Private PSKs Fetched Successfully!",
  "data": [
    {
      "key": "mySecurePassword123",
      "name": "John's Laptop",
      "mac": null,
      "vlanId": null
    },
    {
      "key": "anotherPassword456",
      "name": "IoT Device",
      "mac": "AA:BB:CC:DD:EE:FF",
      "vlanId": 100
    }
  ],
  "successful": true
}

Create Private PSK

POST /unifi/site/:id/wifi/:wlanId/ppsk

Create a private pre-shared key on a specific WiFi network. This adds a new PPSK to the WLAN's list and enables PPSK mode if not already enabled.

Authentication Required: Yes

Required Permissions: unifi.access, unifi.site.wifi, unifi.site.wifi.ppsk, unifi.site.wifi.ppsk.create

URL Parameters:

  • id - Internal UniFi site ID (database ID)
  • wlanId - UniFi WLAN configuration ID

Request Body:

{
  "key": "mySecurePassword123",
  "name": "John's Laptop",
  "mac": "AA:BB:CC:DD:EE:FF",
  "vlanId": 100
}
Field Type Required Description
key string Yes The pre-shared key (min 8 characters)
name string Yes Descriptive name for this PSK
mac string No MAC address to lock this PSK to a specific device
vlanId number No VLAN ID to assign to clients using this PSK

Response (201):

{
  "status": 201,
  "message": "UniFi Private PSK Created Successfully!",
  "data": [
    {
      "key": "mySecurePassword123",
      "name": "John's Laptop",
      "mac": "AA:BB:CC:DD:EE:FF",
      "vlanId": 100
    }
  ],
  "successful": true
}

Note: The response returns the full updated list of all PPSKs on the WLAN after creation.


POST /unifi/site/:id/link

Link a UniFi site to a company.

Authentication Required: Yes

Required Permissions: unifi.access, unifi.sites.link

URL Parameters:

  • id - Internal UniFi site ID (database ID)

Request Body:

{
  "companyId": "ckx..."
}

Response:

{
  "status": 200,
  "message": "UniFi Site Linked to Company Successfully!",
  "data": {
    "id": "ckx...",
    "name": "Total Tech - Murray Office",
    "siteId": "km9b1v8i",
    "companyId": "ckx...",
    "createdAt": "2025-01-01T00:00:00.000Z",
    "updatedAt": "2025-01-01T00:00:00.000Z"
  },
  "successful": true
}

POST /unifi/site/:id/unlink

Unlink a UniFi site from its associated company.

Authentication Required: Yes

Required Permissions: unifi.access, unifi.sites.link

URL Parameters:

  • id - Internal UniFi site ID (database ID)

Response:

{
  "status": 200,
  "message": "UniFi Site Unlinked from Company Successfully!",
  "data": {
    "id": "ckx...",
    "name": "Total Tech - Murray Office",
    "siteId": "km9b1v8i",
    "companyId": null,
    "createdAt": "2025-01-01T00:00:00.000Z",
    "updatedAt": "2025-01-01T00:00:00.000Z"
  },
  "successful": true
}

Error Responses

All endpoints may return error responses in the following format:

400 Bad Request

{
  "status": 400,
  "message": "Validation error",
  "errors": [
    {
      "path": ["field"],
      "message": "Field is required"
    }
  ],
  "successful": false
}

401 Unauthorized

{
  "status": 401,
  "message": "Unauthorized",
  "successful": false
}

403 Forbidden

{
  "status": 403,
  "message": "Insufficient permissions",
  "successful": false
}

404 Not Found

{
  "status": 404,
  "message": "Resource not found",
  "successful": false
}

500 Internal Server Error

{
  "status": 500,
  "message": "Internal server error",
  "successful": false
}

Authentication

Most endpoints require authentication via an access token in the request headers:

Authorization: Bearer <access_token>

Tokens are obtained through the Microsoft OAuth flow:

  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.