Guide · CRM & Customer Support

MCP Tools for Freshdesk — API key Basic auth, status/priority integer codes, and plan-based rate limits

Freshdesk uses a distinctive authentication pattern that catches every new integrator: the API key goes in the username field of HTTP Basic Auth, and the password field must be the literal string X — not empty, not "password", not the API key again — exactly the uppercase letter X. This is documented but frequently missed, producing 401 errors that look identical to wrong-credential errors. Freshdesk's endpoint is subdomain-specific ({subdomain}.freshdesk.com), ticket status and priority are integers (not strings), and rate limits depend on the plan: Sprout gets 40 requests/minute, Blossom/Garden 50, Estate 100, Forest 400. Pagination uses page and per_page offset parameters (not cursors).

TL;DR

Auth: Authorization: Basic base64("{api_key}:X") — the password is the single uppercase letter X. Base URL: https://{subdomain}.freshdesk.com/api/v2. List tickets: GET /tickets?page=1&per_page=30. Create ticket: POST /tickets with {"subject":"...","description":"...","email":"user@co.com","priority":1,"status":2}. Status integers: 2=Open, 3=Pending, 4=Resolved, 5=Closed. Priority integers: 1=Low, 2=Medium, 3=High, 4=Urgent. Add note: POST /tickets/{id}/notes with {"body":"...","private":true}. Health probe: GET /api/v2/settings/helpdesk. Rate limit exceeded: HTTP 429 with Retry-After header.

Authentication — the literal X password

Freshdesk's API key authentication is implemented as HTTP Basic Auth where:

This is not a placeholder — it is literally the character X, chosen by Freshdesk because Basic Auth requires a non-empty password field but the API key alone is sufficient for authentication. Sending an empty password or omitting the colon separator causes a 401 that is indistinguishable from a wrong API key. The API key itself does not expire but can be regenerated from the UI, which immediately revokes the old key.

// Freshdesk client — API key as Basic Auth with literal "X" password
class FreshdeskClient {
  constructor(subdomain, apiKey) {
    this.baseUrl = `https://${subdomain}.freshdesk.com/api/v2`;
    // API key as username, "X" as password — this is correct, not a typo
    const credentials = Buffer.from(`${apiKey}:X`).toString('base64');
    this.headers = {
      'Authorization': `Basic ${credentials}`,
      'Content-Type': 'application/json',
    };
  }

