Guide · CRM & Customer Support

MCP Tools for HubSpot — Private App tokens, CRM Objects API v3, batch upsert, and search

HubSpot provides two auth models for server integrations and they have critically different scoping and expiry behavior: Private App tokens (pat-na1-xxx) are long-lived (no expiry), scoped to specific HubSpot permissions at creation time, and are the recommended path for server-to-server MCP tools; OAuth2 apps use 30-minute access tokens with refresh tokens and are required when the tool needs to act on behalf of multiple different HubSpot portals. The legacy hapikey query parameter still works but is deprecated — HubSpot logs a warning on each use and will eventually remove it. The CRM Objects API v3 is the modern surface (api.hubapi.com/crm/v3/objects/{objectType}); older v1/v2 endpoints have different URL structures and response shapes that cannot be mixed with v3 patterns.

TL;DR

Private App auth: Authorization: Bearer pat-na1-xxx header on every request. Base URL: https://api.hubapi.com. CRM reads: GET /crm/v3/objects/{objectType}?properties=email,firstname,lastname&limit=100. Search: POST /crm/v3/objects/{objectType}/search with {"filterGroups":[{"filters":[{"propertyName":"email","operator":"EQ","value":"user@co.com"}]}]}. Create: POST /crm/v3/objects/{objectType} with {"properties":{...}}. Batch upsert: POST /crm/v3/objects/{objectType}/batch/upsert with idProperty for external ID matching. Rate limit: 100 req/10s for Private Apps. Health probe: GET /crm/v3/objects/contacts?limit=1.

Authentication: Private Apps vs OAuth2

Choose based on deployment model:

// HubSpot client for Private App authentication
class HubSpotClient {
  constructor(privateAppToken) {
    this.token = privateAppToken;
    this.baseUrl = 'https://api.hubapi.com';
  }

  async request(path, method = 'GET', body = null) {
    const res = await fetch(`${this.baseUrl}${path}`, {
      method,
      headers: {
        'Authorization': `Bearer ${this.token}`,
        'Content-Type': 'application/json',
      },
      body: body ? JSON.stringify(body) : null,
      signal: AbortSignal.timeout(30_000),
    });

    if (!res.ok) {
      const error = await res.json().catch(() => ({ message: 'unknown' }));
      // HubSpot error format: { status: "error", message: "...", correlationId: "..." }
      throw new Error(`HubSpot ${method} ${path} → ${res.status}: ${error.message}`);
    }
    if (res.status === 204) return null;
    return res.json();
  }
}

// OAuth2 client for multi-portal apps
class HubSpotOAuthClient {
  #accessToken = null;
  #refreshToken = null;
  #expiresAt = 0;

  constructor(clientId, clientSecret, refreshToken) {
    this.clientId = clientId;
    this.clientSecret = clientSecret;
    this.#refreshToken = refreshToken;
    this.baseUrl = 'https://api.hubapi.com';
  }

  async #refresh() {
    const res = await fetch('https://api.hubapi.com/oauth/v1/token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'refresh_token',
        client_id: this.clientId,
        client_secret: this.clientSecret,
        refresh_token: this.#refreshToken,
      }),
    });
    if (!res.ok) throw new Error(`HubSpot token refresh failed: ${res.status}`);
    const data = await res.json();
    this.#accessToken = data.access_token;
    this.#expiresAt = Date.now() + data.expires_in * 1000 - 60_000; // 1min buffer
    if (data.refresh_token) this.#refreshToken = data.refresh_token;
  }

  async request(path, method = 'GET', body = null) {
    if (!this.#accessToken || Date.now() > this.#expiresAt) await this.#refresh();
    const res = await fetch(`${this.baseUrl}${path}`, {
      method,
      headers: {
        'Authorization': `Bearer ${this.#accessToken}`,
        'Content-Type': 'application/json',
      },
      body: body ? JSON.stringify(body) : null,
      signal: AbortSignal.timeout(30_000),
    });
    if (res.status === 401) {
      this.#accessToken = null;
      throw new Error('HubSpot access token invalid — retry after refresh');
    }
    if (!res.ok) {
      const error = await res.json().catch(() => ({}));
      throw new Error(`HubSpot ${method} ${path} → ${res.status}: ${error.message}`);
    }
    return res.status === 204 ? null : res.json();
  }
}

CRM Objects API v3 — contacts, companies, deals, tickets

The CRM Objects API v3 provides a uniform interface for all standard objects (contacts, companies, deals, tickets, products, quotes) and custom objects. Properties must be explicitly requested — the API returns only id, createdAt, updatedAt, and archived by default. Use the properties query parameter to specify which fields to return.

// Search contacts by email
async function findContact(client, email) {
  const result = await client.request('/crm/v3/objects/contacts/search', 'POST', {
    filterGroups: [{
      filters: [{
        propertyName: 'email',
        operator: 'EQ',
        value: email,
      }]
    }],
    properties: ['email', 'firstname', 'lastname', 'phone', 'company', 'lifecyclestage'],
    limit: 1,
  });
  return result.results[0] ?? null;
}

// Create or update a contact using email as the unique key
async function upsertContact(client, email, properties) {
  // HubSpot deduplicates contacts by email automatically on create
  // If you need true upsert with guaranteed idempotency, use batch/upsert
  const existing = await findContact(client, email);
  if (existing) {
    await client.request(`/crm/v3/objects/contacts/${existing.id}`, 'PATCH', { properties });
    return { id: existing.id, created: false };
  }
  const result = await client.request('/crm/v3/objects/contacts', 'POST', {
    properties: { email, ...properties },
  });
  return { id: result.id, created: true };
}

