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-Versionheader, Intercom defaults to the oldest stable API version for your token. This version has different field names (e.g.,user_idvsexternal_id), missing fields, and deprecated endpoints. Always includeIntercom-Version: 2.11(or the latest stable version) in every request. - Using the deprecated /users endpoint
- Intercom merged users and leads into a single
/contactsmodel in API v2. The/usersendpoint was removed in v2.11. Code written against/userswill receive 404 errors. Migrate to/contactswithrole: "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
verifycallback 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
- 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 Freshdesk — API key as Basic auth, status/priority codes, and plan-based rate limits
- MCP tools for Slack — Bot token auth, Block Kit, cursor pagination, and rate limit tiers
- MCP server health check patterns — composite endpoints and failure classification