CRM & Customer Support · 2026-07-16 · CRM arc

MCP Tools for CRM and Customer Support: Five Auth Credential Shapes, Instance URL Routing, Shared Org Quotas, and Ticket State Machines

Five CRM and helpdesk platforms — Salesforce, HubSpot, Zendesk, Intercom, and Freshdesk — and each has a credential encoding scheme that looks trivially obvious until the moment it causes a 401 that an API-key-as-Bearer-token approach would never produce. Salesforce has no API key concept at all — it requires a Connected App registered in Salesforce Setup with OAuth2 client_credentials flow, and the token response includes not just access_token but instance_url, which routes your org to its specific pod and must be stored alongside the token. HubSpot offers two auth models with dramatically different scoping behaviors: Private App tokens (pat-na1-xxx) have no expiry but are scoped at creation time, while OAuth2 tokens expire every 30 minutes; the legacy hapikey query parameter still works but emits deprecation warnings per call. Zendesk uses HTTP Basic Auth but encodes credentials as {email}/token:{api_token} — the /token: separator between email and key is mandatory and not obvious from the Basic Auth spec. Intercom uses standard Bearer tokens but requires a Intercom-Version: 2.11 header on every request — omitting it silently downgrades to an older API version with different response schemas. Freshdesk has the most surprising pattern: the API key is the Basic Auth username, and the password must be the literal uppercase letter X — not empty, not repeated, exactly X. Beyond credential encoding, all five platforms share three structural patterns that determine whether your MCP tool's health probes are accurate: endpoint URLs that route per-organization (not globally), rate limits that are shared across every concurrent integration in the same org (not per-API-key), and ticket/conversation state machines with irreversible transitions that cause 422 errors when you try to update objects in their terminal state. This post covers all four patterns with probe designs that catch what per-platform health endpoints miss.

TL;DR

Five platforms, four patterns. (1) Auth credential encoding: Salesforce — OAuth2 client_credentials via Connected App, token response gives access_token + instance_url, all API calls go to the org-specific instance_url; HubSpot — Private App token as Authorization: Bearer pat-na1-xxx, no expiry, scoped at creation; Zendesk — Basic Auth as base64("{email}/token:{api_token}"), the /token: separator is mandatory; IntercomAuthorization: Bearer {token} plus mandatory Intercom-Version: 2.11 header; Freshdesk — Basic Auth with API key as username and literal X as password. (2) Endpoint routing: Salesforce instance_url from token response routes per-org and can change on pod migration; Zendesk and Freshdesk use {subdomain}.platform.com; HubSpot and Intercom use fixed global base URLs. (3) Shared org quotas: Salesforce governor limits are per-org across ALL integrations simultaneously — a migration job can exhaust the quota for your MCP tool; Zendesk and Freshdesk rate limits are per-plan account-wide; HubSpot and Intercom are per-app. (4) State machine traps: Zendesk closed tickets cannot be updated (422 TicketFinalStateError); Freshdesk status and priority are integers not strings; Intercom uses an additive Conversation Parts model rather than a status state machine; HubSpot pipeline stages are configurable per pipeline; Salesforce Case status is configurable string values. Monitor all five layers with AliveMCP.

Pattern 1: Auth Credential Encoding — Five Shapes That All Look Like "Just Pass a Token"

Every CRM and helpdesk platform claims to have simple authentication. Every one of them has at least one encoding rule that differs from what "passing an API key as a Bearer token" implies — and the failure mode when you get it wrong is a generic 401 that does not describe which encoding rule was violated. The first task in any MCP CRM integration is mapping the credential encoding to the right HTTP header shape, not just acquiring a key.

Salesforce is the platform with no API key concept at all. Every server-to-server integration must register a Connected App — a configuration object in Salesforce Setup — which provides a client_id and client_secret for the OAuth2 client credentials flow. The token request goes to https://login.salesforce.com/services/oauth2/token (or test.salesforce.com for sandbox orgs) as a URL-encoded POST body with grant_type=client_credentials&client_id=xxx&client_secret=xxx. The response includes access_token for use in the Authorization: Bearer header on all API calls — but critically also includes instance_url, the org-specific base URL that routes to that org's dedicated pod. Every subsequent API call must use the instance_url as the base, not login.salesforce.com. The issued_at timestamp in the response is epoch milliseconds; tokens are typically valid for two hours, after which Salesforce returns 401 INVALID_SESSION_ID.