// Batch upsert contacts by external ID (idempotent, up to 100 per call)
async function batchUpsertContacts(client, contacts, externalIdProperty = 'hs_object_id') {
  // idProperty: the property name whose value uniquely identifies each record
  // For external IDs from your system, create a custom property in HubSpot
  // and specify it here (e.g., 'your_app_user_id')
  const result = await client.request('/crm/v3/objects/contacts/batch/upsert', 'POST', {
    inputs: contacts.map(c => ({
      idProperty: externalIdProperty,
      id: c[externalIdProperty],     // the value to match on
      properties: c,
    })),
  });
  // result.status: "COMPLETE" | "PENDING" | "PROCESSING" | "CANCELED"
  // result.results: array of { id, properties, createdAt, updatedAt }
  return result;
}

// Create an association between two CRM objects (e.g., contact → company)
async function associateObjects(client, fromType, fromId, toType, toId, associationType) {
  // Default association types: 1=contact_to_company, 2=company_to_contact, etc.
  // Use GET /crm/v4/associations/{fromType}/{toType}/labels to list valid types
  await client.request(
    `/crm/v4/objects/${fromType}/${fromId}/associations/${toType}/${toId}`,
    'PUT',
    [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: associationType }]
  );
}

// Get deal pipeline stages (needed to create deals with valid stage IDs)
async function getPipelineStages(client, objectType = 'deals', pipelineId = 'default') {
  const result = await client.request(
    `/crm/v3/pipelines/${objectType}/${pipelineId}/stages`
  );
  return result.results.map(s => ({
    id: s.id,
    label: s.label,
    displayOrder: s.displayOrder,
    metadata: s.metadata, // includes probability for deal stages
  }));
}

Rate limits and pagination

HubSpot enforces 100 requests per 10 seconds for Private Apps and OAuth2 apps on most endpoints. The response includes X-HubSpot-RateLimit-Remaining and X-HubSpot-RateLimit-Reset headers. When the limit is exceeded, the API returns 429 Too Many Requests with a Retry-After header in seconds.

List endpoints use cursor-based pagination via the after parameter (not page number). The response includes paging.next.after when more results exist.

// Paginate through all records of an object type
async function* getAllRecords(client, objectType, properties = [], pageSize = 100) {
  let after = undefined;

  while (true) {
    const params = new URLSearchParams({
      limit: pageSize,
      properties: properties.join(','),
    });
    if (after) params.set('after', after);

    const page = await client.request(`/crm/v3/objects/${objectType}?${params}`);
    yield* page.results;

    if (!page.paging?.next?.after) break;
    after = page.paging.next.after;
  }
}

// Rate-limit-aware request wrapper
async function requestWithRetry(client, path, method = 'GET', body = null, maxRetries = 3) {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await client.request(path, method, body);
    } catch (err) {
      if (err.message.includes('429') && attempt < maxRetries) {
        // HubSpot 429 includes Retry-After header; default to 10s if missing
        await new Promise(r => setTimeout(r, 10_000 * (attempt + 1)));
        continue;
      }
      throw err;
    }
  }
}

Health monitoring

HubSpot does not expose a dedicated health endpoint. The lightest authenticated read is a minimal contact list with limit=1. A complete probe should verify token validity, CRM read access, and property schema accessibility (custom property reads fail distinctly from auth failures).

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

    // 1. Auth + CRM read access
    (async () => {
      const res = await client.request('/crm/v3/objects/contacts?limit=1&properties=email');
      return { kind: 'crm_read', contactsAccessible: true, total: res.total };
    })(),

    // 2. Token scope validation (Private Apps only)
    // GET /oauth/v1/access-tokens/{token} returns scopes for Private Apps
    (async () => {
      const token = client.token;
      if (!token?.startsWith('pat-')) return { kind: 'scope', skipped: true };
      const res = await fetch(`https://api.hubapi.com/oauth/v1/access-tokens/${token}`, {
        headers: { 'Authorization': `Bearer ${token}` },
        signal: AbortSignal.timeout(5_000),
      });
      if (!res.ok) throw new Error(`Token info → ${res.status}`);
      const data = await res.json();
      return {
        kind: 'scope',
        hubId: data.hub_id,
        userId: data.user_id,
        scopes: data.scopes,
      };
    })(),

    // 3. Properties schema — verifies CRM schema is accessible
    (async () => {
      const res = await client.request('/crm/v3/properties/contacts?limit=5');
      return { kind: 'schema', propertiesCount: res.results?.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 the deprecated hapikey parameter
The ?hapikey=xxx query parameter still works but HubSpot logs a deprecation warning on each use. Migrate to Private App Bearer tokens for all new integrations. The hapikey will be removed in a future API version without a specific removal date announced.
Mixing v1/v2 and v3 API URL structures
HubSpot's v1 contacts endpoint is /contacts/v1/contact/email/{email}/profile; the v3 equivalent is /crm/v3/objects/contacts/search. These have different response shapes, different property access patterns, and different rate limit accounting. Mixing them in one integration causes unpredictable property key differences (properties.email.value in v1 vs properties.email in v3).
Requesting all properties without specifying which ones
The CRM Objects API returns only default metadata unless you specify properties=.... Omitting the properties parameter does not return all properties — it returns none. Always specify the property names you need. Use GET /crm/v3/properties/{objectType} to discover available property names for an object type.
Batch upsert without idProperty configured in HubSpot
The idProperty field in batch upsert must reference a property configured as a unique identifier in HubSpot's property settings, or hs_object_id for internal IDs. Using an arbitrary property name that isn't configured as unique causes the upsert to fail with a validation error rather than deduplicating records.

Related guides