  async request(path, method = 'GET', body = null) {
    const res = await fetch(`${this.baseUrl}${path}`, {
      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(`Freshdesk rate limit exceeded — retry after ${retryAfter}s`);
    }
    if (!res.ok) {
      const error = await res.json().catch(() => ({}));
      // Freshdesk error format: { description: "...", errors: [...] }
      const msg = error.description ?? JSON.stringify(error.errors ?? error);
      throw new Error(`Freshdesk ${method} ${path} → ${res.status}: ${msg}`);
    }
    if (res.status === 204) return null;
    return res.json();
  }
}

// Status and priority constants — always use integers, never strings
const TICKET_STATUS = {
  OPEN: 2,
  PENDING: 3,
  RESOLVED: 4,
  CLOSED: 5,
};

const TICKET_PRIORITY = {
  LOW: 1,
  MEDIUM: 2,
  HIGH: 3,
  URGENT: 4,
};

Ticket operations

Freshdesk tickets require either an email (which creates or finds a contact) or a requester_id (an existing contact's ID). The email field is Freshdesk's primary contact deduplication key — if a contact with that email already exists, the ticket is associated with them; otherwise, a new contact is created automatically. All ticket mutations return the updated ticket object in the response body; no separate GET is needed to confirm the update.

// Create a new ticket (creates contact by email if not already existing)
async function createTicket(client, subject, description, email, options = {}) {
  const result = await client.request('/tickets', 'POST', {
    subject,
    description,          // HTML allowed; use description_text for plain text
    email,
    priority: options.priority ?? TICKET_PRIORITY.MEDIUM,
    status: options.status ?? TICKET_STATUS.OPEN,
    tags: options.tags ?? [],
    type: options.type,   // Question | Incident | Problem | Feature Request | Refund
    // custom_fields: { cf_fieldname: value }
  });
  return result;
}

// Update ticket — partial update, only send fields you want to change
async function updateTicket(client, ticketId, updates) {
  return client.request(`/tickets/${ticketId}`, 'PUT', updates);
}

// Resolve a ticket
async function resolveTicket(client, ticketId, note) {
  if (note) {
    await addNote(client, ticketId, note, false); // public resolution note
  }
  return updateTicket(client, ticketId, { status: TICKET_STATUS.RESOLVED });
}

// Add a note or reply to a ticket
async function addNote(client, ticketId, body, isPrivate = true, attachments = []) {
  // Private=true: internal note (only agents see it)
  // Private=false: public reply (sent to requester via email)
  return client.request(`/tickets/${ticketId}/notes`, 'POST', {
    body,           // HTML supported
    private: isPrivate,
    notify_emails: [], // optional: CC specific agents on this note
  });
}

// Get full ticket with conversations (note/reply history)
async function getTicketWithConversations(client, ticketId) {
  const [ticket, conversations] = await Promise.all([
    client.request(`/tickets/${ticketId}`),
    client.request(`/tickets/${ticketId}/conversations`),
  ]);
  return { ticket, conversations };
}

// Assign ticket to an agent group
async function assignTicket(client, ticketId, agentId, groupId) {
  return updateTicket(client, ticketId, {
    responder_id: agentId ?? null,
    group_id: groupId ?? null,
  });
}

Search and pagination

Freshdesk uses offset pagination with page and per_page parameters. The maximum per_page is 100 for most endpoints. Unlike cursor-based pagination, Freshdesk does not provide a total count in list responses — you know you've reached the last page when the response returns fewer items than per_page (or an empty array). The filter-based ticket search endpoint supports predefined filter sets (e.g., all open tickets by type) and custom filter expressions.

// Paginate through all tickets matching a filter
async function* getTicketsByFilter(client, filter = {}) {
  // filter options: email, requester_id, company_id, updated_since (ISO8601)
  const params = new URLSearchParams({ per_page: '100' });
  if (filter.email) params.set('email', filter.email);
  if (filter.status) params.set('status', filter.status.toString());
  if (filter.updatedSince) params.set('updated_since', filter.updatedSince);

  let page = 1;
  while (true) {
    params.set('page', page.toString());
    const tickets = await client.request(`/tickets?${params}`);

    if (!tickets.length) break;
    yield* tickets;

    if (tickets.length < 100) break; // last page has fewer than per_page items
    page++;
  }
}

// Search tickets with Freshdesk query syntax
async function searchTickets(client, query, page = 1) {
  // Query syntax: "status:2 AND priority:3" or "email:'user@co.com'"
  // Note: query string must be URL-encoded and wrapped in single quotes in some fields
  const params = new URLSearchParams({
    query: `"${query}"`,
    page: page.toString(),
  });
  const result = await client.request(`/search/tickets?${params}`);
  // result: { total: N, results: [...tickets] }
  return result;
}

// Get a contact by email
async function getContactByEmail(client, email) {
  const contacts = await client.request(
    `/contacts?email=${encodeURIComponent(email)}`
  );
  return contacts[0] ?? null; // returns array even for single match
}

// Create a contact explicitly (vs auto-creation via ticket email)
async function createContact(client, name, email, phone, companyId) {
  return client.request('/contacts', 'POST', {
    name,
    email,
    phone,
    company_id: companyId,
  });
}

Rate limits and plan tiers

Freshdesk's rate limits are tied to the plan, not the API key. All API calls from all integrations in the same Freshdesk account share the same per-minute quota. The limit is visible in the X-RateLimit-Limit response header; X-RateLimit-Remaining shows how many calls are left in the current minute window. When the limit is exceeded, the API returns 429 with a Retry-After header indicating how many seconds to wait.

PlanRequests/minRequests/hour
Sprout (Free)402,400
Blossom / Garden503,000
Estate1006,000
Forest40024,000
// Rate-limit-aware request with exponential backoff
async function requestWithBackoff(client, path, method = 'GET', body = null, maxRetries = 4) {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await client.request(path, method, body);
    } catch (err) {
      if (!err.message.includes('rate limit') || attempt === maxRetries) throw err;
      // Use Retry-After header value if available (extracted from error message)
      const retryMatch = err.message.match(/retry after (\d+)s/);
      const waitMs = retryMatch ? parseInt(retryMatch[1], 10) * 1000 : 1000 * Math.pow(2, attempt);
      await new Promise(r => setTimeout(r, waitMs));
    }
  }
}