// Salesforce OAuth2 token request and instance_url extraction
async function getSalesforceToken(clientId, clientSecret, loginUrl = 'https://login.salesforce.com') {
  const res = await fetch(`${loginUrl}/services/oauth2/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'client_credentials',
      client_id: clientId,
      client_secret: clientSecret,
    }),
    signal: AbortSignal.timeout(15_000),
  });
  if (!res.ok) {
    const err = await res.text().catch(() => '');
    throw new Error(`Salesforce token ${res.status}: ${err.slice(0, 200)}`);
  }
  const data = await res.json();
  // instance_url is org-specific — e.g., "https://acme.my.salesforce.com"
  // All REST API calls: ${data.instance_url}/services/data/v{version}/...
  return { token: data.access_token, instanceUrl: data.instance_url };
}

HubSpot offers two server auth models. Private App tokens (pat-na1-xxx for North America or pat-eu1-xxx for the EU data center) are the recommended path for MCP tools accessing a single portal's data — they never expire and are scoped to specific permissions chosen at token creation time. If you need a scope your existing token doesn't have, you must regenerate it; there is no way to add scopes to an existing Private App token. The deprecated ?hapikey={key} query parameter still works for legacy integrations but logs a deprecation warning in HubSpot's audit log on every request. OAuth2 access tokens (for multi-portal tools) expire every 30 minutes. The credential encoding itself is the least surprising of the five platforms: Authorization: Bearer {private_app_token} on every request.

One HubSpot-specific encoding pitfall is the default property set on GET requests. GET /crm/v3/objects/contacts without query parameters returns only id, createdAt, updatedAt, and archived — not email, first name, last name, or any other contact property. Properties must be explicitly requested: ?properties=email,firstname,lastname,phone. This is not an auth error and not a permissions error — it returns HTTP 200 with incomplete data. An MCP tool that does not include explicit property requests will silently return empty contact profiles that look correct.

// HubSpot: explicit properties required — default returns only id/createdAt
async function getHubSpotContacts(token, properties = ['email', 'firstname', 'lastname']) {
  const params = new URLSearchParams({
    limit: '100',
    properties: properties.join(','),
  });
  const res = await fetch(`https://api.hubapi.com/crm/v3/objects/contacts?${params}`, {
    headers: { 'Authorization': `Bearer ${token}` },
    signal: AbortSignal.timeout(15_000),
  });
  if (!res.ok) throw new Error(`HubSpot ${res.status}`);
  return res.json(); // { results: [...], paging: { next: { after: "cursor" } } }
}

Zendesk uses HTTP Basic Auth but with a non-standard encoding that catches every new integrator. The username field must be {email}/token:{api_token} — the email address, a forward slash, the literal string "token:", then the API token. This is encoded as Base64 like any Basic Auth credential, but the format of the credential string itself is unusual. If you encode just the API token as the username (as you would with many other APIs), Zendesk returns 401 with no indication that the separator is wrong. The endpoint is also subdomain-specific: https://{subdomain}.zendesk.com/api/v2, not a global base URL.

// Zendesk Basic Auth: email/token:{api_key} encoding
function zendesk(subdomain, email, apiToken) {
  const credential = Buffer.from(`${email}/token:${apiToken}`).toString('base64');
  const baseUrl = `https://${subdomain}.zendesk.com/api/v2`;

  return async (path, method = 'GET', body = null) => {
    const res = await fetch(`${baseUrl}${path}`, {
      method,
      headers: {
        'Authorization': `Basic ${credential}`,
        'Content-Type': 'application/json',
      },
      body: body ? JSON.stringify(body) : null,
      signal: AbortSignal.timeout(15_000),
    });
    if (res.status === 429) {
      const retryAfter = parseInt(res.headers.get('Retry-After') || '60', 10);
      throw Object.assign(new Error(`Zendesk rate limit — retry after ${retryAfter}s`), { retryAfter });
    }
    if (!res.ok) throw new Error(`Zendesk ${method} ${path} → ${res.status}`);
    if (res.status === 204) return null;
    return res.json();
  };
}

Intercom uses standard Bearer token authentication — but with a mandatory version header that silently changes the API response schema. Intercom-Version: 2.11 must accompany every request. Without it, Intercom defaults to the oldest stable API version, which has different field names, missing fields, and deprecated response shapes. A response that looks valid will be missing data that the current API version exposes. The version header is not documented in the error response when omitted — the request succeeds with HTTP 200, but returns data from an outdated schema. This is not a credentials error and does not appear in any auth troubleshooting guide, making it easy to ship code that appears to work but silently returns outdated data formats.

// Intercom: mandatory Intercom-Version header on every request
async function intercomRequest(token, path, method = 'GET', body = null) {
  const res = await fetch(`https://api.intercom.io${path}`, {
    method,
    headers: {
      'Authorization': `Bearer ${token}`,
      'Intercom-Version': '2.11',  // required — omitting silently uses oldest schema
      'Content-Type': 'application/json',
      'Accept': 'application/json',
    },
    body: body ? JSON.stringify(body) : null,
    signal: AbortSignal.timeout(15_000),
  });
  if (!res.ok) throw new Error(`Intercom ${method} ${path} → ${res.status}`);
  if (res.status === 204) return null;
  return res.json();
}

