Back to Blog

HubSpot Integration in NestJS: Sync CRM Data

Published: June 29, 2026
HubSpot Integration in NestJS: Sync CRM Data

The first version of a CRM sync always works in the demo and breaks in week two. A customer updates a contact in your app, you push it to HubSpot, HubSpot fires a webhook back, your handler sees a "change" and pushes again — and now two systems are ping-ponging the same record until something rate-limits or corrupts. A real HubSpot integration is less about the API calls and more about knowing which changes are echoes of your own.

The architecture that holds up: OAuth per tenant, stable ID mapping, a source-attribution lock to kill circular loops, and webhooks processed off the request thread in a queue. Get those four right and you can stream thousands of contact and event updates without breaking CRM state. Here's the build in NestJS.

A native HubSpot integration puts your product's events straight into the customer's sales dashboard

OAuth, Not Private Apps

HubSpot gives you two auth paths, and only one is multi-tenant:

  • Private App token — a single static string for one portal. Fine for an internal tool, useless for SaaS where every customer has their own HubSpot.
  • OAuth 2.0 — the production standard. The user clicks "Connect HubSpot," approves on a consent screen, and your callback stores a per-tenant access_token and refresh_token. This is the same OAuth pattern as a Slack integration — a button in your dashboard, a token per workspace.

HubSpot's public apps guide covers the install flow; the key point is one stored credential set per tenant.

Map IDs, Never Emails

Emails change, and the day one does, an email-keyed sync corrupts. Bind your user to HubSpot's internal object ID in a mapping table:

SQL
1CREATE TABLE hubspot_contact_mappings (
2    id BIGSERIAL PRIMARY KEY,
3    tenant_id UUID NOT NULL REFERENCES tenants(id),
4    user_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
5    hubspot_contact_id VARCHAR(64) NOT NULL UNIQUE,
6    last_synced_at TIMESTAMPTZ DEFAULT NOW()
7);
8
9CREATE INDEX idx_hubspot_mappings ON hubspot_contact_mappings (tenant_id, hubspot_contact_id);

Then upsert through the real CRM contacts API — patch the mapped ID if you have one, create otherwise:

TypeScript
1// src/integrations/hubspot/hubspot-sync.service.ts
2import { Injectable } from '@nestjs/common';
3import { HttpService } from '@nestjs/axios';
4import { firstValueFrom } from 'rxjs';
5
6@Injectable()
7export class HubSpotSyncService {
8  private readonly base = 'https://api.hubapi.com/crm/v3/objects/contacts';
9
10  constructor(private readonly http: HttpService) {}
11
12  async upsertContact(token: string, contact: { email: string; firstName: string; lastName: string }, hubspotId?: string) {
13    const url = hubspotId ? `${this.base}/${hubspotId}` : this.base;
14    const method = hubspotId ? 'patch' : 'post';
15    const body = { properties: { email: contact.email, firstname: contact.firstName, lastname: contact.lastName } };
16
17    const res = await firstValueFrom(
18      this.http[method](url, body, { headers: { Authorization: `Bearer ${token}` } }),
19    );
20    return res.data; // contains HubSpot's internal contact id
21  }
22}

Break the Circular Loop With Source Attribution

This is the bug that defines the feature. Before pushing an outbound change, drop a short-lived Redis lock identifying your write; when the echo webhook comes back matching it, skip it:

TypeScript
1// src/integrations/hubspot/hubspot-webhook.processor.ts
2import { Processor, WorkerHost } from '@nestjs/bullmq';
3import { Job } from 'bullmq';
4import { Injectable } from '@nestjs/common';
5import Redis from 'ioredis';
6
7@Processor('hubspot-webhook-queue')
8@Injectable()
9export class HubSpotWebhookProcessor extends WorkerHost {
10  constructor(private readonly redis: Redis) { super(); }
11
12  async process(job: Job<{ userId: string; property: string; value: string }>) {
13    const { userId, property, value } = job.data;
14    const lockKey = `hs:outbound:${userId}:${property}`;
15
16    // If this matches a change WE just pushed, it's an echo — clear the lock and stop.
17    if ((await this.redis.get(lockKey)) === value) {
18      await this.redis.del(lockKey);
19      return { skipped: 'echo' };
20    }
21
22    // Otherwise it's a genuine inbound edit from HubSpot — apply it locally.
23    await this.applyInbound(userId, property, value);
24    return { applied: true };
25  }
26
27  private async applyInbound(userId: string, property: string, value: string) {
28    // local DB write...
29  }
30}

Set the lock (SET hs:outbound:... value EX 30) right before each outbound push. It's a small amount of bookkeeping that's the difference between a sync and a slow-motion data-corruption incident.

A source-attribution lock tells real inbound edits apart from echoes of your own writes

Emit Behavioral Events

Customers want to see what users did, not just contact fields. Push milestone events — project created, teammate invited — into HubSpot so they land on the contact's timeline:

