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 autotaskThis skill uses the workspace's default tool permissions.
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.
Guides Next.js Cache Components and Partial Prerendering (PPR) with cacheComponents enabled. Implements 'use cache', cacheLife(), cacheTag(), revalidateTag(), static/dynamic optimization, and cache debugging.
Migrates code, prompts, and API calls from Claude Sonnet 4.0/4.5 or Opus 4.1 to Opus 4.5, updating model strings on Anthropic, AWS, GCP, Azure platforms.
Compresses source documents into lossless, LLM-optimized distillates preserving all facts and relationships. Use for 'distill documents' or 'create distillate' requests.
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