Freshdesk has the most surprising credential encoding. The API key goes in the HTTP Basic Auth username field, and the password field must be the literal uppercase letter X — not an empty string, not the API key again, not any placeholder value: the exact single character X. This is documented in the Freshdesk API reference but the reasoning is opaque (it mirrors a cURL convention from older Freshdesk docs). Sending an empty password or any other value produces a 401 that is indistinguishable from a wrong API key error. The endpoint is subdomain-specific: https://{subdomain}.freshdesk.com/api/v2.

// Freshdesk: API key as username, literal "X" as password
function freshdeskClient(subdomain, apiKey) {
  // "X" is not a placeholder — it is the exact required password character
  const credential = Buffer.from(`${apiKey}:X`).toString('base64');
  const baseUrl = `https://${subdomain}.freshdesk.com/api/v2`;

  return async (path, method = 'GET', body = null) => {
    const res = await fetch(`${baseUrl}${path}`, {
      method,
      headers: {
        'Authorization': `Basic ${credential}`,
        'Content-Type': 'application/json',
      },
      body: body ? JSON.stringify(body) : null,
      signal: AbortSignal.timeout(15_000),
    });
    if (res.status === 429) {
      const retryAfter = parseInt(res.headers.get('Retry-After') || '60', 10);
      throw Object.assign(new Error(`Freshdesk rate limit — retry after ${retryAfter}s`), { retryAfter });
    }
    if (!res.ok) throw new Error(`Freshdesk ${method} ${path} → ${res.status}`);
    if (res.status === 204) return null;
    return res.json();
  };
}
Auth Credential Shapes: Five CRM and Helpdesk Platforms
Platform Credential Type Header / Encoding Expiry Primary Gotcha
Salesforce OAuth2 client_credentials (Connected App) Authorization: Bearer {access_token} ~2 hours; 401 INVALID_SESSION_ID instance_url from token response routes per-org; must be stored and used as API base
HubSpot Private App token or OAuth2 Bearer Authorization: Bearer pat-na1-xxx No expiry (Private App); 30 min (OAuth2) Scopes are fixed at token creation; missing scope = 403, not 401; properties must be explicit in requests
Zendesk API token as HTTP Basic Auth Authorization: Basic base64("{email}/token:{key}") No expiry /token: separator between email and key is mandatory; encoding just the key produces 401
Intercom Access Token (Workspace app) Authorization: Bearer {token} + Intercom-Version: 2.11 No expiry (Access Token); 60 min (OAuth2) Missing version header silently returns outdated schema — HTTP 200 with wrong field names
Freshdesk API key as HTTP Basic Auth username Authorization: Basic base64("{api_key}:X") No expiry Password must be literal uppercase X — any other value produces 401 identical to wrong key

Pattern 2: Endpoint Routing — Per-Org vs Global Base URLs

Three of five platforms route each customer organization to a distinct endpoint URL. This is not a minor implementation detail — it means you cannot hardcode the API base URL as a constant, the URL can change between deployments (or mid-deployment), and a health probe that checks connectivity to a generic platform URL does not verify connectivity to the customer's specific org.

Salesforce has the most extreme routing model. The instance_url field in the OAuth2 token response is the org-specific base URL: it identifies which Salesforce pod (regional data center cluster) hosts the org. A customer org might have https://acme.my.salesforce.com as its instance URL. Salesforce migrates orgs between pods during infrastructure maintenance events — when this happens, the instance_url changes. An integration that cached the instance URL from an old token and hardcoded it stops working after a pod migration, producing connection errors that look like network failures rather than configuration staleness. The correct pattern is to always read instance_url from the most recent token response and cache it with the same TTL as the token itself.

// Correct: store instance_url with the token, not separately
class SalesforceSession {
  #state = null;

