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.

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_tokenandrefresh_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:
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:
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:
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.

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:
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}
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:
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:
- Throttle the outbound worker so it can't burst past the window.
- Exponential backoff with jitter on any
429, so a flood of jobs doesn't all retry on the same tick. - 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 | |
|---|---|---|
| Onboarding | One click in your app | User builds Zaps, maps fields |
| Latency | Sub-second | Polling delays by tier |
| Multi-tenant | Clean via tenant FKs | Hard to scale securely |
| Build cost | Days of engineering | An afternoon |
| Ongoing cost | Flat server overhead | Scales 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.
