Guide · CRM & Customer Support

MCP Tools for Intercom — Access Token auth, Conversation model, Contact search, and webhook validation

Intercom's API has two auth mechanisms that differ in token lifetime and expiry behavior: Access Tokens (Workspace apps — no expiry, used in Authorization: Bearer) and OAuth2 tokens (public apps — 60-minute access token with refresh). Both require the Intercom-Version header to specify the API version (2.11 as of mid-2026) — omitting it defaults to an old version with different response shapes. Intercom's data model distinguishes three object types that beginners frequently conflate: Contacts (the single model for both leads and users, differentiated by role), Conversations (support threads with a Parts array for messages/notes/activities), and Companies (organizations linked to contacts). Conversations contain Parts — each reply, internal note, or activity is a Part with its own type field. Pagination uses starting_after cursor, not page numbers.

TL;DR

Auth header: Authorization: Bearer {access_token}. Required version header: Intercom-Version: 2.11. Base URL: https://api.intercom.io. Find contact: POST /contacts/search with {"query":{"field":"email","operator":"=","value":"user@co.com"}}. List conversations: GET /conversations?order=desc&sort=updated_at with starting_after={cursor} for pagination. Create conversation: POST /conversations with {"from":{"type":"user","id":"contact_id"},"body":"message"}. Rate limit: 1,000 req/min (83 req/s). Webhook validation: HMAC-SHA256 of raw request body using shared secret, compare to X-Hub-Signature header. Health probe: GET /me returns workspace details.

Authentication and API versioning

The Intercom-Version header is mandatory for stable behavior. Without it, Intercom uses the oldest stable API version for backward compatibility — which has different field names, missing fields, and deprecated response shapes. Always pin to the latest stable version and update it during planned maintenance windows.

// Intercom client — handles versioning and auth
class IntercomClient {
  constructor(accessToken, apiVersion = '2.11') {
    this.baseUrl = 'https://api.intercom.io';
    this.headers = {
      'Authorization': `Bearer ${accessToken}`,
      'Content-Type': 'application/json',
      'Accept': 'application/json',
      // CRITICAL: always pin API version — omitting defaults to an old version
      'Intercom-Version': apiVersion,
    };
  }