  async ensure() {
    if (this.#state && Date.now() < this.#state.expiresAt) return this.#state;
    const { token, instanceUrl } = await getSalesforceToken(/* ... */);
    // Token valid ~2h; refresh 5 min early to avoid mid-request expiry
    this.#state = { token, instanceUrl, expiresAt: Date.now() + 115 * 60 * 1000 };
    return this.#state;
  }

  async request(path, method = 'GET', body = null) {
    const { token, instanceUrl } = await this.ensure();
    // Base URL changes per-org and can change after pod migration
    const url = `${instanceUrl}/services/data/v61.0${path}`;
    const res = await fetch(url, {
      method,
      headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
      body: body ? JSON.stringify(body) : null,
      signal: AbortSignal.timeout(30_000),
    });
    if (res.status === 401) {
      this.#state = null; // INVALID_SESSION_ID — force token refresh
      throw new Error('Salesforce session expired — retry request');
    }
    if (!res.ok) {
      const errs = await res.json().catch(() => [{ message: 'unknown' }]);
      throw new Error(`Salesforce ${res.status}: ${errs[0]?.message}`);
    }
    if (res.status === 204) return null;
    return res.json();
  }
}

Zendesk and Freshdesk both route at the subdomain level. https://{subdomain}.zendesk.com and https://{subdomain}.freshdesk.com are the org-specific endpoints — the subdomain is the customer's account identifier and is fixed for the lifetime of their account (it does not change unless the customer renames their Zendesk subdomain, which is rare). The subdomain is a required configuration parameter, not something that can be derived from any token response. A health probe that checks https://zendesk.com/ says nothing about whether the customer's https://acme.zendesk.com/api/v2/users/me.json endpoint is accessible.

HubSpot and Intercom use fixed global base URLs — https://api.hubapi.com and https://api.intercom.io respectively. The routing to the customer's specific data is handled server-side by the platform, identified by the token. From an MCP tool's perspective, the base URL is a constant and does not need to be stored per-customer. This is the simpler routing model and eliminates the "URL changed" failure class entirely.

Endpoint Routing: Per-Org vs Global
Platform Base URL Model How It Changes Health Probe Implication
Salesforce Per-org, from token response (instance_url) Can change on pod migration; always read from latest token Probe must use stored instance_url, not a fixed URL
Zendesk Per-subdomain ({subdomain}.zendesk.com) Fixed unless customer renames subdomain (rare) Probe must use customer subdomain; global zendesk.com is irrelevant
Freshdesk Per-subdomain ({subdomain}.freshdesk.com) Fixed per account Probe must use customer subdomain
HubSpot Global (api.hubapi.com) Never changes; routing handled server-side by token Fixed URL in probe; token validity proves portal access
Intercom Global (api.intercom.io) Never changes Fixed URL in probe; GET /me verifies workspace access

The routing model determines what a health probe actually verifies. For Salesforce, a probe that fetches a new token and confirms a 200 response from instance_url/services/data/ verifies three things: OAuth2 token issuance works, the org's pod is reachable, and the authenticated session is valid. For HubSpot, a probe that calls GET /crm/v3/objects/contacts?limit=1 verifies the token is valid and the HubSpot CRM API is reachable. For Zendesk, GET /users/me.json verifies the email/token encoding is correct, the subdomain exists, and the account is active. Each probe must be designed for its platform's routing model — there is no single cross-platform health check pattern that works identically for all five.

Pattern 3: Plan-Level and Org-Level Shared Quotas

Rate limits for CRM and helpdesk APIs are almost never per-API-key. They are per-organization, per-plan, or per-account — shared across every integration running simultaneously against that org. This has a specific and often unexpected failure mode: a data migration script, a bulk export job, or another team's integration can exhaust the quota that your MCP tool relies on, causing your tool's calls to fail during that window — not because your tool did anything wrong, but because another process consumed the shared budget.

Salesforce governor limits are the most extreme example. Salesforce enforces hard quotas on API usage at the org level — the default DailyApiRequests for most org editions is 15,000 per 24-hour period (higher for Enterprise and above). This quota is not per-integration, not per-user, not per-API-key: it is per org, counting calls from every Connected App, every workflow integration, every data loader job, and every MCP tool simultaneously. The response header X-Sfdc-LimitInfo: api-usage=1234/15000 on every API call shows the current org-wide consumption. The GET /services/data/v61.0/limits endpoint returns the full picture including remaining quota.

// Check Salesforce governor limits — run proactively, not just on failure
async function checkSalesforceQuota(session) {
  const limits = await session.request('/limits');
  const { Remaining, Max } = limits.DailyApiRequests;
  const pctUsed = ((Max - Remaining) / Max * 100).toFixed(1);

  // Alert: <20% remaining = warning; <5% remaining = critical
  const status =
    Remaining / Max < 0.05 ? 'critical' :
    Remaining / Max < 0.20 ? 'warning' : 'ok';

  return { status, remaining: Remaining, max: Max, pctUsed: parseFloat(pctUsed) };
}

// Parse per-call limit tracking from the response header
function parseSfLimitHeader(headers) {
  const h = headers.get('X-Sfdc-LimitInfo');
  if (!h) return null;
  const m = h.match(/api-usage=(\d+)\/(\d+)/);
  return m ? { used: +m[1], max: +m[2] } : null;
}

When Salesforce DailyApiRequests.Remaining reaches zero, every API call returns 403 REQUEST_LIMIT_EXCEEDED until midnight UTC (the typical reset time, though org configuration may vary). At that point, no amount of credential rotation, token refresh, or retry logic will restore functionality. The quota must be monitored proactively — by the time calls start failing, the budget is already exhausted. Set a warning alert at 20% remaining, not at 0%.

Zendesk rate limits are per-plan and account-wide. Starter plan: 10 req/min; Team: 200 req/min; Professional: 400 req/min; Enterprise: 700 req/min. These limits apply to the entire Zendesk account, shared across all API integrations. A bulk export of tickets by one integration, combined with normal MCP tool activity, can push the account over the limit for the plan tier. Zendesk signals rate limit exhaustion with HTTP 429 and a Retry-After header giving the seconds until the window resets. Cursor pagination (the links.next approach with page[size]=100) is faster and more efficient than offset pagination — use it for bulk reads to minimize the number of API calls consumed from the shared quota.

Freshdesk similarly enforces plan-based account-wide limits. Sprout: 40 req/min; Blossom/Garden: 50 req/min; Estate: 100 req/min; Forest: 400 req/min. The X-RateLimit-Limit and X-RateLimit-Remaining headers on every response show the current budget. A 429 response includes a Retry-After header. These limits compound with any other API clients hitting the same Freshdesk account.

HubSpot Private App tokens have per-app rate limits: 100 requests per 10 seconds (600 req/min) for the CRM Objects API. Unlike Salesforce and Zendesk, this is per-token, not account-wide — a bulk import running under a different Private App token does not consume your tool's quota. HubSpot enforces a separate daily burst limit and a per-second limit; hitting either returns 429. The X-HubSpot-RateLimit-Remaining response header tracks remaining budget in the current window.

Intercom limits are per-app at 1,000 requests per minute (roughly 16 req/s). Like HubSpot, this is per-access-token, not account-wide. A 429 response from Intercom includes a X-RateLimit-Reset header with the Unix timestamp when the window resets — wait until that time, then retry.

// Unified rate limit handler for CRM clients — respects Retry-After or X-RateLimit-Reset
async function withRateLimitRetry(fn, maxRetries = 3) {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (err) {
      if (attempt === maxRetries) throw err;

      const isRateLimit = err.message?.includes('429') || err.retryAfter != null;
      if (!isRateLimit) throw err; // don't retry non-rate-limit errors

      // Use provided retry delay or exponential backoff
      const retryAfterMs = (err.retryAfter ?? Math.pow(2, attempt) * 5) * 1000;
      await new Promise(r => setTimeout(r, retryAfterMs));
    }
  }
}

