---
title: "How to Build a HubSpot Integration: 2026 Architecture Guide"
slug: how-do-i-build-a-hubspot-integration-2026-architecture-guide
date: 2026-03-30
author: Roopendra Talekar
categories: [Guides, By Example, Engineering]
excerpt: "A complete architecture guide for building a HubSpot integration — OAuth 2.0, rate limits, cursor pagination, filterGroups, and the build vs. buy decision."
tldr: "Building a HubSpot integration requires OAuth with 30-min token expiry, 110 req/10s rate limits, cursor pagination, and complex filterGroups — or a unified API like Truto to skip the infrastructure build."
canonical: https://truto.one/blog/how-do-i-build-a-hubspot-integration-2026-architecture-guide/
---

# How to Build a HubSpot Integration: 2026 Architecture Guide


**Building a HubSpot integration** means wiring your app into HubSpot's CRM v3 API to read and write contacts, companies, deals, and tickets. It requires implementing OAuth 2.0 with aggressive token refresh, handling cursor-based pagination, respecting strict rate limits, normalizing HubSpot's dynamic `properties` object, and constructing complex `filterGroups` for search.

The short answer to "how do I build a HubSpot integration?" is: register a public OAuth app, build a token management system, write a polling or webhook ingestion service, and map HubSpot's custom properties to your database. The long answer involves exponential backoff, cursor pagination, and months of engineering maintenance.

This guide breaks down each of those pieces — the real architecture, the gotchas nobody warns you about, and the strategic decision of whether to build it yourself or buy.

## Why Your B2B SaaS Needs a Native HubSpot Integration

Your sales tool does not exist in isolation. It lives inside a stack of 6 to 10 other tools that every sales rep already juggles daily. If your application cannot read from and write to the system of record, it becomes an administrative burden.

HubSpot CRM holds a 5.32% market share in the CRM platforms category, with over 36,400 companies using it worldwide. That might sound small next to Salesforce's 22%, but look at who those customers are: the majority fall in the 20–49 employee and 100–249 employee company size brackets — exactly the mid-market segment where most B2B SaaS companies find their sweet spot.

HubSpot's revenue grew from $883 million in 2020 to over $2.6 billion in 2025, with over 228,000 paying customers across more than 135 countries. The CRM market they operate in is massive: the global CRM software market reached $112.91 billion in 2025 and is projected to hit $262.74 billion by 2032, driving intense demand for reliable data synchronization.

If you're building a sales tool, revenue intelligence platform, customer success product, or marketing automation tool, your prospects will ask about HubSpot in the first demo. Not the second. Not in the follow-up email. The first demo. When a prospect asks if you integrate with HubSpot and you reply, "it's on the roadmap," the evaluation ends. Missing this integration doesn't just cost you one deal — it signals to buyers that you don't understand their stack.