  async request(path, method = 'GET', body = null) {
    const url = `${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 = res.headers.get('X-RateLimit-Reset');
      throw new Error(`Intercom rate limited — resets at ${retryAfter}`);
    }
    if (!res.ok) {
      const error = await res.json().catch(() => ({}));
      // Intercom error format: { type: "error.list", errors: [{ code, message }] }
      const msg = error.errors?.[0]?.message ?? error.message ?? 'unknown';
      throw new Error(`Intercom ${method} ${path} → ${res.status}: ${msg}`);
    }
    if (res.status === 204) return null;
    return res.json();
  }
}

// OAuth2 client for public apps (60-minute access tokens)
class IntercomOAuthClient extends IntercomClient {
  #refreshToken;
  #expiresAt = 0;
  #clientId;
  #clientSecret;

  constructor(clientId, clientSecret, initialRefreshToken) {
    super(null); // token set after first refresh
    this.#clientId = clientId;
    this.#clientSecret = clientSecret;
    this.#refreshToken = initialRefreshToken;
  }

  async #refresh() {
    const res = await fetch('https://api.intercom.io/auth/eagle/token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        client_id: this.#clientId,
        client_secret: this.#clientSecret,
        grant_type: 'refresh_token',
        refresh_token: this.#refreshToken,
      }),
    });
    if (!res.ok) throw new Error(`Intercom token refresh failed: ${res.status}`);
    const data = await res.json();
    this.headers['Authorization'] = `Bearer ${data.access_token}`;
    this.#expiresAt = Date.now() + data.expires_in * 1000 - 60_000;
    if (data.refresh_token) this.#refreshToken = data.refresh_token;
  }

  async request(path, method = 'GET', body = null) {
    if (Date.now() > this.#expiresAt) await this.#refresh();
    return super.request(path, method, body);
  }
}

Contact model — users and leads unified

Intercom consolidated users and leads into a single Contact object with a role field: user (has an authenticated account) or lead (anonymous visitor). The deprecated /users endpoint has been removed in API v2.11 — all contact operations go through /contacts. The external_id field links Intercom contacts to your system's user IDs; the email field is Intercom's primary deduplication key.

// Search contacts with filter operators
async function searchContacts(client, query) {
  // Single field query
  const result = await client.request('/contacts/search', 'POST', {
    query: {
      field: 'email',
      operator: '=',       // = | != | IN | NIN | < | > | <= | >= | ~  (contains) | !~ (not contains)
      value: query.email,
    },
    pagination: { per_page: 50 },
    sort: { field: 'updated_at', order: 'descending' },
  });
  return result.data;
}

// Multi-condition search with AND/OR operators
async function findContactByExternalId(client, externalId) {
  const result = await client.request('/contacts/search', 'POST', {
    query: {
      operator: 'AND',
      value: [
        { field: 'external_id', operator: '=', value: externalId },
        { field: 'role', operator: '=', value: 'user' },
      ],
    },
  });
  return result.data[0] ?? null;
}

// Create or update a contact (upsert by external_id)
async function upsertContact(client, externalId, email, name, customAttributes = {}) {
  const existing = await findContactByExternalId(client, externalId);

  const payload = {
    role: 'user',
    external_id: externalId,
    email,
    name,
    custom_attributes: customAttributes,
  };

  if (existing) {
    const result = await client.request(`/contacts/${existing.id}`, 'PUT', payload);
    return { id: result.id, created: false };
  }
  const result = await client.request('/contacts', 'POST', payload);
  return { id: result.id, created: true };
}

// Attach contact to a company
async function attachContactToCompany(client, contactId, companyId) {
  await client.request(`/contacts/${contactId}/companies`, 'POST', {
    id: companyId,
  });
}

Conversations and Parts model

A Conversation is a support thread. Its content is split into the initial source (the first message) and subsequent conversation_parts (an array of Parts, each with a part_type). Part types: comment (visible message), note (internal note, not shown to the user), assignment (reassignment activity), open/close (state changes), and channel_convert (platform switches). The conversation_parts.total_count field is the total Parts count; only the first 500 are returned in the initial response — paginate further Parts if needed.

// Create an outbound conversation (proactive message to a contact)
async function createConversation(client, contactId, message, adminId) {
  const result = await client.request('/conversations', 'POST', {
    from: { type: 'admin', id: adminId },
    to: { type: 'user', id: contactId },
    message_type: 'conversation',  // 'email' for email outreach
    body: message,
  });
  return result;
}

// Reply to a conversation
async function replyToConversation(client, conversationId, body, adminId, isNote = false) {
  const result = await client.request(
    `/conversations/${conversationId}/reply`,
    'POST',
    {
      type: isNote ? 'admin' : 'admin',
      message_type: isNote ? 'note' : 'comment',  // 'note' = internal only
      admin_id: adminId,
      body,
    }
  );
  return result;
}

// Get conversation with all Parts (paginate if conversation_parts.total_count > 500)
async function getConversationFull(client, conversationId) {
  const convo = await client.request(`/conversations/${conversationId}`);

  const parts = [...convo.conversation_parts.conversation_parts];

  // Paginate remaining parts if more than 500 exist
  if (convo.conversation_parts.total_count > 500) {
    let cursor = parts[parts.length - 1]?.id;
    while (cursor) {
      const page = await client.request(
        `/conversations/${conversationId}?starting_after=${cursor}`
      );
      const newParts = page.conversation_parts.conversation_parts;
      if (!newParts.length) break;
      parts.push(...newParts);
      cursor = newParts[newParts.length - 1]?.id;
    }
  }

  return {
    id: convo.id,
    state: convo.state,          // open | closed | snoozed
    source: convo.source,        // the first message
    parts,                        // all subsequent messages/notes/activities
    totalParts: convo.conversation_parts.total_count,
  };
}

// List conversations with cursor pagination
async function* getAllConversations(client, filter = {}) {
  let cursor = undefined;

  while (true) {
    const params = new URLSearchParams({
      order: 'desc',
      sort: 'updated_at',
      per_page: '60',
    });
    if (cursor) params.set('starting_after', cursor);
    if (filter.state) params.set('open', filter.state === 'open' ? 'true' : 'false');

    const page = await client.request(`/conversations?${params}`);
    yield* page.data;

    if (!page.pages?.next) break;
    // Extract cursor from next URL
    cursor = new URL(page.pages.next).searchParams.get('starting_after');
  }
}

Webhook validation

Intercom webhook payloads must be validated using HMAC-SHA256 to prevent forgery. The signature is the hex digest of HMAC-SHA256 applied to the raw request body (as bytes, before JSON parsing) using the webhook's Client Secret. The computed digest appears in the X-Hub-Signature header as sha256={hex_digest}.

import { createHmac, timingSafeEqual } from 'crypto';

// Validate an Intercom webhook — must use raw body bytes, not parsed JSON
function validateIntercomWebhook(rawBody, signatureHeader, clientSecret) {
  if (!signatureHeader?.startsWith('sha256=')) return false;
  const receivedSig = signatureHeader.slice(7); // strip "sha256=" prefix
  const expectedSig = createHmac('sha256', clientSecret)
    .update(rawBody)  // rawBody must be Buffer or string, NOT JSON.parse()'d
    .digest('hex');

  // Timing-safe comparison prevents timing attacks
  return timingSafeEqual(
    Buffer.from(receivedSig, 'hex'),
    Buffer.from(expectedSig, 'hex')
  );
}

// Express/Fastify middleware for webhook validation
function intercomWebhookMiddleware(clientSecret) {
  return (req, res, next) => {
    // Raw body must be captured BEFORE JSON parsing middleware
    const rawBody = req.rawBody;  // configured at express level with verify callback
    const signature = req.headers['x-hub-signature'];

    if (!validateIntercomWebhook(rawBody, signature, clientSecret)) {
      return res.status(401).json({ error: 'Invalid webhook signature' });
    }
    next();
  };
}

Health monitoring

The GET /me endpoint returns the authenticated workspace's details and verifies token validity. A complete health probe should also verify that contact read and conversation read access are functional, as token scopes can be misconfigured to exclude specific resource types.

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

    // 1. Auth and workspace identity
    (async () => {
      const res = await client.request('/me');
      return {
        kind: 'auth',
        workspaceId: res.app?.id_code,
        name: res.app?.name,
        type: res.type,  // 'admin' | 'bot' | 'nobody_admin'
      };
    })(),

    // 2. Contact read access
    (async () => {
      const res = await client.request('/contacts?per_page=1');
      return { kind: 'contacts', totalCount: res.total_count };
    })(),

    // 3. Conversation read access
    (async () => {
      const res = await client.request('/conversations?per_page=1&sort=updated_at&order=desc');
      return { kind: 'conversations', totalCount: res.total_count };
    })(),
  ]);

  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

Omitting the Intercom-Version header
Without the Intercom-Version header, Intercom defaults to the oldest stable API version for your token. This version has different field names (e.g., user_id vs external_id), missing fields, and deprecated endpoints. Always include Intercom-Version: 2.11 (or the latest stable version) in every request.
Using the deprecated /users endpoint
Intercom merged users and leads into a single /contacts model in API v2. The /users endpoint was removed in v2.11. Code written against /users will receive 404 errors. Migrate to /contacts with role: "user" filtering.
Validating webhooks against parsed JSON instead of raw bytes
The HMAC-SHA256 signature is computed over the raw request body bytes, not the JSON-serialized version of the parsed object. JSON serializers may reorder keys, change number formatting, or add/remove whitespace, which changes the digest. Always capture the raw body with a parser middleware verify callback before parsing, and pass that raw buffer to the HMAC computation.
Creating a Contact instead of a Conversation for inbound messages
When a user sends an inbound message through your UI, you POST a Conversation (not a Contact update). Contacts represent people; Conversations represent support threads. A common mistake is updating the Contact's note field with the message instead of creating a Conversation — this means the message doesn't appear in Intercom's inbox for agents to respond to.

Related guides