ConnectWise Manage API Patterns — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited ConnectWise Manage API Patterns (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
The ConnectWise PSA REST API provides access to all PSA entities including tickets, companies, contacts, projects, and time entries. This skill covers authentication, query syntax, pagination, rate limiting, and best practices for API integration.
| Region | Base URL |
|---|---|
| North America | https://api-na.myconnectwise.net/{codebase}/apis/3.0/ |
| Europe | https://api-eu.myconnectwise.net/{codebase}/apis/3.0/ |
| Australia | https://api-au.myconnectwise.net/{codebase}/apis/3.0/ |
Replace {codebase} with your company identifier (e.g., v4_6_release or custom).
Some instances may use legacy URLs:
https://api-na.myconnectwise.net/v4_6_release/apis/3.0/
https://api-staging.connectwisedev.com/v4_6_release/apis/3.0/ConnectWise PSA uses Basic Authentication with a combined credential string plus a Client ID header.
Authorization: Basic base64({companyId}+{publicKey}:{privateKey})
clientId: {your-client-id} companyId + "+" + publicKey + ":" + privateKey
Example: company+publickey:privatekey base64("company+publickey:privatekey") = "Y29tcGFueStwdWJsaWNrZXk6cHJpdmF0ZWtleQ==" Authorization: Basic Y29tcGFueStwdWJsaWNrZXk6cHJpdmF0ZWtleQ==
clientId: your-registered-client-id
Content-Type: application/jsonGET /v4_6_release/apis/3.0/service/tickets
Host: api-na.myconnectwise.net
Authorization: Basic Y29tcGFueStwdWJsaWNrZXk6cHJpdmF0ZWtleQ==
clientId: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Content-Type: application/jsonconst companyId = process.env.CONNECTWISE_COMPANY_ID;
const publicKey = process.env.CONNECTWISE_PUBLIC_KEY;
const privateKey = process.env.CONNECTWISE_PRIVATE_KEY;
const clientId = process.env.CONNECTWISE_CLIENT_ID;
const credentials = `${companyId}+${publicKey}:${privateKey}`;
const base64Credentials = Buffer.from(credentials).toString('base64');
const headers = {
'Authorization': `Basic ${base64Credentials}`,
'clientId': clientId,
'Content-Type': 'application/json'
};conditions=field operator value| Operator | Description | Example |
|---|---|---|
= | Equals | status/id=1 |
!= | Not equals | status/id!=5 |
< | Less than | priority/id<3 |
<= | Less than or equal | priority/id<=2 |
> | Greater than | dateEntered>2024-01-01 |
>= | Greater than or equal | dateEntered>=2024-01-01 |
contains | Contains substring | summary contains "email" |
like | Pattern match | summary like "%email%" |
in | In list | status/id in (1,2,3) |
not in | Not in list | status/id not in (5) |
Use / to reference nested fields:
company/id=12345
status/name="New"
contact/firstName contains "John"AND (default):
conditions=company/id=12345 and status/id!=5 and priority/id<=2OR:
conditions=status/id=1 or status/id=2Complex:
conditions=(status/id=1 or status/id=2) and company/id=12345Date format: YYYY-MM-DD or ISO 8601
conditions=dateEntered>=[2024-01-01]
conditions=dateEntered>=[2024-01-01T00:00:00Z] and dateEntered<[2024-02-01T00:00:00Z]Exact match:
conditions=summary="Email not working"Contains:
conditions=summary contains "email"Like (wildcards):
conditions=summary like "%email%"
conditions=company/identifier like "AC%"conditions=contact=null
conditions=assignedResource!=nullSpecial characters must be URL-encoded:
| Character | Encoded |
|---|---|
| Space | %20 |
= | %3D |
< | %3C |
> | %3E |
" | %22 |
Example:
GET /service/tickets?conditions=company/id%3D12345%20and%20status/id!%3D5| Parameter | Type | Default | Max | Description |
|---|---|---|---|---|
page | int | 1 | - | Page number (1-based) |
pageSize | int | 25 | 1000 | Records per page |
GET /service/tickets?page=1&pageSize=100| Header | Description |
|---|---|
Link | Contains next/prev page URLs |
X-Total-Count | Total record count (if requested) |
async function fetchAllTickets(conditions) {
const allTickets = [];
let page = 1;
const pageSize = 250;
let hasMore = true;
while (hasMore) {
const response = await fetch(
`${baseUrl}/service/tickets?conditions=${conditions}&page=${page}&pageSize=${pageSize}`,
{ headers }
);
const tickets = await response.json();
allTickets.push(...tickets);
hasMore = tickets.length === pageSize;
page++;
}
return allTickets;
}GET /service/tickets?conditions=status/id!=5&pageSize=1&fields=idCheck X-Total-Count header or use /count endpoint:
GET /service/tickets/count?conditions=status/id!=5| Limit | Value |
|---|---|
| Requests per minute | 60 |
| Per API member | Yes |
| Header | Description |
|---|---|
X-RateLimit-Limit | Maximum requests per minute |
X-RateLimit-Remaining | Requests remaining in window |
X-RateLimit-Reset | Seconds until limit resets |
When rate limited, you receive HTTP 429:
{
"code": "RateLimitExceeded",
"message": "Rate limit exceeded. Try again in 30 seconds."
}async function requestWithRetry(url, options, maxRetries = 5) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const response = await fetch(url, options);
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After') || 30;
const jitter = Math.random() * 1000;
await sleep(retryAfter * 1000 + jitter);
continue;
}
return response;
}
throw new Error('Max retries exceeded');
}| Code | Meaning | Action |
|---|---|---|
| 200 | Success | Process response |
| 201 | Created | Entity created |
| 204 | No Content | Delete successful |
| 400 | Bad Request | Check request format |
| 401 | Unauthorized | Verify credentials |
| 403 | Forbidden | Check permissions |
| 404 | Not Found | Entity doesn't exist |
| 409 | Conflict | Record locked/modified |
| 429 | Rate Limited | Implement backoff |
| 500 | Server Error | Retry with backoff |
{
"code": "InvalidArgument",
"message": "The value 'invalid' is not valid for field 'status/id'.",
"errors": [
{
"code": "InvalidArgument",
"message": "status/id must be a valid integer",
"field": "status/id"
}
]
}| Error | Cause | Resolution |
|---|---|---|
InvalidCredentials | Bad auth | Verify company ID, keys |
MissingClientId | No clientId header | Add clientId header |
InvalidArgument | Bad field value | Check field type/values |
RequiredFieldMissing | Missing required field | Add required fields |
RecordNotFound | Entity doesn't exist | Verify ID exists |
RecordLocked | Being edited | Retry after delay |
Request specific fields only:
GET /service/tickets?fields=id,summary,status/name,company/nameGET /service/tickets?orderBy=priority/id asc, dateEntered descInclude child records:
GET /service/tickets?childconditions=notes/text contains "update"GET /service/tickets?customFieldConditions=customField1 contains "value"ConnectWise can POST to your endpoint on entity changes:
{
"Action": "updated",
"ID": 54321,
"Type": "ticket",
"MemberID": 123,
"Callback": {
"ID": 54321,
"Type": "ticket"
}
}POST /system/callbacks
Content-Type: application/json
{
"url": "https://your-server.com/webhook",
"objectId": 0,
"type": "ticket",
"level": "owner",
"description": "Ticket updates webhook"
}export CONNECTWISE_COMPANY_ID="your-company-id"
export CONNECTWISE_PUBLIC_KEY="your-public-key"
export CONNECTWISE_PRIVATE_KEY="your-private-key"
export CONNECTWISE_CLIENT_ID="your-client-id"
export CONNECTWISE_SITE="api-na.myconnectwise.net"const config = {
companyId: process.env.CONNECTWISE_COMPANY_ID,
publicKey: process.env.CONNECTWISE_PUBLIC_KEY,
privateKey: process.env.CONNECTWISE_PRIVATE_KEY,
clientId: process.env.CONNECTWISE_CLIENT_ID,
site: process.env.CONNECTWISE_SITE || 'api-na.myconnectwise.net',
apiPath: '/apis/3.0'
};~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.