For a deeper look at what else belongs on your integration roadmap, see our [guide to the most requested integrations for B2B sales tools](https://truto.one/blog/what-are-the-most-requested-integrations-for-b2b-sales-tools/).

## The Architecture of a HubSpot Integration

A production-grade HubSpot integration has four major subsystems. Skip any of them and you'll be debugging in production within a month.

```mermaid
flowchart TD
    A["Your Application"] --> B["OAuth 2.0<br>Token Manager"]
    B --> C["Rate Limiter<br>+ Retry Queue"]
    C --> D["HubSpot CRM v3 API"]
    D --> E["Response Parser<br>+ Schema Mapper"]
    E --> A
    F["HubSpot Webhooks"] --> G["Signature<br>Verification"]
    G --> H["Event Router<br>+ Enrichment"]
    H --> A
```

Building a toy integration that pulls a list of contacts via a static API key takes an hour. Building a multi-tenant, customer-facing integration that handles thousands of connected accounts requires serious architectural planning across each of these subsystems.

### OAuth 2.0: 30-Minute Tokens and the Refresh Race

HubSpot uses the OAuth 2.0 authorization code flow for public apps. You register a public app in their developer portal, define your scopes (like `crm.objects.contacts.read`), and redirect users through the standard authorization flow. You cannot ask your customers to generate and paste static API keys.

Here's the full authorization sequence:

```mermaid
sequenceDiagram
    participant User
    participant App as Your Application
    participant Auth as HubSpot Auth Server
    participant API as HubSpot CRM API

    User->>App: Clicks "Connect HubSpot"
    App->>Auth: Redirects with client_id, scopes, state
    Auth->>User: Prompts for authorization
    User->>Auth: Approves access
    Auth->>App: Redirects back with authorization code
    App->>Auth: POST /oauth/v3/token (exchanges code)
    Auth->>App: Returns access_token & refresh_token
    App->>API: GET /crm/v3/objects/contacts (Bearer token)
    API->>App: Returns Contact Data
```

Here's where it gets painful. HubSpot changed OAuth access token expiration from 6 hours to 30 minutes. That's an aggressive TTL. The response gives you an access token, a refresh token, and an `expires_in` value of 1800 seconds.

The good news: refresh tokens stay valid indefinitely unless the user uninstalls your app or you revoke them manually. The bad news: that 30-minute window means your integration must proactively refresh tokens *before* they expire, not after a `401 Unauthorized` hits you in production. Waiting for a 401 to trigger a refresh introduces latency and race conditions when multiple background workers attempt to refresh the same token simultaneously, a distributed systems challenge we cover in our [guide to scalable OAuth token management](https://truto.one/blog/how-to-architect-a-scalable-oauth-token-management-system-for-saas-integrations/).

A basic token refresh flow looks like this:

```typescript
async function getValidAccessToken(accountId: string): Promise<string> {
  const account = await db.getIntegratedAccount(accountId);
  const tokenAge = Date.now() - account.tokenIssuedAt;
  const bufferMs = 60 * 1000; // refresh 60s before expiry
  
  if (tokenAge < (account.expiresIn * 1000) - bufferMs) {
    return account.accessToken;
  }

  const response = await fetch('https://api.hubspot.com/oauth/v3/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      client_id: process.env.HUBSPOT_CLIENT_ID,
      client_secret: process.env.HUBSPOT_CLIENT_SECRET,
      refresh_token: account.refreshToken,
    }),
  });

  if (!response.ok) {
    await db.markAccountNeedsReauth(accountId);
    throw new Error('Token refresh failed - user must re-authorize');
  }

  const tokens = await response.json();
  await db.updateTokens(accountId, tokens);
  return tokens.access_token;
}
```

:::warning
HubSpot released new OAuth v3 API endpoints in January 2026 with enhanced security features. The v1 OAuth endpoints are now deprecated but remain operational. New integrations should use v3. Make sure you're POSTing to `/oauth/v3/token`, not the legacy v1 endpoint.
:::

The 30-minute expiry caught a lot of developers off guard. One developer noted they had logic like `Max(0, expires_in - 1 hour)`, which obviously can't subtract an hour from 30 minutes, causing the code to refresh the token on every event loop. Don't hardcode expiry assumptions — always read the `expires_in` value from the token response.

### Credential Storage

Your OAuth tokens are essentially passwords to your customer's CRM. Encrypt them at rest. At minimum, use envelope encryption with a KMS. Never log access tokens. Never return them in API responses to your frontend.

## Handling HubSpot API Rate Limits

HubSpot enforces two independent rate limit ceilings that trip up integration engineers.

**Burst limits (per 10-second rolling window):**
- Public OAuth apps: 110 requests every 10 seconds per account
- Private apps on Professional plans: 190 requests per 10 seconds
- Enterprise accounts: 190 requests per 10 seconds, with a daily limit increased to 1 million requests per day

**Daily limits:**
- Professional: 650,000 requests per day
- Enterprise: 1 million per day
- The API Limit Increase capacity pack adds 1 million requests per day to the standard limit

Any app or integration exceeding its rate limits receives a `429 Too Many Requests` response for all subsequent API calls. And here's the part most developers miss: requests resulting in an error response shouldn't exceed 5% of your total daily requests. If you plan on listing your app in the HubSpot Marketplace, it must stay under this 5% error threshold to be certified.

The practical problem is that 110 requests per 10 seconds sounds generous until you're syncing contacts for a customer with 500,000 records. At 100 records per page, that's 5,000 API calls just for the initial sync — and you're burning through your burst budget if you're also handling live API requests from your app simultaneously.

A naive implementation will fail the sync job. A production-ready implementation requires:

- **A queueing system:** Decouple API requests from user actions.
- **Exponential backoff with jitter:** When a 429 occurs, pause the worker, wait for a calculated duration, and retry. Add random jitter to prevent the "thundering herd" problem where multiple workers retry at the exact same millisecond.
- **Circuit breakers:** If an endpoint consistently returns 5xx errors or 429s despite backoff, temporarily halt all requests to that endpoint to prevent cascading failures.

Even exponential backoff is not enough when retries happen across threads, tenants, or object types. What looks like a resilient retry strategy often turns into a retry storm. You need a proper token-bucket rate limiter that's shared across all workers hitting the same HubSpot account — not per-thread backoff logic.

```typescript
class HubSpotRateLimiter {
  private tokens: number;
  private lastRefill: number;
  private readonly maxTokens = 100; // conservative buffer below 110
  private readonly refillInterval = 10_000; // 10 seconds

  async acquire(): Promise<void> {
    this.refill();
    if (this.tokens <= 0) {
      const waitMs = this.refillInterval - (Date.now() - this.lastRefill);
      await new Promise(resolve => setTimeout(resolve, waitMs));
      this.refill();
    }
    this.tokens--;
  }

  private refill(): void {
    const now = Date.now();
    if (now - this.lastRefill >= this.refillInterval) {
      this.tokens = this.maxTokens;
      this.lastRefill = now;
    }
  }
}
```

:::tip
HubSpot's CRM Search endpoint now supports 200 records per call. Use it. Fetching 200 contacts per request instead of 100 cuts your API call volume in half during bulk syncs.
:::

## How HubSpot Cursor Pagination Actually Works

HubSpot's CRM v3 API uses **cursor-based pagination**. You cannot request `?page=5`. Every list response includes a `paging` object with a `next.after` token that you pass as a query parameter to get the next page.

When you request a list of objects, HubSpot returns a payload like this:

```json
{
  "results": [
    { "id": "123", "properties": { "firstname": "Jane" } },
    { "id": "124", "properties": { "firstname": "Alex" } }
  ],
  "paging": {
    "next": {
      "after": "NTI1Cg%3D%3D",
      "link": "?after=NTI1Cg%3D%3D"
    }
  }
}
```

The `after` value is an opaque cursor. You cannot predict it, skip ahead, or jump to a specific page. There's no random access, no previous page cursor, no total count in list responses. Your code must check for the existence of `paging.next.after` and append it to the next request. This forces your synchronization logic to be strictly sequential — you cannot parallelize fetching page 1, page 2, and page 3.

Here's the basic pagination loop:

```typescript
async function fetchAllContacts(accessToken: string): Promise<any[]> {
  const contacts: any[] = [];
  let after: string | null = null;

  do {
    const params = new URLSearchParams({
      limit: '100',
      properties: 'firstname,lastname,email,phone',
    });
    if (after) params.set('after', after);

    const response = await fetch(
      `https://api.hubspot.com/crm/v3/objects/contacts?${params}`,
      { headers: { Authorization: `Bearer ${accessToken}` } }
    );
    const data = await response.json();
    contacts.push(...data.results);

    after = data.paging?.next?.after ?? null;
  } while (after);

  return contacts;
}
```

**The Search API gotcha:** HubSpot's Search endpoint (`POST /crm/v3/objects/contacts/search`) uses the same cursor pagination, but the `after` parameter can't be greater than 10,000. That means you can only page through the first 10,000 results of any search query. If you need more, you have to break your query into smaller time windows or use the standard list endpoint with `hs_lastmodifieddate` filtering.

> [!WARNING]
> **Pagination Edge Cases:** Cursor tokens can expire, and the format of the cursor changes between different HubSpot API versions (e.g., v1 vs v3). Your pagination logic must be resilient to malformed or expired cursors.

## Handling Custom Properties and FilterGroups

This is where most HubSpot integrations get messy.

### The Custom Properties Problem

Every HubSpot instance is heavily customized. While standard fields like `firstname` and `email` exist, enterprise customers rely on custom properties (e.g., `target_account_tier`, `churn_risk_score`). HubSpot doesn't return contact fields as flat, top-level keys. Every field — both default and custom — lives inside a nested `properties` object:

```json
{
  "id": "512",
  "properties": {
    "createdate": "2026-01-15T10:30:00Z",
    "email": "founder@example.com",
    "firstname": "Jane",
    "lastname": "Doe",
    "hs_additional_emails": "jane.doe@personal.com;jane@startup.io",
    "custom_churn_risk": "High"
  }
}
```

Notice the `hs_additional_emails` field. It is a semicolon-separated string, not an array. Your code must split this string and normalize it into a usable format.

You don't get all properties by default — you must explicitly request them via the `properties` query parameter. Miss a field, and it silently won't appear in the response. Custom properties are especially tricky because each HubSpot account has different ones. Your integration needs to either:

1. **Discover properties dynamically** using the Properties API (`GET /crm/v3/properties/contacts`), then include them in every request
2. **Let customers configure** which custom fields they care about, and only fetch those

### Searching with FilterGroups

The Search API makes things worse. Filtering contacts requires constructing `filterGroups` — an array-of-arrays structure that represents boolean logic. Filters within a group are ANDed; groups are ORed.

To search for a contact where the email matches "founder@example.com" AND the lead status is "QUALIFIED":

```json
{
  "filterGroups": [
    {
      "filters": [
        {
          "propertyName": "email",
          "operator": "EQ",
          "value": "founder@example.com"
        },
        {
          "propertyName": "hs_lead_status",
          "operator": "EQ",
          "value": "QUALIFIED"
        }
      ]
    }
  ],
  "limit": 100
}
```

To search where the first name contains "Jane" OR the email matches "founder@example.com", you need separate filter groups:

```json
{
  "filterGroups": [
    {
      "filters": [
        {
          "propertyName": "firstname",
          "operator": "CONTAINS_TOKEN",
          "value": "Jane"
        }
      ]
    },
    {
      "filters": [
        {
          "propertyName": "email",
          "operator": "EQ",
          "value": "founder@example.com"
        }
      ]
    }
  ]
}
```

The supported operators vary by field type (`EQ`, `NEQ`, `LT`, `GT`, `CONTAINS_TOKEN`, `IN`, `HAS_PROPERTY`, etc.), and getting them wrong produces unhelpful error messages. Building a query builder that translates your application's internal search parameters into HubSpot's `filterGroups` syntax requires significant engineering effort. This is the kind of API surface that looks simple in the docs and takes two weeks to get right across every edge case.

For teams that need to map HubSpot's properties to a standard data model alongside other CRMs, this normalization work multiplies. Salesforce uses flat PascalCase fields (`FirstName`, `LastName`), Pipedrive uses a completely different structure, and Zoho has its own conventions. Mapping all of these to one internal schema is the [hardest problem in SaaS integrations](https://truto.one/blog/why-schema-normalization-is-the-hardest-problem-in-saas-integrations/).

## Webhook Ingestion and Bidirectional Sync

Polling HubSpot every 5 minutes to check for updated contacts is highly inefficient and will quickly exhaust your rate limits. Instead, you must register webhook subscriptions. HubSpot sends HTTP POST requests to your server whenever a record is created, updated, or deleted.

Your infrastructure must:

- **Verify the HMAC signature** of incoming payloads using your app secret to ensure authenticity
- **Enqueue events asynchronously** rather than processing them inline — webhook handlers must return 200 quickly or HubSpot will retry
- **Enrich payloads** by fetching the full record from the API, since webhook payloads contain only the object ID and changed properties
- **Handle deduplication** — HubSpot may send the same event multiple times, so your processing must be idempotent

Bidirectional sync introduces another challenge: your integration needs to push updates back to HubSpot while also listening to HubSpot webhooks. This requires mapping your internal data model to HubSpot's schema, handling validation errors, and ensuring that updates don't trigger infinite loops. Without careful tracking of which changes originated from your app, every write triggers a webhook, which triggers another write, ad infinitum. For a deep dive into this problem, see our guide on [how to architect a bidirectional HubSpot sync without infinite loops](https://truto.one/blog/how-to-sync-customer-data-bidirectionally-between-your-app-and-hubspot/).

## Build vs. Buy: The True Cost of Engineering a HubSpot Integration

Let's be honest about the math.

When product managers ask for a HubSpot integration, developers usually look at the API documentation, find the `/crm/v3/objects/contacts` endpoint, and estimate a week of work. This is the integration iceberg. The HTTP request is the 10% visible above the water. The remaining 90% — authentication, pagination, rate limiting, data normalization, webhooks, and ongoing maintenance — lurks below the surface.

**Building in-house** for a single HubSpot integration means:

| Component | Estimated Engineering Time |
|---|---|
| OAuth 2.0 flow + token refresh | 1–2 weeks |
| Cursor pagination + rate limiting | 1 week |
| Data normalization (contacts, companies, deals) | 2–3 weeks |
| Webhook ingestion + signature verification | 1–2 weeks |
| Custom field discovery + mapping | 1–2 weeks |
| Error handling, retry logic, monitoring | 1–2 weeks |
| **Total initial build** | **7–12 weeks** |

That's before ongoing maintenance. HubSpot changes things. They moved token expiry from 6 hours to 30 minutes with minimal notice. They introduced new OAuth v3 endpoints and deprecated v1. They adjust rate limits. Every change requires engineering time to detect, understand, and patch — forever.

If HubSpot is truly the *only* CRM your customers will ever use, building in-house can make sense. You get full control, no third-party dependency, and you can optimize the integration for your exact use case.

But if your roadmap includes Salesforce, Pipedrive, Zoho, Dynamics 365, or any combination of CRMs — and it will, because prospects always ask — then you're signing up to repeat this entire exercise for each one. Every hour your senior engineers spend maintaining a HubSpot integration is an hour they're not building your core product features. The [true cost of building integrations in-house](https://truto.one/blog/build-vs-buy-the-true-cost-of-building-saas-integrations-in-house/) scales linearly with every new CRM you add.

## How Truto Handles the HubSpot Integration

Truto takes a fundamentally different approach: every integration — HubSpot included — is defined as **declarative configuration**, not code. There are no `if (provider === 'hubspot')` branches in the codebase. The same execution engine that calls HubSpot's API also calls Salesforce, Pipedrive, and 100+ other integrations, driven entirely by JSON config and JSONata transformation expressions.

### OAuth, Rate Limits, and Pagination — Handled by Config

HubSpot's OAuth flow, 30-minute token expiry, and proactive refresh are all managed through a declarative authentication configuration. Truto refreshes OAuth tokens shortly before they expire, and if a refresh fails, the connected account is automatically flagged as `needs_reauth` with a webhook event fired to your app.

Rate limiting is configured per integration. When HubSpot returns a 429, Truto detects it and applies backoff automatically — your app never sees the error. Cursor pagination through `paging.next.after` is handled by the platform's cursor pagination strategy, configured once in the HubSpot integration definition.

### Schema Normalization via JSONata

The mapping between HubSpot's nested `properties` object and Truto's unified CRM schema is a JSONata expression. HubSpot's `properties.firstname` maps to the unified `first_name`. The semicolon-delimited `hs_additional_emails` field gets split into an array. Six different phone fields collapse into a typed `phone_numbers` array. All of this happens in a declarative mapping that can be modified without redeploying anything.

Your app calls one endpoint:

```
GET /unified/crm/contacts?integrated_account_id=abc123&limit=10
```

And gets back a normalized response — the same shape whether the underlying CRM is HubSpot, Salesforce, or Pipedrive:

```json
{
  "result": [
    {
      "id": "123",
      "first_name": "Jane",
      "last_name": "Smith",
      "email_addresses": [
        { "email": "jane@example.com", "is_primary": true },
        { "email": "jane.smith@work.com" }
      ],
      "phone_numbers": [
        { "number": "+1-555-0123", "type": "phone" },
        { "number": "+1-555-0456", "type": "mobile" }
      ],
      "custom_fields": { "custom_field_abc": "some value" },
      "remote_data": { /* original HubSpot response preserved */ }
    }
  ],
  "next_cursor": "..."
}
```

The `remote_data` field preserves the original HubSpot response, so you always have access to raw data that the unified schema doesn't cover.

### Dynamic Resource Routing

HubSpot's "list contacts" operation actually routes to different endpoints depending on the query. A plain list goes to the standard contacts endpoint. Adding filter parameters routes to the search endpoint with `filterGroups`. Specifying a view ID routes to a third endpoint for list results. Truto handles this with dynamic resource resolution — a JSONata expression evaluates the incoming query and picks the right endpoint, all configured as data. You never have to think about which HubSpot endpoint to call.

### Per-Customer Customization Without Code

Every enterprise customer's HubSpot instance is different. Custom properties, custom objects, unique workflows. Truto's three-level override system (platform default, environment override, per-account override) means you can customize how mappings work for specific customers without changing the base integration. If one customer needs a special custom field included in the unified response, you override the response mapping for just that account.

### Built-in MCP Tool Generation

Truto automatically exposes HubSpot resources as AI agent tools with generated JSON schemas, bypassing the need to write custom wrappers for LLM function calling. If your product involves AI agents that need CRM context, this eliminates an entire layer of integration glue code.

### Trade-offs to Consider

Unified APIs are not free lunches. You're adding a dependency between your app and a third-party service. The unified schema necessarily loses some HubSpot-specific nuance — though `remote_data` and the Proxy API give you escape hatches for direct access when you need it. If you have very HubSpot-specific workflow requirements that go beyond CRUD operations, you may still need the Proxy API for those edge cases.

The honest calculation: if your team needs HubSpot plus two or more other CRMs, the unified API approach pays for itself in weeks, not months. If you only need HubSpot and have engineering bandwidth to spare, building in-house gives you more control.

## Your Integration Roadmap: What to Do Next

Here's the honest sequence for a PM staring at a spreadsheet of lost deals:

1. **Audit your pipeline.** Count the deals blocked by missing HubSpot (and CRM) integrations. Put a dollar figure on it. This is your business case.

2. **Decide build vs. buy based on scope.** If HubSpot is the only integration you'll need for 12+ months, consider building it. If your roadmap has 3+ CRMs, the math favors a unified API every time.

3. **Start with read operations.** Contacts and companies. Get data flowing into your app. This alone unblocks most sales conversations.

4. **Add write-back and webhooks.** Bidirectional sync is where the real product stickiness comes from. Prospects want to see their CRM update in real time when they use your tool, which requires [architecting real-time CRM syncs](https://truto.one/blog/architecting-real-time-crm-syncs-for-enterprise-a-technical-guide/) that can handle rate limits and conflicting writes.

5. **Plan for the long tail.** HubSpot is one CRM. Your enterprise prospects use Salesforce. Your SMB customers use Pipedrive or Zoho. Build your architecture assuming you'll support all of them — whether that means a unified API or a well-abstracted internal integration layer. The [startup integration playbook](https://truto.one/blog/why-truto-is-the-best-unified-api-for-startups-shipping-integrations-in-2026/) covers how to think about this strategically.

The CRM market is headed toward $262 billion by 2032. Your product either plugs into this ecosystem or gets left out of the buying conversation entirely. The question isn't whether to build the HubSpot integration — it's how fast you can ship it without letting integration work eat your roadmap alive.

> Need to ship HubSpot — and 50 other integrations — without burning your engineering roadmap? Truto's unified API handles OAuth, rate limits, pagination, and schema normalization across 100+ integrations with zero integration-specific code. Let's talk about your integration roadmap.
>
> [Talk to us](https://cal.com/truto/partner-with-truto)