The most important practical consequence of shared org quotas is health probe design. A health probe that makes N API calls to verify N aspects of system health consumes N calls from the shared quota. For Salesforce, a composite probe making 3 calls per minute (token check, limits check, SOQL probe) consumes 4,320 calls per day — meaningful against a 15,000 daily limit. For Zendesk Starter plan (10 req/min), a probe that calls an endpoint once per minute uses 1,440 of those 10 available per-minute requests. Design health probes to minimize per-probe call count, use lightweight endpoints (e.g., GET /users/me.json rather than a search), and set probe intervals appropriate to the account's plan tier.

Pattern 4: Ticket and Conversation State Machines

CRM and helpdesk platforms model customer interactions as stateful objects — tickets, conversations, cases, deals — and each platform's state model has irreversible transitions and terminal states that produce 422 errors when an MCP tool tries to update an object that has left that state. Understanding the state model is not optional: it determines which update operations are possible, which transitions require creating new objects rather than updating existing ones, and which status values are integers vs strings.

Zendesk tickets have the strictest terminal state. The ticket status progression is: newopenpendingholdsolvedclosed. The closed state is immutable — you cannot update a closed ticket, you cannot reopen it, and you cannot add comments to it. Attempting to PUT /tickets/{id} on a closed ticket returns 422 Unprocessable Entity with the error message referencing TicketFinalStateError. The correct pattern for continuing a conversation on a closed ticket is to use Zendesk's follow-up ticket feature: POST /tickets/{id}/follow_ups creates a new ticket linked to the closed one. An MCP tool that tries to reopen closed tickets must be aware that the only valid action is creating a follow-up — not updating the status field.

// Zendesk: handle closed ticket state — create follow-up instead of updating
async function addNoteToTicket(client, ticketId, noteBody) {
  try {
    // Attempt to add a comment to the ticket
    return await client('/tickets/' + ticketId, 'PUT', {
      ticket: { comment: { body: noteBody, public: false } },
    });
  } catch (err) {
    if (err.message?.includes('422')) {
      // Ticket is closed — create a follow-up ticket instead
      return client('/tickets/' + ticketId + '/follow_ups', 'POST', {
        ticket: { comment: { body: noteBody, public: false } },
      });
    }
    throw err;
  }
}

