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 closed status (set automatically after the auto-solve period or by a workflow trigger), it is immutable. API attempts to update it return a 422 with TicketFinalStateError. Create a follow-up ticket using POST /tickets/{id}/create_follower which 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-Limit response 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_subdomain field — do not assume a fixed subdomain across all webhook events.
Confusing pending vs solved ticket status for automation
pending means waiting for the requester (customer) to reply; solved means the agent considers the issue resolved but the ticket is still open to requester comments. Building automation that auto-closes pending tickets confuses customers who haven't yet responded to the agent's question.

Related guides