TypeScript
1async trackEvent(token: string, email: string, eventName: string, value: number) {
2  await firstValueFrom(
3    this.http.post(
4      'https://api.hubapi.com/events/v3/send',
5      { eventName: `pe_app_${eventName}`, email, properties: { value: value.toString(), at: new Date().toISOString() } },
6      { headers: { Authorization: `Bearer ${token}` } },
7    ),
8  );
9}

Behavioral events from your HubSpot integration turn product usage into sales signals

Webhooks: Verify, Queue, Ack

HubSpot wants a fast 200 or it throttles you. Verify the v3 signature, push to a BullMQ queue, return immediately — never do the database work inline:

TypeScript
1// src/integrations/hubspot/hubspot-webhook.controller.ts
2import { Controller, Post, Body, HttpCode, HttpStatus, UseGuards } from '@nestjs/common';
3import { InjectQueue } from '@nestjs/bullmq';
4import { Queue } from 'bullmq';
5import { HubSpotSignatureGuard } from './hubspot-signature.guard';
6
7@Controller('webhooks/hubspot')
8export class HubSpotWebhookController {
9  constructor(@InjectQueue('hubspot-webhook-queue') private readonly queue: Queue) {}
10
11  @Post()
12  @UseGuards(HubSpotSignatureGuard) // verifies v3 HMAC-SHA256 over method+uri+body+timestamp
13  @HttpCode(HttpStatus.OK)
14  async receive(@Body() payload: any) {
15    await this.queue.add('property-change', payload);
16    return { received: true };
17  }
18}

Same ack-fast, process-async discipline as every reliable webhook receiver — see the job-queue comparison for why the worker, not the handler, does the work.

Survive the Rate Limits

HubSpot rate-limits per 10-second window, and the exact cap depends on the customer's plan and API add-on — so read the current usage guidelines rather than hardcoding a number. Three patterns keep you under it:

  1. Throttle the outbound worker so it can't burst past the window.
  2. Exponential backoff with jitter on any 429, so a flood of jobs doesn't all retry on the same tick.
  3. Batch Objects API — send up to 100 updates in one request instead of 100 calls. This single change removes most rate-limit pressure.

All three are the standard third-party API reliability patterns — timeouts, retries, backoff — pointed at HubSpot.

HubSpot Integration: Native vs Zapier

Native (OAuth)Zapier middleware
OnboardingOne click in your appUser builds Zaps, maps fields
LatencySub-secondPolling delays by tier
Multi-tenantClean via tenant FKsHard to scale securely
Build costDays of engineeringAn afternoon
Ongoing costFlat server overheadScales with task volume

Zapier is a fine bridge for one company's internal automation. For a product you're selling to hundreds of tenants, the native OAuth integration is the one that onboards in a click and syncs in real time — and crucially, it's yours, not a brittle dependency on someone else's polling schedule.

Build the OAuth flow, map the IDs, set the source-attribution lock, and let the queue absorb the webhooks. Do that and a HubSpot integration stops being the feature that pages you when two systems fight over a record, and becomes the one that quietly drops your customers' usage data exactly where their sales team already looks.

Frequently Asked Questions

OAuth, always, for multi-tenant. A Private App token is a single static string tied to one HubSpot portal — fine for an internal tool, useless when each customer connects their own portal. With OAuth, the user clicks 'Connect HubSpot', approves on a consent screen, and your callback stores a per-tenant access_token and refresh_token. Private Apps only make sense for single-company internal integrations.

Your app updates a contact, which fires a HubSpot webhook back to you, which your handler treats as a change and pushes again — forever. Break it with source attribution: before an outbound update, set a short-lived Redis lock keyed to the user and property with the new value. When the echo webhook arrives matching that lock, clear it and skip processing. Only changes that don't match a lock are real inbound edits.

Verify the v3 signature (HMAC-SHA256 over method, URI, body, and timestamp), then immediately push the payload onto a queue and return 200. HubSpot expects a fast acknowledgement and will throttle your endpoint if you hold the connection open doing database work. The real processing — mapping, conflict resolution, local writes — happens in a BullMQ worker, not the request handler.

Three layers: rate-limit your outbound worker so it stays under HubSpot's per-10-second window (the cap depends on your plan and API add-on, so check current limits); add exponential backoff with jitter on any 429; and use the Batch Objects API to send up to 100 updates in one request instead of 100 separate calls. The batch endpoint alone removes most rate-limit pressure.

Native for a real product. Zapier is great for a single company's internal automation, but pushing enterprise customers to build their own Zaps, manage keys, and map fields is high-friction, polling-based, and hard to scale across hundreds of tenants. A native OAuth button gives one-click onboarding, sub-second sync, and tenant isolation through your own foreign keys. Build native once SSO-grade customers start asking.

Portrait of Umar Farooq

About Umar Farooq

Umar Farooq is the founder and lead engineer of Codify SaaS. He builds B2B SaaS products and web applications on modern TypeScript stacks and enterprise Java, and writes code-first guides drawn from real production work — the schema decisions, the migrations that almost went wrong, and the performance fixes that actually moved the numbers. When he recommends an approach, he shows the code and explains the trade-offs.

Read full bio