Back to blog
Unified APIGuides & TutorialsCRM

How to Connect with the HubSpot API

Learn how to connect to HubSpot API with OAuth 2.0, handle rate limits, manage lifecycle stages, and avoid common integration pitfalls. Complete implementation guide with TypeScript examples, webhook setup, and solutions to undocumented API quirks.

Saurabh RaiSaurabh Rai

Saurabh Rai

13 min read
How to Connect with the HubSpot API

HubSpot's API looks modern on the surface. REST endpoints, JSON payloads, OAuth 2.0, webhooks. Then you actually build something with it and discover the truth: it's a maze of rate limits, undocumented quirks, and lifecycle stage logic that defies human comprehension.

Screenshot 2025-09-12 at 23.42.05@2x

I've spent the last three years integrating HubSpot for various clients. Here's what the documentation won't tell you and what will save you from the same pain I went through.

The OAuth Dance Nobody Warns You About

HubSpot uses OAuth 2.0, which sounds standard until you realize their implementation has its own special flavor. You need three things before you even start: a developer account (separate from your regular HubSpot account), an app registration, and the patience of a saint.

First, create your app at developers.hubspot.com. You'll get a Client ID and Client Secret. Guard that secret like your life depends on it, because regenerating it will break every existing integration.

Here's the authorization URL you need to build:

const authUrl = `https://app.hubspot.com/oauth/authorize?` +
  `client_id=${CLIENT_ID}&` +
  `redirect_uri=${encodeURIComponent(REDIRECT_URI)}&` +
  `scope=crm.objects.contacts.read%20crm.objects.contacts.write`;

But wait, there's a catch nobody mentions: the redirect URI must match EXACTLY what you registered. Not just the domain, not just the path, but every single character, including trailing slashes. Get it wrong and you'll see a generic error that tells you nothing.

When the user approves, HubSpot redirects back with a code. You have exactly 30 seconds to exchange it for tokens, or it expires:

const tokenResponse = await fetch('https://api.hubapi.com/oauth/v1/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    grant_type: 'authorization_code',
    client_id: CLIENT_ID,
    client_secret: CLIENT_SECRET,
    redirect_uri: REDIRECT_URI,
    code: authCode
  })
});

const { access_token, refresh_token } = await tokenResponse.json();

Most access tokens are short-lived. You can check the expires_in parameter when generating an access token to determine its lifetime (in seconds). Practically you have a few minutes before they expire.

Screenshot 2025-09-12 at 23.42.53@2x

Here's the refresh logic you'll need running constantly:

async function refreshAccessToken(refreshToken) {
  const response = 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: CLIENT_ID,
      client_secret: CLIENT_SECRET,
      refresh_token: refreshToken
    })
  });

  if (!response.ok) {
    // Refresh token is dead, user needs to reauthorize
    throw new Error('Token refresh failed - user must reauthorize');
  }

  return await response.json();
}

You can read more about the refresh access token logic from the HubSpot documentation here.

Rate Limiting: The 429 Error Festival

HubSpot's rate limits are documented, but what they don't tell you is how aggressive they are. You get 100 requests per 10 seconds for public apps. That sounds like a lot until you realize that's shared across ALL your users if you're using a single app registration.

Screenshot 2025-09-13 at 22.07.28@2x

However, you can upgrade the number of calls your app can make, which is based on your account subscription in the account it's installed in. Here's the plan details from the HubSpot Documentation:

[IMAGE: Rate limit table by subscription tier]

Here's what a 429 error looks like when you hit the limit:

{
  "status": "error",
  "message": "You have reached your secondly limit",
  "errorType": "RATE_LIMIT",
  "correlationId": "c033cdaa-2c40-4a64-ae48-b4cec88dad24",
  "policyName": "TEN_SECONDLY_ROLLING"
}

The worst part? HubSpot counts requests that fail against your rate limit. So when you hit the limit and retry immediately, you're making it worse. You need exponential backoff or you'll be stuck in 429 hell forever:

