Guide · CRM & Customer Support
MCP Tools for Zendesk — API token auth, cursor pagination, ticket lifecycle, and per-plan rate limits
Zendesk exposes three distinct authentication models — API token (encoded as {email}/token:{api_token} in Basic Auth), OAuth2 Bearer (for user-delegated access), and OAuth2 client_credentials (for machine-to-machine without a user) — and MCP tools must choose the right one for their use case. Two properties of Zendesk distinguish it from typical REST APIs: the endpoint is subdomain-specific ({subdomain}.zendesk.com, not a fixed global URL); and rate limits are per-plan, not per-endpoint (Starter: 10 req/min, Team: 200 req/min, Professional: 400 req/min, Enterprise: 700 req/min). The ticket status flow is a one-way state machine: new → open → pending → hold → solved → closed. The closed status is immutable — you cannot reopen a closed ticket; you must create a follow-up ticket instead.
TL;DR
API token auth: Authorization: Basic base64("{email}/token:{api_token}"). Base URL: https://{subdomain}.zendesk.com/api/v2. List tickets: GET /tickets?page[size]=100. Search: GET /search?query=type:ticket+status:open. Create ticket: POST /tickets with {"ticket":{"subject":"...","comment":{"body":"..."},"requester_id":123}}. Cursor pagination: follow links.next URL until meta.has_more is false. Rate limit exceeded: HTTP 429 with Retry-After seconds. Health probe: GET /users/me.json — returns authenticated user and verifies org accessibility. Closed tickets cannot be updated — they throw a TicketFinalStateError.
Authentication and client setup
For server-side MCP tools, the API token method is simplest: create an API token in Zendesk Admin → Apps and Integrations → APIs → Zendesk API → API Tokens. The token authenticates as the agent user whose email is in the Basic Auth header — choose an agent with appropriate role for the tool's operations (read-only for querying, agent role for creating/updating tickets).
// Zendesk client — handles subdomain routing and auth
class ZendeskClient {
constructor(subdomain, agentEmail, apiToken) {
// API token auth: email/token:TOKEN encoded as Basic Auth
const credentials = Buffer.from(`${agentEmail}/token:${apiToken}`).toString('base64');
this.baseUrl = `https://${subdomain}.zendesk.com/api/v2`;
this.headers = {
'Authorization': `Basic ${credentials}`,
'Content-Type': 'application/json',
};
}
async request(path, method = 'GET', body = null) {
const url = path.startsWith('http') ? path : `${this.baseUrl}${path}`;
const res = await fetch(url, {
method,
headers: this.headers,
body: body ? JSON.stringify(body) : null,
signal: AbortSignal.timeout(30_000),
});
if (res.status === 429) {
const retryAfter = parseInt(res.headers.get('Retry-After') ?? '60', 10);
throw new Error(`Zendesk rate limit exceeded — retry after ${retryAfter}s`);
}
if (!res.ok) {
const error = await res.json().catch(() => ({}));
// Zendesk error format: { error: "RecordNotFound", description: "..." }
throw new Error(`Zendesk ${method} ${path} → ${res.status}: ${error.description ?? error.error}`);
}
if (res.status === 204) return null;
return res.json();
}
}
// OAuth2 Bearer client (for user-delegated access)
class ZendeskOAuthClient {
constructor(subdomain, accessToken) {
this.baseUrl = `https://${subdomain}.zendesk.com/api/v2`;
this.headers = {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
};
}
// Same request() method as above — only auth header differs
}
Ticket operations and status lifecycle
Tickets are the core Zendesk object. The status field represents a one-way workflow: tickets move forward through states but cannot go backward (a solved ticket can be reopened by a new requester reply, but a closed ticket is permanently immutable). The hold status requires a Hold reason and is only available on Professional plans and above.
// Create a new ticket
async function createTicket(client, subject, description, requesterId, options = {}) {
const result = await client.request('/tickets.json', 'POST', {
ticket: {
subject,
comment: { body: description },
requester_id: requesterId,
priority: options.priority ?? 'normal', // low | normal | high | urgent
type: options.type ?? 'problem', // problem | incident | question | task
tags: options.tags ?? [],
custom_fields: options.customFields ?? [],
},
});
return result.ticket;
}
// Update a ticket — only works for non-closed tickets
async function updateTicket(client, ticketId, updates) {
// Attempting to update a closed ticket returns 422 with "TicketFinalStateError"
// Check ticket status before attempting updates
const result = await client.request(`/tickets/${ticketId}.json`, 'PUT', {
ticket: updates,
});
return result.ticket;
}
// Add a comment (internal note vs public reply)
async function addComment(client, ticketId, body, isPublic = true) {
return updateTicket(client, ticketId, {
comment: {
body,
public: isPublic, // false = internal note (only agents see it)
// html_body: '...
', // use html_body for rich text comments
},
});
}
// Ticket status: new → open → pending → hold → solved → closed
// pending = waiting for requester; hold = waiting for third party
// solved → closed happens automatically after your "auto-solve" period (default 28 days)
// NEVER try to update a closed ticket — use createFollowUpTicket instead
async function createFollowUpTicket(client, closedTicketId, subject, body) {
const result = await client.request(`/tickets/${closedTicketId}/create_follower.json`, 'POST', {
ticket: {
subject,
comment: { body },
},
});
return result.ticket;
}
// Side-loading: include related objects in one request to avoid N+1
async function getTicketWithContext(client, ticketId) {
// ?include=users includes requester + assignee data in same response
const result = await client.request(
`/tickets/${ticketId}.json?include=users,groups,organizations`
);
return {
ticket: result.ticket,
users: Object.fromEntries((result.users ?? []).map(u => [u.id, u])),
groups: Object.fromEntries((result.groups ?? []).map(g => [g.id, g])),
};
}
Search API and cursor pagination
Zendesk provides two pagination models: the legacy offset-based (page=N&per_page=100) and the newer cursor-based (page[size]=100, following links.next). Use cursor-based pagination for all new integrations — offset pagination returns incorrect results for large datasets when records are created or deleted during pagination. The Search API supports complex queries combining type filters, field conditions, and date ranges.
// Search tickets with Zendesk search syntax
async function searchTickets(client, query) {
// Search query syntax:
// type:ticket — filter to tickets only
// status:open — filter by status
// assignee:me — assigned to the authenticated user
// created>2026-01-01 — created after date
// tags:bug — has specific tag
// subject:"login error" — subject contains phrase
const result = await client.request(
`/search.json?query=${encodeURIComponent(query)}&sort_by=created_at&sort_order=desc`
);
return result.results; // mixed types; each has result_type field
}
// Cursor-based pagination through all open tickets
async function* getAllOpenTickets(client) {
let url = '/tickets.json?status=open&page[size]=100&sort=created_at';
while (url) {
const page = await client.request(url);
yield* page.tickets;
// links.next is an absolute URL when more pages exist
url = page.meta?.has_more ? page.links?.next : null;
}
}
// Bulk ticket create via batch endpoint (up to 100 per call)
async function createTicketsBatch(client, tickets) {
const result = await client.request('/imports/tickets/create_many.json', 'POST', {
tickets: tickets.map(t => ({
subject: t.subject,
comment: { body: t.body, author_id: t.requesterId },
requester_id: t.requesterId,
status: t.status ?? 'open',
})),
});
// Returns a job_status object — poll GET /job_statuses/{id} for completion
return result.job_status;
}
// Poll a background job until complete
async function waitForJob(client, jobId, maxWaitMs = 60_000) {
const deadline = Date.now() + maxWaitMs;
while (Date.now() < deadline) {
const result = await client.request(`/job_statuses/${jobId}.json`);
const job = result.job_status;
if (job.status === 'completed') return job;
if (job.status === 'failed') throw new Error(`Job ${jobId} failed: ${job.message}`);
await new Promise(r => setTimeout(r, 2_000));
}
throw new Error(`Job ${jobId} timed out after ${maxWaitMs}ms`);
}
Health monitoring
Zendesk does not have a dedicated health endpoint. The GET /users/me.json endpoint is the recommended health probe — it authenticates, verifies the token has at least read scope, and confirms the subdomain routes correctly. A complete health probe should also check queue depth (open ticket count) and rate limit headroom.
async function probeZendesk(client) {
const results = await Promise.allSettled([
// 1. Auth and subdomain routing
(async () => {
const res = await client.request('/users/me.json');
return {
kind: 'auth',
userId: res.user.id,
email: res.user.email,
role: res.user.role, // end-user | agent | admin
};
})(),
// 2. Ticket queue accessibility
(async () => {
const res = await client.request('/tickets/count.json?status=open');
// Rate limit remaining visible in response headers, but not in JSON body
return {
kind: 'queue',
openTickets: res.count?.value ?? 0,
};
})(),
// 3. Rate limit check via response headers
(async () => {
// Make a lightweight request and read rate limit headers
const url = `${client.baseUrl}/users/me.json`;
const res = await fetch(url, {
headers: client.headers,
signal: AbortSignal.timeout(5_000),
});
const remaining = res.headers.get('X-Rate-Limit-Remaining');
const limit = res.headers.get('X-Rate-Limit');
return {
kind: 'rate_limit',
remaining: remaining ? parseInt(remaining, 10) : null,
limit: limit ? parseInt(limit, 10) : null,
};
})(),
]);
return {
healthy: results.every(r => r.status === 'fulfilled'),
components: results.map(r =>
r.status === 'fulfilled'
? { ...r.value, ok: true }
: { ok: false, error: r.reason?.message }
),
};
}
Common integration pitfalls
- Using offset pagination for large datasets
- Zendesk's offset pagination (
page=N) has a maximum of 1,000 pages × 100 records = 100,000 records. More importantly, offset pagination returns inconsistent results when records are added or deleted between page requests. For any dataset that changes during pagination, use cursor-based pagination (page[size]=100+links.next). - Attempting to update or close a closed ticket
- Once a ticket reaches
closedstatus (set automatically after the auto-solve period or by a workflow trigger), it is immutable. API attempts to update it return a 422 withTicketFinalStateError. Create a follow-up ticket usingPOST /tickets/{id}/create_followerwhich links the new ticket to the original thread. - Rate limits are per-plan, not per-endpoint or per-key
- Zendesk rate limits apply to the entire portal's API usage, shared across all integrations. A Starter plan portal gets 10 requests per minute total — not per integration. If you have multiple MCP tools or other integrations hitting the same Zendesk portal, they all share the same quota. The plan limit is visible in the
X-Rate-Limitresponse header. - Missing subdomain routing in webhook handling
- Zendesk webhook payloads include the subdomain in the payload. If your MCP tool needs to map a webhook to the correct Zendesk client instance (for multi-tenant deployments), extract the subdomain from the webhook's
account_subdomainfield — do not assume a fixed subdomain across all webhook events. - Confusing pending vs solved ticket status for automation
pendingmeans waiting for the requester (customer) to reply;solvedmeans the agent considers the issue resolved but the ticket is still open to requester comments. Building automation that auto-closespendingtickets confuses customers who haven't yet responded to the agent's question.
Related guides
- MCP tools for Salesforce — OAuth2 Connected App, instance URL routing, governor limits, and composite API
- MCP tools for HubSpot — Private App tokens, CRM Objects API v3, batch upsert, and search
- MCP tools for Intercom — Access Token auth, Conversation model, search, and webhook validation
- MCP tools for Freshdesk — API key as Basic auth, status/priority codes, and plan-based rate limits
- MCP tools for PagerDuty — Token auth, incident lifecycle, on-call schedules, and escalation
- MCP server health check patterns — composite endpoints and failure classification