// Zendesk cursor pagination — follow links.next until meta.has_more is false
async function getAllTickets(client, status = 'open') {
  let url = `/tickets?status=${status}&page[size]=100`;
  const tickets = [];
  while (url) {
    const page = await client(url);
    tickets.push(...page.tickets);
    url = page.meta.has_more ? new URL(page.links.next).pathname + new URL(page.links.next).search : null;
  }
  return tickets;
}

Freshdesk represents ticket status and priority as integers, not strings. Status: 2 = Open, 3 = Pending, 4 = Resolved, 5 = Closed. Priority: 1 = Low, 2 = Medium, 3 = High, 4 = Urgent. Sending the string "open" for status returns a validation error. An MCP tool generating tickets from natural language descriptions must map status labels to integers before the API call. Freshdesk does not have Zendesk's immutable-closed-state constraint — a closed ticket (status 5) can be reopened by updating the status back to 2 or 3. Notes on any ticket are created via POST /tickets/{id}/notes with {"body":"...","private":true} for internal notes and {"body":"...","private":false} for customer-visible replies.

// Freshdesk: status and priority are integers — map from labels to codes
const FRESHDESK_STATUS = { open: 2, pending: 3, resolved: 4, closed: 5 };
const FRESHDESK_PRIORITY = { low: 1, medium: 2, high: 3, urgent: 4 };

async function createFreshdeskTicket(client, params) {
  const body = {
    subject: params.subject,
    description: params.description,
    email: params.email,
    // Must be integers — string values produce 422 validation errors
    status: FRESHDESK_STATUS[params.status] ?? FRESHDESK_STATUS.open,
    priority: FRESHDESK_PRIORITY[params.priority] ?? FRESHDESK_PRIORITY.medium,
    tags: params.tags ?? [],
  };
  return client('/tickets', 'POST', body);
}

// Freshdesk offset pagination — detect last page by count < per_page
async function getAllFreshdeskTickets(client, status = 2) {
  const perPage = 100;
  let page = 1;
  const tickets = [];
  while (true) {
    const batch = await client(`/tickets?status=${status}&page=${page}&per_page=${perPage}`);
    tickets.push(...batch);
    if (batch.length < perPage) break; // last page
    page++;
  }
  return tickets;
}

Intercom does not use a simple status state machine. A Conversation is not updated between discrete states — it is a thread with an append-only array of Parts: each reply, note, assignment, state change, or activity is a Part with its own type. The conversation itself has an open boolean (open = true, resolved = false) and an assignee, but the lifecycle model is not a status machine. Closing a conversation (open: false) does not prevent new messages — it just marks it resolved. When a customer replies to a resolved conversation, Intercom automatically reopens it. An MCP tool interacting with Intercom conversations must model the Parts array rather than a status field. Parts have types including comment (customer-visible), note (internal agent-only), assignment, and open/close state-change events.

// Intercom: conversations are Part streams, not status machines
async function getConversationParts(client, conversationId) {
  const conv = await client(`/conversations/${conversationId}`);
  // conversation_parts is an array of Part objects, newest last
  return {
    id: conv.id,
    isOpen: conv.open,
    assignee: conv.assignee,
    parts: conv.conversation_parts.conversation_parts.map(p => ({
      type: p.part_type,        // 'comment' | 'note' | 'assignment' | 'open' | 'close'
      body: p.body,
      author: p.author,
      createdAt: p.created_at,
    })),
  };
}

// Add an internal note to an Intercom conversation
async function addIntercomNote(client, conversationId, body) {
  return client(`/conversations/${conversationId}/parts`, 'POST', {
    message_type: 'note',
    type: 'admin',
    admin_id: process.env.INTERCOM_ADMIN_ID,
    body,
  });
}

// Intercom cursor pagination: starting_after for next page
async function listIntercomConversations(client, cursor = null) {
  const path = '/conversations?order=desc&sort=updated_at' +
    (cursor ? `&starting_after=${cursor}` : '');
  const res = await client(path);
  return {
    conversations: res.data,
    nextCursor: res.pages?.next?.starting_after ?? null,
  };
}

