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:
- Username: your Freshdesk API key (found in Profile Settings → Your API Key)
- Password: the single uppercase letter
X
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.
| Plan | Requests/min | Requests/hour |
|---|---|---|
| Sprout (Free) | 40 | 2,400 |
| Blossom / Garden | 50 | 3,000 |
| Estate | 100 | 6,000 |
| Forest | 400 | 24,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, notx(lowercase), notnull, and not the API key repeated. Thebase64("{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 of2for 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-Remainingand back off proactively before hitting zero. - Searching by email via /tickets instead of /contacts
GET /tickets?email=user@co.comfilters tickets by the requester's email.GET /contacts?email=user@co.comreturns 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 byrequester_id.- Assuming note body accepts plain text only
- Freshdesk's ticket
descriptionand notebodyfields 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
- 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 Zendesk — API token auth, cursor pagination, ticket lifecycle, and rate limits
- MCP tools for Intercom — Access Token auth, Conversation model, Contact search, and webhook validation
- MCP tools for PagerDuty — Token auth, incident lifecycle, on-call schedules, and escalation
- MCP server health check patterns — composite endpoints and failure classification