[object Object]

A first integration with the HubSpot CRM API usually works on day one and breaks on day thirty. The shape of the API rewards taking auth, rate limits, and batching seriously upfront. Skipping past those because the docs make a single GET look easy is the most common cause of integrations that quietly drop data in production.

Auth: pick the right pattern

Private app  - one portal, static access token, fastest to build
Public app   - many portals, OAuth flow, install + token refresh
API key      - deprecated, do not build on it

Private apps fit internal tooling. Public apps fit anything you ship to customers. OAuth flow returns an access token (30-minute TTL) and a refresh token (long-lived) — store the refresh encrypted, never log it, rotate on the same cadence as your secrets.

// Private app call
const res = await fetch("https://api.hubapi.com/crm/v3/objects/contacts/123", {
  headers: { "Authorization": `Bearer ${process.env.HS_PRIVATE_TOKEN}` }
});

Core endpoints map to objects

The CRM API exposes contacts, companies, deals, tickets, line items, products, quotes, and any custom objects you have defined. Each supports the standard verbs:

GET    /crm/v3/objects/{type}/{id}
POST   /crm/v3/objects/{type}
PATCH  /crm/v3/objects/{type}/{id}
DELETE /crm/v3/objects/{type}/{id}
POST   /crm/v3/objects/{type}/search

The search endpoint is the only filtered query path. Use it for “all contacts modified since X” rather than paginating the full collection.

Rate limits and the 429

HubSpot enforces both burst and daily quotas. Burst is 100 requests per 10 seconds for most paid portals; daily is in the hundreds of thousands. Public apps have per-app limits that span all installs. The right response to 429 is exponential backoff with jitter:

async function callWithRetry(fn, attempt = 0) {
  try {
    return await fn();
  } catch (err) {
    if (err.status !== 429 || attempt >= 5) throw err;
    const delay = Math.min(1000 * 2 ** attempt, 30000) + Math.random() * 500;
    await new Promise(r => setTimeout(r, delay));
    return callWithRetry(fn, attempt + 1);
  }
}

Watch the X-HubSpot-RateLimit-Remaining header to back off proactively rather than waiting for the 429.

Batch endpoints save your quota

Single-object create/update calls burn rate limit unnecessarily. Every CRM object has batch endpoints accepting up to 100 inputs per call:

POST /crm/v3/objects/contacts/batch/create
POST /crm/v3/objects/contacts/batch/update
POST /crm/v3/objects/contacts/batch/archive
POST /crm/v3/objects/contacts/batch/read

A 1000-object sync goes from 1000 calls to 10 calls. Use batch for any bulk operation, even if it means waiting briefly to accumulate inputs.

Webhooks for push, polling for pull

Webhooks (public apps only) push events: contact.creation, contact.propertyChange, deal.creation, ticket.priorityChange. Subscribe by event type, configure one target URL per app, and verify signatures on every receipt:

import crypto from "crypto";
function verify(req, secret) {
  const sig = req.headers["x-hubspot-signature-v3"];
  const body = req.rawBody;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(req.method + req.url + body)
    .digest("base64");
  return sig === expected;
}

Make every receiver idempotent because retries happen.

Pagination, search, and limits

List endpoints page with after cursors, not numeric offsets. Search results cap at 10,000. Beyond that, narrow your filter or chunk by date. Include only the properties you need with properties=email,firstname to keep payloads small.

What to do this week

Inventory your API calls, replace any single-object loops with batch endpoints, add 429 backoff with jitter to your client, and document which webhook events your integration depends on so a HubSpot platform change does not surprise you.

[object Object]
Share