Health monitoring

Freshdesk's GET /api/v2/settings/helpdesk endpoint returns the helpdesk configuration and verifies both authentication and org accessibility in one request. A complete health probe should also verify ticket read access and check rate limit headroom from response headers.

async function probeFreshdesk(client) {
  const results = await Promise.allSettled([

    // 1. Auth + helpdesk accessibility
    (async () => {
      const res = await client.request('/settings/helpdesk');
      return {
        kind: 'auth',
        helpdesk: res.primary_language,
        portalName: res.portal_name,
      };
    })(),

    // 2. Ticket read access + rate limit headers
    (async () => {
      const url = `${client.baseUrl}/tickets?per_page=1`;
      const res = await fetch(url, {
        headers: client.headers,
        signal: AbortSignal.timeout(5_000),
      });
      if (!res.ok) throw new Error(`/tickets → ${res.status}`);

      const rateLimitLimit = res.headers.get('X-RateLimit-Limit');
      const rateLimitRemaining = res.headers.get('X-RateLimit-Remaining');

      const tickets = await res.json();
      return {
        kind: 'tickets',
        accessible: true,
        ticketsReturned: tickets.length,
        rateLimit: {
          limit: rateLimitLimit ? parseInt(rateLimitLimit, 10) : null,
          remaining: rateLimitRemaining ? parseInt(rateLimitRemaining, 10) : null,
        },
      };
    })(),

    // 3. Agent list (verifies admin or agent access scope)
    (async () => {
      const agents = await client.request('/agents?per_page=5');
      return {
        kind: 'agents',
        agentsAccessible: true,
        count: agents.length,
      };
    })(),
  ]);

  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 empty string or wrong value as the password in Basic Auth
The password in Freshdesk's Basic Auth must be the uppercase letter X — not empty string, not x (lowercase), not null, and not the API key repeated. The base64("{api_key}:X") pattern is exact. Any variation produces a 401 that looks like a wrong API key error.
Using string values for status and priority
Freshdesk's API accepts integers for status and priority, not strings. Passing "open" instead of 2 for the status field returns a 400 validation error. Always use the integer constants: status 2=Open, 3=Pending, 4=Resolved, 5=Closed; priority 1=Low, 2=Medium, 3=High, 4=Urgent.
Not accounting for shared rate limit quotas
Rate limits are per-helpdesk-account, not per-API-key. If a bulk import job, another integration, or a reporting tool is running simultaneously, your MCP tool shares that quota. On the free Sprout plan at 40 req/min, a batch operation by another tool can exhaust the quota in under a minute. Monitor X-RateLimit-Remaining and back off proactively before hitting zero.
Searching by email via /tickets instead of /contacts
GET /tickets?email=user@co.com filters tickets by the requester's email. GET /contacts?email=user@co.com returns the contact record with their profile. These are different queries — the first returns tickets, the second returns the person. Building a "find all tickets for this user" feature requires first fetching the contact ID, then filtering tickets by requester_id.
Assuming note body accepts plain text only
Freshdesk's ticket description and note body fields accept HTML. Plain text with newlines should be wrapped in <p> tags or sent as <br>-separated lines to render correctly in the Freshdesk UI. Sending raw plain text with newlines renders as a single line in the ticket timeline.

Related guides