async function makeHubSpotRequest(url, options, retryCount = 0) {
  const response = await fetch(url, options);

  if (response.status === 429) {
    if (retryCount >= 5) {
      throw new Error('Max retries exceeded');
    }

    // Exponential backoff: 1s, 2s, 4s, 8s, 16s
    const delay = Math.pow(2, retryCount) * 1000;
    console.log(`Rate limited. Waiting ${delay}ms before retry...`);

    await new Promise(resolve => setTimeout(resolve, delay));
    return makeHubSpotRequest(url, options, retryCount + 1);
  }

  if (!response.ok) {
    throw new Error(`HubSpot API error: ${response.status}`);
  }

  return response.json();
}

But here's the real kicker: if you're using Make.com, Zapier, or any integration platform, you're sharing rate limits with every other customer using their HubSpot connector. I've seen perfectly reasonable workflows fail at 2 AM because someone else's integration went haywire. The only solution is to create your own OAuth app and use their "advanced" connection option.

The Lifecycle Stage Nightmare

HubSpot's lifecycle stages are supposed to track where contacts are in your sales process. In reality, they're a one-way street designed by someone who's never had to fix bad data.

Lifecycle stages can only move forward by default. Lead to Customer? Fine. Customer back to Lead because they canceled? Nope. You have to clear the field first, then set the new value in a separate API call:

// This won't work - lifecycle stage can't go backwards
await updateContact(contactId, { lifecyclestage: 'lead' }); // Fails silently

// This is what you actually need
await updateContact(contactId, { lifecyclestage: '' }); // Clear it first
await updateContact(contactId, { lifecyclestage: 'lead' }); // Now set it

