Posted by

Handling HubSpot API Rate Limits in Node.js: Retries, Backoff and Batching

Sooner or later every HubSpot integration hits the same wall: a sync job that worked perfectly in testing starts returning 429 Too Many Requests in production. The fix is rarely "make fewer calls" alone. It is a combination of understanding the limits, reading the signals HubSpot gives you, and retrying politely. This guide walks through all three in Node.js.

1. Know the limits

HubSpot enforces two kinds of limits: a burst limit (requests per 10 seconds) and a daily limit. According to HubSpot's API usage documentation, the burst limits for private apps are:

  • Free and Starter accounts: 100 requests per 10 seconds per private app
  • Professional and Enterprise accounts: 190 requests per 10 seconds per private app
  • With the API Limit Increase add-on: 250 requests per 10 seconds per private app

For public apps using OAuth, each HubSpot account that installs the app is limited to 110 requests per 10 seconds (the Search API is excluded from this figure). The CRM Search API has its own, much tighter limit: five requests per second per authentication token, with up to 200 records per page. Daily limits depend on the account tier. HubSpot adjusts these numbers over time, so always confirm them on the official page linked in the sources below before you size a job.

2. Read the rate-limit headers

HubSpot reports your current position in the response headers, so you do not need to guess:

  • X-HubSpot-RateLimit-Max: the number of requests allowed in the window
  • X-HubSpot-RateLimit-Remaining: the requests still permitted in the window
  • X-HubSpot-RateLimit-Interval-Milliseconds: the length of that window

The daily counters are not returned for OAuth requests. Note that the documentation reviewed for this article does not promise a Retry-After header on every 429 from the REST APIs, so treat it as optional: use it when present, and fall back to your own backoff when it is not.

3. A retrying fetch wrapper

The wrapper below retries on 429 and transient 5xx errors, honors Retry-After when it exists, and otherwise uses exponential backoff with full jitter. It requires Node.js 18 or newer, which includes fetch.

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function hubspotFetch(path, options = {}, maxRetries = 5) {
  const url = `https://api.hubapi.com${path}`;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    const res = await fetch(url, {
      ...options,
      headers: {
        Authorization: `Bearer ${process.env.HUBSPOT_TOKEN}`,
        'Content-Type': 'application/json',
        ...options.headers,
      },
    });

    const retryable = res.status === 429 || res.status >= 500;
    if (!retryable) return res;
    if (attempt === maxRetries) {
      throw new Error(`HubSpot request failed after ${maxRetries} retries (${res.status})`);
    }

    const retryAfter = Number(res.headers.get('retry-after'));
    const backoff = Math.min(30000, 500 * 2 ** attempt);
    const delay = retryAfter > 0
      ? retryAfter * 1000
      : Math.random() * backoff; // full jitter

    await sleep(delay);
  }
}

Jitter matters. If ten workers all fail at the same moment and all wait exactly one second, they will all collide again. Randomizing the delay spreads them out.

4. Slow down before you are told to

Retrying is a safety net, not a strategy. You can also watch the headers and pause proactively when the window is nearly empty:

async function politeFetch(path, options) {
  const res = await hubspotFetch(path, options);
  const remaining = Number(res.headers.get('x-hubspot-ratelimit-remaining'));
  const windowMs = Number(res.headers.get('x-hubspot-ratelimit-interval-milliseconds'));

  if (Number.isFinite(remaining) && remaining < 5 && windowMs) {
    await sleep(windowMs / 2);
  }
  return res;
}

5. Make fewer, bigger requests

HubSpot's own recommendations are to use batch requests, cache configuration data, and use webhooks instead of polling. Batch endpoints are the biggest win. For example, the CRM batch read endpoint fetches many contacts in a single call:

const res = await hubspotFetch('/crm/v3/objects/contacts/batch/read', {
  method: 'POST',
  body: JSON.stringify({
    properties: ['email', 'firstname'],
    inputs: ids.map((id) => ({ id })),
  }),
});
const { results } = await res.json();

Check the endpoint's reference page for its maximum batch size and chunk your input accordingly. One batch call replaces many single-record calls and counts as one request against the burst limit.

6. Remember that HubSpot retries too

If you build workflow webhook actions or custom code actions, HubSpot's documentation notes that workflows retry automatically after a 429 response and respect the Retry-After header when present. If your receiving endpoint is rate limited, returning a 429 with Retry-After is therefore a legitimate way to ask HubSpot to slow down.

Checklist

  • Know your tier's burst limit and the Search API limit of five requests per second.
  • Log the X-HubSpot-RateLimit-* headers while developing.
  • Retry only 429 and 5xx, with a cap and jitter.
  • Prefer batch endpoints, caching and webhooks over tight polling loops.
  • Limit concurrency in your job runner so parallel workers share one budget.

Sources