HubSpot has two object types relevant to CRM MCP tools: Contacts (people) and Deals (opportunities moving through a pipeline). Deals have a dealstage property whose valid values are configurable per pipeline — the stage names are determined by the customer's HubSpot portal configuration, not by HubSpot's API spec. An MCP tool that hardcodes deal stage names will fail when integrated with a portal that uses custom stage labels. The correct approach is to fetch the pipeline configuration at startup: GET /crm/v3/pipelines/deals returns all pipelines with their stages and IDs. Use stage IDs in API calls, not stage names. Deal stages do not have HubSpot-enforced ordering — any stage can follow any other stage (unlike Zendesk's enforced progression), but moving a deal back to a previous stage is semantically unusual and may trigger automations.

Salesforce Cases (the Salesforce equivalent of support tickets) have a Status field that is a configurable picklist — the valid values are defined in Salesforce Setup for the org, not by the Salesforce API spec. Common values are New, Working, Escalated, and Closed, but any org can rename, add, or remove status values. An MCP tool must query the valid picklist values at runtime: GET /services/data/v61.0/sobjects/Case/describe returns the field metadata including picklist values for Status. Hardcoding "Closed" as a status string will fail on orgs where the value has been renamed to "Resolved" or "Completed".

Ticket/Conversation State Models Compared
Platform State Model Terminal State Behavior Status Value Type Key Constraint
Zendesk Status machine: new → open → pending → hold → solved → closed closed is immutable; updates return 422 String enum Closed tickets need follow-up ticket, not status update
Freshdesk Status machine: Open → Pending → Resolved → Closed Closed can be reopened (set status back to 2) Integer (2/3/4/5) String status values produce validation errors
Intercom Append-only Parts array; open boolean No terminal state; customer reply auto-reopens Boolean (open: true/false) Model Parts types: comment / note / assignment / state-change
HubSpot Deal pipeline stages (configurable per pipeline) No platform-enforced terminal state Stage ID string (configurable) Stage names are org-specific; fetch from pipeline config at runtime
Salesforce Case Status picklist (configurable per org) No universal terminal state String (configurable) Valid values are org-defined; read from describe API at startup

Health Probe Designs for Each Platform

A health probe that only validates credentials is insufficient for any of these five platforms. Credentials can be valid while the org is rate-limited, the subdomain is unreachable, governor limits are exhausted, or the pipeline configuration has changed. Complete probes must verify each of these layers independently.

// Composite health probes for all five CRM/helpdesk platforms

async function probeSalesforce(session) {
  const checks = await Promise.allSettled([
    // 1. Token validity + instance_url reachability
    session.request('/limits').then(l => ({
      ok: true,
      dailyRemaining: l.DailyApiRequests.Remaining,
      dailyMax: l.DailyApiRequests.Max,
    })),
    // 2. SOQL path — verifies DB accessibility
    session.request('/query?q=SELECT+Id+FROM+Organization+LIMIT+1').then(r => ({
      ok: true,
      orgId: r.records[0]?.Id,
    })),
  ]);
  return {
    platform: 'salesforce',
    healthy: checks.every(c => c.status === 'fulfilled'),
    details: checks.map(c => c.status === 'fulfilled' ? c.value : { ok: false, error: c.reason?.message }),
  };
}

async function probeHubspot(token) {
  const res = await fetch('https://api.hubapi.com/crm/v3/objects/contacts?limit=1&properties=email', {
    headers: { 'Authorization': `Bearer ${token}` },
    signal: AbortSignal.timeout(10_000),
  });
  const remaining = res.headers.get('X-HubSpot-RateLimit-Remaining');
  return { platform: 'hubspot', healthy: res.ok, status: res.status, rateLimitRemaining: remaining ? +remaining : null };
}

async function probeZendesk(subdomain, email, apiToken) {
  const credential = Buffer.from(`${email}/token:${apiToken}`).toString('base64');
  const res = await fetch(`https://${subdomain}.zendesk.com/api/v2/users/me.json`, {
    headers: { 'Authorization': `Basic ${credential}` },
    signal: AbortSignal.timeout(10_000),
  });
  if (!res.ok) return { platform: 'zendesk', healthy: false, status: res.status };
  const data = await res.json();
  return { platform: 'zendesk', healthy: true, userId: data.user?.id, role: data.user?.role };
}

async function probeIntercom(token) {
  const res = await fetch('https://api.intercom.io/me', {
    headers: {
      'Authorization': `Bearer ${token}`,
      'Intercom-Version': '2.11',
    },
    signal: AbortSignal.timeout(10_000),
  });
  if (!res.ok) return { platform: 'intercom', healthy: false, status: res.status };
  const data = await res.json();
  return { platform: 'intercom', healthy: true, appId: data.app?.id_code, name: data.name };
}

async function probeFreshdesk(subdomain, apiKey) {
  const credential = Buffer.from(`${apiKey}:X`).toString('base64');
  const res = await fetch(`https://${subdomain}.freshdesk.com/api/v2/settings/helpdesk`, {
    headers: { 'Authorization': `Basic ${credential}` },
    signal: AbortSignal.timeout(10_000),
  });
  const remaining = res.headers.get('X-RateLimit-Remaining');
  return { platform: 'freshdesk', healthy: res.ok, status: res.status, rateLimitRemaining: remaining ? +remaining : null };
}

For Salesforce, register the DailyApiRequests limits check as a separate monitor from process liveness. Set a warning at 20% remaining (the daily reset is at midnight UTC for most orgs). For Zendesk and Freshdesk, probe at intervals that fit within the plan's rate limit budget — for Starter (10 req/min), a probe every 5 minutes leaves room for actual integration traffic. For HubSpot and Intercom (per-app rate limits), more frequent probes are viable but still add to the per-app quota.

The Intercom version header is worth verifying in the probe response body. The /me endpoint returns the workspace's Intercom-Version in use — if it differs from 2.11, your integration may be running against a different schema than tested. For HubSpot, the probe should verify that the properties you need are accessible: a token with insufficient scopes returns HTTP 200 on some endpoints but 403 on property-level access.

Cross-Platform Configuration Architecture

Building an MCP tool that integrates with multiple CRM or helpdesk platforms means managing five different credential shapes, five different endpoint base URLs (three of which are per-org), and five different rate limit models. The configuration layer matters.

// Multi-platform CRM config — structured per-platform, loaded from env
function loadCrmConfig() {
  const configs = {};

  if (process.env.SALESFORCE_CLIENT_ID) {
    configs.salesforce = {
      clientId: process.env.SALESFORCE_CLIENT_ID,
      clientSecret: process.env.SALESFORCE_CLIENT_SECRET,
      // 'https://test.salesforce.com' for sandbox orgs
      loginUrl: process.env.SALESFORCE_LOGIN_URL ?? 'https://login.salesforce.com',
      // Note: instance_url is NOT stored in config — read from token response at runtime
    };
  }

  if (process.env.HUBSPOT_TOKEN) {
    configs.hubspot = {
      token: process.env.HUBSPOT_TOKEN,
      // Region prefix indicates data center: pat-na1- (NA) or pat-eu1- (EU)
    };
  }

  if (process.env.ZENDESK_SUBDOMAIN) {
    configs.zendesk = {
      subdomain: process.env.ZENDESK_SUBDOMAIN,
      email: process.env.ZENDESK_EMAIL,
      apiToken: process.env.ZENDESK_API_TOKEN,
      // baseUrl derived: https://${subdomain}.zendesk.com/api/v2
    };
  }

  if (process.env.INTERCOM_TOKEN) {
    configs.intercom = {
      token: process.env.INTERCOM_TOKEN,
      version: '2.11', // always pin the version
    };
  }

  if (process.env.FRESHDESK_SUBDOMAIN) {
    configs.freshdesk = {
      subdomain: process.env.FRESHDESK_SUBDOMAIN,
      apiKey: process.env.FRESHDESK_API_KEY,
      // password is always 'X' — not stored in env (it's a constant)
    };
  }

  return configs;
}

Two rules follow from this configuration structure. First, Salesforce instance_url should never be a configuration variable — it belongs in the session state layer alongside the access token, refreshed on every token rotation. Putting it in config creates a stale-URL risk after Salesforce pod migrations. Second, the Intercom version header (2.11) should be a code constant, not an environment variable — it controls schema compatibility and should only change when you've tested against the new version. Letting it drift via environment configuration silently changes the response schema.

Monitoring CRM Integrations with AliveMCP

Each of the failure patterns in this post — credential encoding errors, stale instance URLs, exhausted org quotas, illegal state transitions — produces a different failure signal. Credential errors appear immediately as 401 or 403 responses. Stale instance URLs appear as connection timeouts or DNS errors. Exhausted governor limits appear as 403 REQUEST_LIMIT_EXCEEDED after the quota is gone. State machine violations appear as 422 on specific update operations. None of these are process crashes — your MCP server process can be running and healthy while any of these conditions exist.

AliveMCP is built for exactly this class of health check: probes that exercise the actual integration path, not just process liveness. Configure a separate monitor for each critical path — Salesforce token + limits check, HubSpot CRM read, Zendesk ticket list, Intercom conversation access, Freshdesk settings read — with alert routing to the right channel. The Salesforce governor limits monitor deserves a dedicated alert threshold at 20% remaining, separate from connectivity health, because the remediation action (quota increase request or stopping other integrations) is different from a connectivity failure.

For teams running multiple integrations in the same CRM org, the shared quota monitor is the most valuable single alert to add. One unexpected batch job can exhaust the Salesforce daily quota, the Zendesk plan limit, or the Freshdesk account limit — and without proactive monitoring, the first signal is user-facing failures from tools that look individually healthy. Catch it at 20% remaining, not at zero.

Related guides