Help us improve
Share bugs, ideas, or general feedback.
From kaseya-autotask
Provides Autotask REST API patterns: header authentication, zone detection, queries with 14 operators, pagination, rate limiting, error handling, and retries.
npx claudepluginhub wyre-technology/msp-claude-plugins --plugin autotaskHow this skill is triggered — by the user, by Claude, or both
Slash command
/kaseya-autotask:api-patternsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
The Autotask REST API provides access to 215+ entities across the PSA. This skill covers authentication, query building, pagination, error handling, and performance optimization patterns.
Provides ConnectWise Manage PSA REST API patterns: authentication with public/private keys and clientId, pagination via page/pageSize, conditions query syntax, 60/min rate limiting, and error handling.
Provides ConnectWise Automate REST API patterns: integrator/user authentication, token management, OData filtering, pagination, rate limiting, and error handling.
Discovers and executes Autotask MCP tools using meta-tools (list_categories, list_category_tools, execute_tool, router) for lazy-loaded connections when direct tools unavailable.
Share bugs, ideas, or general feedback.
The Autotask REST API provides access to 215+ entities across the PSA. This skill covers authentication, query building, pagination, error handling, and performance optimization patterns.
Autotask uses header-based authentication (NOT Basic Auth):
GET /v1.0/Tickets
ApiIntegrationCode: YOUR_INTEGRATION_CODE
UserName: your-api-user@domain.com
Secret: YOUR_SECRET
Content-Type: application/json
Required Headers:
| Header | Description |
|---|---|
ApiIntegrationCode | Your Autotask integration code |
UserName | API username (email address) |
Secret | API secret/password |
Content-Type | application/json |
export AUTOTASK_USERNAME="your-api-user@domain.com"
export AUTOTASK_INTEGRATION_CODE="YOUR_INTEGRATION_CODE"
export AUTOTASK_SECRET="YOUR_SECRET"
Autotask operates in multiple zones. The API can automatically detect your zone:
GET https://webservices.autotask.net/atservicesrest/v1.0/ZoneInformation
UserName: your-api-user@domain.com
Response:
{
"url": "https://webservices5.autotask.net/atservicesrest",
"webUrl": "https://ww5.autotask.net"
}
Common Zones:
| Zone | API URL |
|---|---|
| webservices | https://webservices.autotask.net/atservicesrest |
| webservices1 | https://webservices1.autotask.net/atservicesrest |
| webservices2 | https://webservices2.autotask.net/atservicesrest |
| webservices5 | https://webservices5.autotask.net/atservicesrest |
| webservices6 | https://webservices6.autotask.net/atservicesrest |
The Autotask API supports 14 query operators:
| Operator | Description | Example |
|---|---|---|
eq | Equals | {"field": "status", "op": "eq", "value": 1} |
ne / noteq | Not equals | {"field": "status", "op": "noteq", "value": 5} |
gt | Greater than | {"field": "priority", "op": "gt", "value": 2} |
gte | Greater than or equal | {"field": "createDate", "op": "gte", "value": "2024-01-01"} |
lt | Less than | {"field": "priority", "op": "lt", "value": 3} |
lte | Less than or equal | {"field": "dueDateTime", "op": "lte", "value": "2024-02-15T17:00:00Z"} |
contains | Contains substring | {"field": "title", "op": "contains", "value": "email"} |
startsWith | Starts with | {"field": "companyName", "op": "startsWith", "value": "Acme"} |
endsWith | Ends with | {"field": "email", "op": "endsWith", "value": "@acme.com"} |
in | In array | {"field": "status", "op": "in", "value": [1, 2, 5]} |
notIn | Not in array | {"field": "status", "op": "notIn", "value": [5, 10]} |
isNull | Is null | {"field": "assignedResourceId", "op": "isNull"} |
isNotNull | Is not null | {"field": "dueDateTime", "op": "isNotNull"} |
between | Between range | {"field": "createDate", "op": "between", "value": ["2024-01-01", "2024-01-31"]} |
POST /v1.0/Tickets/query
Content-Type: application/json
{
"filter": [
{"field": "companyID", "op": "eq", "value": 12345},
{"field": "status", "op": "noteq", "value": 5}
],
"maxRecords": 50,
"includeFields": ["Company.companyName", "AssignedResource.firstName"]
}
AND conditions (default):
{
"filter": [
{"field": "companyID", "op": "eq", "value": 12345},
{"field": "priority", "op": "lte", "value": 2},
{"field": "status", "op": "in", "value": [1, 2, 5]}
]
}
OR conditions with items array:
{
"filter": [
{
"op": "or",
"items": [
{"field": "priority", "op": "eq", "value": 1},
{"field": "status", "op": "eq", "value": 14}
]
}
]
}
Nested AND/OR:
{
"filter": [
{"field": "companyID", "op": "eq", "value": 12345},
{
"op": "or",
"items": [
{"field": "priority", "op": "in", "value": [3, 4]},
{
"op": "and",
"items": [
{"field": "status", "op": "eq", "value": 1},
{"field": "estimatedHours", "op": "gt", "value": 10}
]
}
]
}
]
}
Retrieve related entity fields in a single request:
{
"filter": [{"field": "id", "op": "gt", "value": 0}],
"includeFields": [
"Company.companyName",
"Company.phone",
"AssignedResource.firstName",
"AssignedResource.lastName",
"Contact.emailAddress"
]
}
Response with includes:
{
"items": [
{
"id": 54321,
"title": "Email issue",
"companyID": 12345,
"companyName": "Acme Corporation",
"companyPhone": "555-123-4567",
"assignedResourceFirstName": "Jane",
"assignedResourceLastName": "Tech"
}
]
}
{
"filter": [{"field": "id", "op": "gt", "value": 0}],
"maxRecords": 100,
"pageNumber": 1
}
Pagination Fields:
| Field | Description | Max |
|---|---|---|
maxRecords | Records per page | 500 |
pageNumber | Current page (1-based) | - |
{
"items": [...],
"pageDetails": {
"count": 100,
"nextPageUrl": "/v1.0/Tickets/query?pageNumber=2",
"prevPageUrl": null,
"requestCount": 2847
}
}
async function fetchAllTickets(filter) {
const allItems = [];
let pageNumber = 1;
let hasMore = true;
while (hasMore) {
const response = await fetch('/v1.0/Tickets/query', {
method: 'POST',
body: JSON.stringify({
filter,
maxRecords: 500,
pageNumber
})
});
const data = await response.json();
allItems.push(...data.items);
hasMore = data.pageDetails.nextPageUrl !== null;
pageNumber++;
}
return allItems;
}
Autotask enforces two hard limits:
| Limit | Value | Scope |
|---|---|---|
| Concurrent threads per endpoint | 3 | Per API tracking identifier (your integrationCode) |
| Total requests per hour | 10,000 | Per Autotask tenant database (all integrations combined) |
Concurrent thread limit is the most common cause of slowdowns in LLM-driven integrations. When Claude issues several tool calls in parallel (e.g., tickets search + companies search + contacts search), all three may target the Tickets endpoint simultaneously and hit the 3-thread cap.
When using the MCP server or autotask-node SDK, this is handled automatically — excess requests are queued and released as slots free up, so you won't see hard failures, but responses may be slower under load.
Multi-user / shared key risk: The 3-thread limit applies per integrationCode. If multiple users or teams share the same credentials, they compete for the same 3 slots. In a team deployment, give each team their own API user:
Support Team → integrationCode: SUPPORT_TEAM_CODE (3 threads, independent)
Projects Team → integrationCode: PROJECTS_TEAM_CODE (3 threads, independent)
When the concurrent thread limit or hourly request limit is exceeded (HTTP 429):
{
"errors": [
{
"message": "Rate limit exceeded. Try again in 30 seconds."
}
]
}
async function requestWithRetry(url, options, maxRetries = 5) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
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;
} catch (error) {
if (attempt === maxRetries - 1) throw error;
// Exponential backoff with jitter
const delay = Math.pow(2, attempt) * 1000 + Math.random() * 1000;
await sleep(delay);
}
}
}
To maximize throughput without hitting the per-endpoint thread limit, query different endpoints in parallel rather than the same endpoint multiple times:
// Good: parallel requests to different endpoints — each has its own 3-thread budget
const [tickets, companies, contacts] = await Promise.all([
client.tickets.query().where('status', 'in', [1, 5]).execute(),
client.companies.query().where('companyType', 'eq', 1).execute(),
client.contacts.query().where('isActive', 'eq', true).execute(),
]);
// Avoid: parallel requests to the SAME endpoint — they share 3 threads
// (will queue automatically, but adds latency)
const [page1, page2, page3] = await Promise.all([
client.tickets.query().pageNumber(1).execute(), // ← same endpoint
client.tickets.query().pageNumber(2).execute(), // ← same endpoint
client.tickets.query().pageNumber(3).execute(), // ← same endpoint
]);
For bulk operations, batch requests to avoid the hourly limit:
async function batchProcess(items, batchSize = 50, delayMs = 1000) {
const results = [];
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
const batchResults = await Promise.all(
batch.map(item => processItem(item))
);
results.push(...batchResults);
if (i + batchSize < items.length) {
await sleep(delayMs);
}
}
return results;
}
| Code | Meaning | Action |
|---|---|---|
| 200 | Success | Process response |
| 201 | Created | Entity created successfully |
| 400 | Bad Request | Check request format/values |
| 401 | Unauthorized | Verify credentials |
| 403 | Forbidden | Check permissions |
| 404 | Not Found | Entity doesn't exist |
| 409 | Conflict | Resource locked/modified |
| 429 | Rate Limited | Implement backoff |
| 500 | Server Error | Retry with backoff |
{
"errors": [
{
"message": "The value '999' is not valid for field 'status'.",
"field": "status",
"value": 999
}
]
}
function handleApiError(response) {
if (!response.errors) return;
response.errors.forEach(error => {
console.log(`Error: ${error.message}`);
if (error.field) {
console.log(` Field: ${error.field}`);
console.log(` Invalid Value: ${error.value}`);
// Suggest fix based on field
if (error.field === 'status') {
console.log(' Suggestion: Query /v1.0/Tickets/entityInformation/fields for valid status IDs');
} else if (error.field === 'queueID') {
console.log(' Suggestion: Query /v1.0/Queues for valid queue IDs');
}
}
});
}
GET /v1.0/Tickets/entityInformation/fields
Response:
{
"fields": [
{
"name": "status",
"dataType": "Integer",
"isRequired": true,
"isPickList": true,
"picklistValues": [
{"value": 1, "label": "New"},
{"value": 2, "label": "In Progress"},
{"value": 5, "label": "Complete"}
]
}
]
}
GET /v1.0/Tickets/entityInformation/userDefinedFields
POST /v1.0/Tickets
Content-Type: application/json
{
"companyID": 12345,
"title": "New ticket",
"status": 1,
"priority": 2,
"queueID": 8
}
Single entity:
GET /v1.0/Tickets/54321
Query:
POST /v1.0/Tickets/query
PATCH /v1.0/Tickets
Content-Type: application/json
{
"id": 54321,
"status": 2,
"assignedResourceID": 29744150
}
PUT /v1.0/Tickets/54321
Content-Type: application/json
{
"id": 54321,
"companyID": 12345,
"title": "Updated ticket",
"status": 2,
"priority": 2,
"queueID": 8
}
DELETE /v1.0/Tickets/54321
Note: Not all entities support DELETE. Check entity documentation.
{
"filter": [{"field": "id", "op": "gt", "value": 0}],
"fields": ["id", "title", "status", "priority"]
}
Good - Uses indexed field:
{"field": "companyID", "op": "eq", "value": 12345}
Avoid - Full text search:
{"field": "description", "op": "contains", "value": "error"}
Cache slowly-changing data:
const cache = new Map();
async function getQueues() {
if (!cache.has('queues') || cache.get('queues').expires < Date.now()) {
const queues = await fetchQueues();
cache.set('queues', {
data: queues,
expires: Date.now() + 5 * 60 * 1000 // 5 minutes
});
}
return cache.get('queues').data;
}
integrationCode. Each team using the integration should have their own API user so they don't compete for the same thread budget