async function updateContact(contactId, properties) {
  return makeHubSpotRequest(
    `https://api.hubapi.com/crm/v3/objects/contacts/${contactId}`,
    {
      method: 'PATCH',
      headers: {
        'Authorization': `Bearer ${accessToken}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ properties })
    }
  );
}

That's two API calls for one field update, doubling your rate limit usage. And it gets worse: the batch API doesn't guarantee order, so you can't clear and set in the same batch request. Every backwards lifecycle stage movement costs you two separate API calls.

Oh, and those "Became a [stage] date" properties? They're being deprecated. HubSpot announced this in 2024, but half of its documentation still references them. You now need to use their "calculated properties," which have their own special quirks and can't be set via API at all.

Custom Properties: The False Promise

HubSpot lets you create custom properties for anything. Sounds great until you realize that enumeration properties (dropdowns, checkboxes) have their own internal IDs that aren't the same as the labels you see in the UI.

You think you're setting a property to "Enterprise Customer," but HubSpot wants "enterprise_customer_7821" or something equally ridiculous. To find the internal values, you need to query the properties endpoint first:

const propertyResponse = await makeHubSpotRequest(
  'https://api.hubapi.com/crm/v3/properties/contacts/industry',
  {
    headers: { 'Authorization': `Bearer ${accessToken}` }
  }
);

// Returns something like:
// {
//   "options": [
//     { "label": "Enterprise Customer", "value": "enterprise_customer_7821" },
//     { "label": "SMB Customer", "value": "smb_customer_9183" }
//   ]
// }

And don't even think about changing these values later. Every integration, workflow, and report using that property will break. I learned this when a client wanted to rename "Hot Lead" to "Qualified Lead" and it took three days to fix all the broken automations.

Webhooks: Death by a Thousand Subscriptions

HubSpot webhooks seem straightforward: subscribe to events, receive notifications. What they don't tell you is that webhooks are tied to your app, not individual accounts. Every customer using your app shares the same webhook URL.

Setting up webhooks requires a verified domain and HTTPS endpoint that can handle HubSpot's validation:

app.post('/webhook', (req, res) => {
  // HubSpot sends validation on setup
  if (req.headers['x-hubspot-signature']) {
    const signature = req.headers['x-hubspot-signature'];
    const sourceString = req.method + req.url + req.rawBody;
    const hash = crypto.createHash('sha256')
      .update(CLIENT_SECRET + sourceString)
      .digest('hex');

    if (hash !== signature) {
      return res.status(401).send('Invalid signature');
    }
  }

  // Process webhook events
  req.body.forEach(event => {
    console.log(`Event: ${event.eventType} for object ${event.objectId}`);
    // But which customer is this for? Good luck figuring that out
  });

  res.status(200).send();
});

The webhook payload doesn't include which account it's from. You get an object ID and have to make another API call (counting against your rate limit) to figure out whose data changed. With 100 customers, that's 100 extra API calls per webhook event.

The Undocumented Reality

Here's what HubSpot won't tell you but you need to know:

The API versions are complex. There's v1, v2, and v3 running simultaneously. Some endpoints only exist in v1 (looking at you, Engagements API), some features are v3 only, and they're deprecating v1 "soon" (they've been saying this for three years).

Private apps are not the same as OAuth apps. Private apps use API keys and are simpler but can't be distributed. OAuth apps can be shared but require the whole token dance. Choose wrong and you'll be rebuilding your integration from scratch.

The search API is basically useless. It has a different rate limit (4 requests per second), can't search all properties, and sometimes returns stale data. One client had contacts appearing in search results three hours after deletion. The only reliable way to find data is to pull everything and filter locally.

Error messages lie. You'll get "Contact already exists" when the real problem is a malformed email. You'll get "Invalid property value" when the property doesn't exist. Always log the full request and response because the error message alone won't help you debug.

Associations are their own special hell. Want to link a contact to a company? That's a separate API call. Want to see all contacts for a company? Another call. Want to update the association? You can't, you have to delete and recreate it. Each operation counts against your rate limit.

Making This Bearable with TypeScript

If you're building anything serious, use TypeScript. HubSpot's API responses are inconsistent and TypeScript will save you from runtime explosions:

interface HubSpotContact {
  id: string;
  properties: {
    email?: string;
    firstname?: string;
    lastname?: string;
    lifecyclestage?: string;
    [key: string]: string | undefined;
  };
  createdAt: string;
  updatedAt: string;
}

interface HubSpotError {
  status: 'error';
  message: string;
  correlationId: string;
  errorType?: 'RATE_LIMIT' | 'VALIDATION_ERROR' | 'NOT_FOUND';
}

class HubSpotClient {
  constructor(private accessToken: string) {}

  async getContact(id: string): Promise<HubSpotContact> {
    const response = await this.request<HubSpotContact>(
      `https://api.hubapi.com/crm/v3/objects/contacts/${id}`
    );
    return response;
  }

  private async request<T>(url: string, options: RequestInit = {}): Promise<T> {
    const response = await fetch(url, {
      ...options,
      headers: {
        'Authorization': `Bearer ${this.accessToken}`,
        'Content-Type': 'application/json',
        ...options.headers
      }
    });

    if (!response.ok) {
      const error = await response.json() as HubSpotError;
      throw new Error(`HubSpot API Error: ${error.message}`);
    }

    return response.json() as Promise<T>;
  }
}

The Apideck Escape Hatch

Look, I've built enough HubSpot integrations to know when to quit. If you need HubSpot plus other CRMs (Salesforce, Pipedrive, etc.), stop building separate integrations and use a unified API.

Screenshot 2025-09-12 at 23.40.16@2x

Apideck's unified CRM API handles HubSpot's quirks so you don't have to. One integration instead of five, OAuth complexity handled, rate limiting managed across their infrastructure, and lifecycle stage nonsense abstracted away. Your 6-week HubSpot integration can be done easily within two weeks of actual coding. And not only this, you can configure other CRMs as well.

Apideck's CRM Unified API

// Direct HubSpot API: OAuth dance, token refresh, rate limiting, lifecycle stage hell
// Apideck: Just this

import { Apideck } from '@apideck/node';

const apideck = new Apideck({
  apiKey: process.env.APIDECK_API_KEY,
  appId: process.env.APIDECK_APP_ID,
  consumerId: 'customer-123' // Your customer's ID
});

// Create a contact in HubSpot (or any CRM they've connected)
async function createContact() {
  try {
    const contact = await apideck.crm.contactsAdd({
      serviceId: 'hubspot', // Or 'salesforce', 'pipedrive', etc.
      contact: {
        firstName: 'John',
        lastName: 'Doe',
        email: 'john@example.com',
        phoneNumbers: [{ number: '+1-555-1234', type: 'work' }],
        // Lifecycle stage just works - no backwards movement BS
        lifecycleStage: 'lead',
        customFields: [
          { id: 'industry', value: 'Technology' }
        ]
      }
    });

    console.log('Contact created:', contact.data.id);
  } catch (error) {
    // Actual useful error messages
    console.error('Error:', error.message);
  }
}

// Get all contacts with pagination handled automatically
async function getAllContacts() {
  const contacts = await apideck.crm.contactsAll({
    serviceId: 'hubspot',
    filter: {
      email: 'john@example.com'
    }
  });

  // No manual pagination, no rate limit management
  for await (const contact of contacts) {
    console.log(contact.name, contact.email);
  }
}

// The same code works for ANY CRM - just change serviceId
async function syncToMultipleCRMs(contactData) {
  const crms = ['hubspot', 'salesforce', 'pipedrive'];

  for (const crm of crms) {
    await apideck.crm.contactsAdd({
      serviceId: crm,
      contact: contactData
    });
  }
  // That's it. No OAuth per platform, no different APIs, no rate limit juggling
}

Compare that to the 200 lines of OAuth handling, rate limit retry logic, and lifecycle stage workarounds you need for direct HubSpot integration. One API, consistent data models, errors that actually make sense.

Tangible benefits that matter:

Ship Something That Works

HubSpot's API is powerful but exhausting. You'll spend more time handling edge cases than building features. Every integration starts simple and ends with dozens of workarounds for HubSpot's peculiarities.

Screenshot 2025-09-12 at 23.41.19@2x

My advice? Start with the smallest possible integration. Get OAuth working. Make one API call successfully. Handle rate limits properly. Only then add more complexity. And when you inevitably hit the wall where you're spending more time fighting HubSpot than building your product, consider whether a unified API makes more sense.

The perfect HubSpot integration doesn't exist. Ship something that works, iterate based on which errors your customers actually hit, and keep a bottle of whiskey handy for when HubSpot changes something without warning.

Because they will, they always do.

Ready to get started?

Scale your integration strategy and deliver the integrations your customers need in record time.

Ready to get started?
Talk to an expert

Trusted by fast-moving product & engineering teams

Nmbrs
Benefex
Invoice2go by BILL
Trengo
Ponto | Isabel Group
Apideck Blog

Insights, guides, and updates from Apideck

Discover company news, API insights, and expert blog posts. Explore practical integration guides and tech articles to make the most of Apideck's platform.

How to Build an Integration with the QuickBooks Desktop API in 2025
Unified APIGuides & TutorialsAccounting

How to Build an Integration with the QuickBooks Desktop API in 2025

Learn how to build an integration with the QuickBooks Desktop API in 2025, including SOAP service setup, Web Connector configuration, and QBXML implementation. Complete guide with code examples, common pitfalls, and production-ready patterns for connecting your app to QuickBooks Desktop.

Saurabh Rai

Saurabh Rai

13 min read
How to Get Your Gemini API Key
AIGuides & Tutorials

How to Get Your Gemini API Key

This guide walks you through everything from creating your API key in AI Studio to testing your first request, setting up restrictions, and preparing for production on Google Cloud. A clear, practical walkthrough for developers who want to get started fast and securely.

Kateryna Poryvay

Kateryna Poryvay

6 min read
How to Get Your Grok (XAI) API Key
AIGuides & Tutorials

How to Get Your Grok (XAI) API Key

Unlock the power of xAI with our complete guide to getting your Grok API key. Follow these step-by-step instructions to subscribe, generate, and test your key today.

Kateryna Poryvay

Kateryna Poryvay

8 min read