HubSpot is the CRM platform that most SaaS companies eventually need to connect to. Whether you are syncing sign-ups, tracking deal progression, automating onboarding sequences, or pulling sales data into a custom dashboard, the integration surface is wide and the implementation details matter.
This guide covers the full HubSpot CRM integration architecture, OAuth, contacts API, deals API, webhook verification, workflow automation, and production patterns across every major frontend and backend stack.
Overview
HubSpot is a cloud-based CRM platform that centralizes contact management, sales pipelines, marketing automation, customer service, and reporting into a single connected system. For software teams, it exposes a comprehensive REST API and webhook system that makes it possible to integrate any product with HubSpot's data layer.
Core HubSpot modules available via API:
- Contacts: individual people records with properties, activity history, and associations
- Companies: organization records linked to contacts with firmographic properties
- Deals: sales opportunities moving through configurable pipeline stages
- Pipelines: stage configurations for deals and tickets with probability and close date tracking
- Activities: calls, emails, meetings, and notes logged against contacts and deals
- Tickets: support request tracking linked to contacts and companies
- Marketing: email sequences, forms, landing pages, and campaign tracking
- Workflows: automated multi-step sequences triggered by contact or deal property changes
Common integration use cases: syncing product sign-ups to HubSpot as new contacts, updating deal stages when a customer upgrades, triggering email sequences when a trial starts, logging support tickets as CRM activities, and building internal dashboards that display HubSpot pipeline data alongside product usage metrics.
Business Benefits
Centralized Customer Data
When your product syncs with HubSpot, every customer touchpoint becomes visible in one place. Sales sees what the customer has done in the product. Support sees the deal history. Marketing sees which customers have not logged in recently. Decisions that previously required data exports and spreadsheets happen in seconds.
Sales Automation and Lead Qualification
Product-qualified leads users who have hit activation milestones, completed a trial, or used a specific feature can be automatically routed to sales with full context attached. A deal is created, the contact is enriched with product behavior data, and the account executive receives a notification before the user has even considered reaching out to sales.
Reduced Manual Data Entry
Every manual CRM update is an opportunity for data to be wrong, delayed, or missing. Automated synchronization from your product to HubSpot means contact records, deal stages, and activity history are always current without anyone typing into a form. Sales teams spend their time selling rather than updating records.
Customer Lifecycle Visibility
A HubSpot integration that covers the full lifecycle from first website visit through onboarding, activation, expansion, and renewal gives leadership teams a complete view of the customer journey. This is the data that supports accurate revenue forecasting, churn prediction, and informed investment in specific lifecycle stages.
Marketing Automation at Scale
HubSpot workflows trigger email sequences, task assignments, and property updates based on contact behavior. When your product sends events to HubSpot, those events can trigger exactly the right communication at exactly the right time a welcome sequence when a user activates, a check-in from customer success when engagement drops, or a renewal prompt when a subscription approaches expiry.
Architecture Overview
A well-designed HubSpot integration never calls the HubSpot API directly from the frontend. The frontend communicates with your backend, which manages HubSpot credentials, enforces rate limits, handles retries, and processes webhooks asynchronously.
flowchart TD
FE["Frontend\nReact / Next.js / Vue"] -- "User actions\n(form submit, upgrade)" --> API["Your Backend API"]
API -- "Create/Update contact\nCreate/Update deal" --> HS["HubSpot API\nv3 REST + OAuth 2.0"]
HS -- "CRM Data Layer\nContacts / Deals / Companies" --> DB_HS[("HubSpot CRM")]
HS -- "Webhooks\n(contact.creation, deal.stageChange)" --> WH["Webhook Handler\n(your backend)"]
WH -- "Queue event" --> Q["Message Queue\nBullMQ / SQS / Redis"]
Q -- "Process event" --> WORKER["Background Worker"]
WORKER -- "Update application state" --> DB[("Your Database")]
API -- "Cache contact lookups" --> CACHE["Redis Cache"]Core architectural rule: your backend owns the HubSpot API credentials and all outbound API calls. Your frontend never touches HubSpot directly. Webhooks from HubSpot are received by your backend, verified, queued, and processed asynchronously never processed synchronously in the webhook handler.
Key Flows
- 1OAuth flow: user or admin authorizes your app in HubSpot; HubSpot redirects to your callback with an authorization code; your backend exchanges the code for access and refresh tokens; tokens are stored encrypted in your database
- 2Sync flow: product event (sign-up, upgrade, login) triggers a background job; job calls HubSpot Contacts or Deals API to create or update a record; response is logged; errors are retried with exponential backoff
- 3Webhook flow: HubSpot POSTs an event to your endpoint; your handler verifies the signature; event is enqueued immediately (respond 200 within 3 seconds); worker processes the event and updates your application state
- 4Data mapping flow: your product's data model is translated into HubSpot properties on every sync; a mapping configuration defines which fields correspond, with transformation rules for type mismatches
Frontend Integration
The frontend's role in a HubSpot integration is limited: capture user input, send it to your backend API, and display results. No HubSpot credentials belong in the frontend. All CRM interactions go through your own API layer.
React
React handles lead capture forms and post-action CRM updates by calling your backend API routes. Use a custom hook to abstract the contact creation call and handle loading and error states cleanly.
import { useState } from "react";
interface ContactPayload {
email: string;
firstName: string;
lastName: string;
company?: string;
}
export function useHubSpotContact() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function createContact(data: ContactPayload) {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/crm/contacts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error(await res.text());
return await res.json();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setLoading(false);
}
}
return { createContact, loading, error };
}Best practice: debounce form submission, deduplicate by email on the backend before calling HubSpot, and return your internal contact ID (not the HubSpot ID) to the frontend. Keep HubSpot IDs server-side.
Next.js
Next.js server actions and route handlers are the cleanest pattern for HubSpot integration. A server action can call HubSpot directly from a form submit without a separate API layer, while a route handler works for client-side triggered events.
"use server";
import { Client } from "@hubspot/api-client";
const hs = new Client({ accessToken: process.env.HUBSPOT_ACCESS_TOKEN });
export async function createHubSpotContact(formData: FormData) {
const email = formData.get("email") as string;
const firstname = formData.get("firstName") as string;
const lastname = formData.get("lastName") as string;
const existing = await hs.crm.contacts.searchApi.doSearch({
filterGroups: [{
filters: [{ propertyName: "email", operator: "EQ", value: email }],
}],
properties: ["email", "firstname"],
limit: 1,
after: 0,
sorts: [],
});
if (existing.total > 0) return { id: existing.results[0].id, created: false };
const contact = await hs.crm.contacts.basicApi.create({
properties: { email, firstname, lastname },
associations: [],
});
return { id: contact.id, created: true };
}Vue.js
In Vue 3, a composable abstracts the HubSpot backend call and exposes reactive state to any component that needs it.
import { ref } from "vue";
export function useHubSpot() {
const loading = ref(false);
const error = ref<string | null>(null);
async function syncContact(payload: {
email: string;
firstName: string;
lastName: string;
}) {
loading.value = true;
error.value = null;
try {
const res = await fetch("/api/crm/contacts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error("CRM sync failed");
return await res.json();
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : "Error";
} finally {
loading.value = false;
}
}
return { syncContact, loading, error };
}Angular
Angular's dependency injection makes CRM integration clean at scale. Create an injectable CrmService that wraps all HubSpot-related backend calls, and inject it into any component that needs it. Use RxJS operators for retry logic and error handling.
import { Injectable } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { catchError, retry, throwError } from "rxjs";
@Injectable({ providedIn: "root" })
export class CrmService {
constructor(private http: HttpClient) {}
createContact(payload: { email: string; firstName: string; lastName: string }) {
return this.http
.post("/api/crm/contacts", payload)
.pipe(retry(2), catchError((err) => throwError(() => err)));
}
updateDeal(dealId: string, stage: string) {
return this.http
.patch(`/api/crm/deals/${dealId}`, { stage })
.pipe(retry(2), catchError((err) => throwError(() => err)));
}
}Svelte
Svelte form actions (SvelteKit) handle HubSpot lead capture elegantly. A form action runs server-side, meaning HubSpot credentials never touch the browser, and the response is handled with progressive enhancement.
import type { Actions } from "./$types";
export const actions: Actions = {
default: async ({ request }) => {
const data = await request.formData();
await fetch("https://your-api/crm/contacts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: data.get("email"),
firstName: data.get("firstName"),
}),
});
return { success: true };
},
};Solid.js
Solid.js createSignal and createResource compose cleanly for CRM calls. Use createResource for data fetching and a signal-triggered refetch when a user action should sync a contact.
Qwik
Qwik's resumability means CRM-related form actions load zero JavaScript until the user interacts. Use routeAction$ for server-side HubSpot calls so the entire CRM interaction path runs server-side without a client bundle cost.
Astro
Astro is a natural fit for HubSpot-connected marketing pages. API endpoints in Astro handle form submissions server-side, create or update HubSpot contacts, and redirect to a confirmation page. The entire flow runs at the edge without client JavaScript.
import type { APIRoute } from "astro";
export const POST: APIRoute = async ({ request }) => {
const body = await request.json();
await fetch("https://your-api/crm/contacts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return new Response(JSON.stringify({ success: true }), { status: 200 });
};Backend Integration
All HubSpot API calls originate from your backend. The official HubSpot Node.js SDK (@hubspot/api-client) is the most comprehensive option for JavaScript-based stacks. For other languages, the HubSpot v3 REST API is simple enough to call with any HTTP client.
Node.js
npm install @hubspot/api-clientimport { Client } from "@hubspot/api-client";
const client = new Client({
accessToken: process.env.HUBSPOT_ACCESS_TOKEN,
numberOfApiCallRetries: 3,
});
export async function upsertContact(email: string, props: Record<string, string>) {
try {
const search = await client.crm.contacts.searchApi.doSearch({
filterGroups: [{
filters: [{ propertyName: "email", operator: "EQ", value: email }],
}],
properties: ["email", "hs_object_id"],
limit: 1,
after: 0,
sorts: [],
});
if (search.total > 0) {
return client.crm.contacts.basicApi.update(
search.results[0].id,
{ properties: props, associations: [] }
);
}
return client.crm.contacts.basicApi.create({
properties: { email, ...props },
associations: [],
});
} catch (err: unknown) {
console.error("HubSpot upsertContact failed", err);
throw err;
}
}NestJS
Wrap the HubSpot client in an injectable NestJS service. Register it in your module and inject it into any service that needs CRM access. Use ConfigService for credential injection and add request logging via an interceptor.
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Client } from "@hubspot/api-client";
@Injectable()
export class HubSpotService implements OnModuleInit {
private client: Client;
private readonly logger = new Logger(HubSpotService.name);
constructor(private config: ConfigService) {}
onModuleInit() {
this.client = new Client({
accessToken: this.config.get<string>("HUBSPOT_ACCESS_TOKEN"),
numberOfApiCallRetries: 3,
});
}
async createContact(email: string, properties: Record<string, string>) {
this.logger.log(`Creating HubSpot contact: ${email}`);
return this.client.crm.contacts.basicApi.create({
properties: { email, ...properties },
associations: [],
});
}
async updateDealStage(dealId: string, stage: string) {
this.logger.log(`Updating deal ${dealId} to stage ${stage}`);
return this.client.crm.deals.basicApi.update(dealId, {
properties: { dealstage: stage },
associations: [],
});
}
}Python FastAPI
pip install httpximport httpx
import os
from typing import Optional
HUBSPOT_BASE = "https://api.hubapi.com"
TOKEN = os.getenv("HUBSPOT_ACCESS_TOKEN")
async def upsert_contact(email: str, properties: dict) -> dict:
headers = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}
async with httpx.AsyncClient(timeout=10.0) as client:
# Search for existing contact
search_res = await client.post(
f"{HUBSPOT_BASE}/crm/v3/objects/contacts/search",
headers=headers,
json={"filterGroups": [{"filters": [
{"propertyName": "email", "operator": "EQ", "value": email}
]}], "properties": ["email"], "limit": 1},
)
search_res.raise_for_status()
results = search_res.json().get("results", [])
if results:
contact_id = results[0]["id"]
res = await client.patch(
f"{HUBSPOT_BASE}/crm/v3/objects/contacts/{contact_id}",
headers=headers,
json={"properties": properties},
)
else:
res = await client.post(
f"{HUBSPOT_BASE}/crm/v3/objects/contacts",
headers=headers,
json={"properties": {"email": email, **properties}},
)
res.raise_for_status()
return res.json()Laravel
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class HubSpotService
{
private string $baseUrl = 'https://api.hubapi.com';
private string $token;
public function __construct()
{
$this->token = config('services.hubspot.token');
}
public function upsertContact(string $email, array $properties): array
{
$search = Http::withToken($this->token)
->post("{$this->baseUrl}/crm/v3/objects/contacts/search", [
'filterGroups' => [[
'filters' => [['propertyName' => 'email', 'operator' => 'EQ', 'value' => $email]],
]],
'properties' => ['email'],
'limit' => 1,
])->throw()->json();
if (!empty($search['results'])) {
$id = $search['results'][0]['id'];
return Http::withToken($this->token)
->patch("{$this->baseUrl}/crm/v3/objects/contacts/{$id}", ['properties' => $properties])
->throw()->json();
}
return Http::withToken($this->token)
->post("{$this->baseUrl}/crm/v3/objects/contacts", [
'properties' => array_merge(['email' => $email], $properties),
])->throw()->json();
}
}Django
import requests
import os
BASE = "https://api.hubapi.com"
HEADERS = {
"Authorization": f"Bearer {os.getenv('HUBSPOT_ACCESS_TOKEN')}",
"Content-Type": "application/json",
}
def upsert_contact(email: str, properties: dict) -> dict:
search = requests.post(
f"{BASE}/crm/v3/objects/contacts/search",
headers=HEADERS,
json={"filterGroups": [{"filters": [
{"propertyName": "email", "operator": "EQ", "value": email}
]}], "properties": ["email"], "limit": 1},
)
search.raise_for_status()
results = search.json().get("results", [])
if results:
contact_id = results[0]["id"]
res = requests.patch(
f"{BASE}/crm/v3/objects/contacts/{contact_id}",
headers=HEADERS,
json={"properties": properties},
)
else:
res = requests.post(
f"{BASE}/crm/v3/objects/contacts",
headers=HEADERS,
json={"properties": {"email": email, **properties}},
)
res.raise_for_status()
return res.json()Go
package hubspot
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
const baseURL = "https://api.hubapi.com"
func authHeader() string {
return "Bearer " + os.Getenv("HUBSPOT_ACCESS_TOKEN")
}
func CreateContact(email string, properties map[string]string) error {
props := map[string]interface{}{"email": email}
for k, v := range properties {
props[k] = v
}
body, _ := json.Marshal(map[string]interface{}{"properties": props})
req, _ := http.NewRequest("POST", baseURL+"/crm/v3/objects/contacts", bytes.NewBuffer(body))
req.Header.Set("Authorization", authHeader())
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("hubspot error: %d", resp.StatusCode)
}
return nil
}Spring Boot
Use Spring's WebClient (reactive) or RestTemplate for HubSpot API calls. Create a HubSpotClient bean configured with the base URL and a default Authorization header. Wrap all calls in a service class with retry logic using Spring Retry.
@Service
public class HubSpotService {
private final WebClient webClient;
public HubSpotService(@Value("${hubspot.access-token}") String token) {
this.webClient = WebClient.builder()
.baseUrl("https://api.hubapi.com")
.defaultHeader("Authorization", "Bearer " + token)
.defaultHeader("Content-Type", "application/json")
.build();
}
public Mono<Map> createContact(String email, Map<String, String> properties) {
Map<String, Object> props = new HashMap<>(properties);
props.put("email", email);
return webClient.post()
.uri("/crm/v3/objects/contacts")
.bodyValue(Map.of("properties", props))
.retrieve()
.onStatus(HttpStatusCode::isError, res ->
Mono.error(new RuntimeException("HubSpot error: " + res.statusCode())))
.bodyToMono(Map.class);
}
}ASP.NET Core
Register a typed HttpClient for HubSpot in Program.cs with the base address and Authorization header preconfigured. Inject it into any service via dependency injection. Use Polly for retry and circuit breaker policies.
public class HubSpotService
{
private readonly HttpClient _http;
private readonly ILogger<HubSpotService> _logger;
public HubSpotService(HttpClient http, ILogger<HubSpotService> logger)
{
_http = http;
_logger = logger;
}
public async Task<JsonElement> CreateContactAsync(string email, Dictionary<string, string> properties)
{
properties["email"] = email;
var payload = new { properties };
var res = await _http.PostAsJsonAsync("/crm/v3/objects/contacts", payload);
res.EnsureSuccessStatusCode();
return await res.Content.ReadFromJsonAsync<JsonElement>();
}
}Rust
Use the reqwest crate for async HTTP calls to the HubSpot API. Serialize payloads with serde_json and deserialize responses into typed structs. Add retry logic with the tokio-retry crate for production resilience.
use reqwest::Client;
use serde_json::{json, Value};
use std::collections::HashMap;
pub async fn create_contact(
email: &str,
properties: HashMap<&str, &str>,
token: &str,
) -> Result<Value, reqwest::Error> {
let mut props = json!({ "email": email });
for (k, v) in &properties {
props[k] = json!(v);
}
let client = Client::new();
let res = client
.post("https://api.hubapi.com/crm/v3/objects/contacts")
.bearer_auth(token)
.json(&json!({ "properties": props }))
.send()
.await?
.error_for_status()?
.json::<Value>()
.await?;
Ok(res)
}Authentication and Security
HubSpot supports two authentication methods for integrations: Private App tokens (for single-portal integrations) and OAuth 2.0 (for multi-portal or public app integrations).
Private App Access Tokens
For most SaaS integrations connecting to a single HubSpot portal, Private App tokens are the correct choice. Create a Private App in HubSpot settings, select the required scopes (crm.objects.contacts.write, crm.objects.deals.write, etc.), and copy the generated token. Store it in your environment variables and never commit it to source control.
Private App tokens do not expire, which means a leaked token gives permanent access until manually rotated. Store them in a secrets manager (AWS Secrets Manager, Doppler, Vault), not in .env files that might be accidentally committed. Rotate tokens quarterly as a baseline hygiene practice.
OAuth 2.0 for Multi-Portal Apps
If your integration needs to connect to multiple customer HubSpot portals (as a marketplace app), implement the full OAuth 2.0 authorization code flow. Redirect the user to HubSpot's authorization endpoint, receive the code at your callback URL, exchange it for access and refresh tokens, and store both encrypted in your database.
import { Client } from "@hubspot/api-client";
import { encrypt, decrypt } from "../lib/crypto";
const hs = new Client({});
// Step 1: Build authorization URL
export function getAuthUrl(state: string) {
return hs.oauth.getAuthorizationUrl(
process.env.HUBSPOT_CLIENT_ID!,
process.env.HUBSPOT_REDIRECT_URI!,
"crm.objects.contacts.write crm.objects.deals.write",
state
);
}
// Step 2: Exchange code for tokens (in callback handler)
export async function handleOAuthCallback(code: string, accountId: string) {
const tokens = await hs.oauth.tokensApi.createToken(
"authorization_code",
code,
process.env.HUBSPOT_REDIRECT_URI!,
process.env.HUBSPOT_CLIENT_ID!,
process.env.HUBSPOT_CLIENT_SECRET!
);
// Store encrypted tokens in your database
await db.hubspotTokens.upsert({
where: { accountId },
update: {
accessToken: encrypt(tokens.accessToken),
refreshToken: encrypt(tokens.refreshToken!),
expiresAt: new Date(Date.now() + tokens.expiresIn * 1000),
},
create: {
accountId,
accessToken: encrypt(tokens.accessToken),
refreshToken: encrypt(tokens.refreshToken!),
expiresAt: new Date(Date.now() + tokens.expiresIn * 1000),
},
});
}
// Step 3: Auto-refresh before API calls
export async function getClientForAccount(accountId: string) {
const record = await db.hubspotTokens.findUnique({ where: { accountId } });
if (!record) throw new Error("No HubSpot token for account");
if (new Date() >= record.expiresAt) {
const refreshed = await hs.oauth.tokensApi.createToken(
"refresh_token",
undefined,
undefined,
process.env.HUBSPOT_CLIENT_ID!,
process.env.HUBSPOT_CLIENT_SECRET!,
decrypt(record.refreshToken)
);
await db.hubspotTokens.update({
where: { accountId },
data: {
accessToken: encrypt(refreshed.accessToken),
expiresAt: new Date(Date.now() + refreshed.expiresIn * 1000),
},
});
return new Client({ accessToken: refreshed.accessToken });
}
return new Client({ accessToken: decrypt(record.accessToken) });
}Webhooks and Event Processing
HubSpot webhooks deliver real-time events to your application when contacts, deals, or companies are created or updated in HubSpot. This is the correct way to keep your application state synchronized with HubSpot not polling.
Supported Webhook Events
- contact.creation: new contact created in HubSpot
- contact.deletion: contact deleted or merged
- contact.propertyChange: any contact property updated (subscribe per property)
- deal.creation: new deal created
- deal.deletion: deal deleted
- deal.propertyChange: deal property changed (use for stage change tracking)
- company.creation / company.propertyChange: company record events
Webhook Signature Verification
HubSpot signs all webhook payloads with your app's client secret. Always verify the signature before processing. An unverified webhook endpoint is a security vulnerability that allows arbitrary actors to inject events into your system.
import crypto from "crypto";
import { Request, Response } from "express";
function verifyHubSpotSignature(req: Request): boolean {
const signature = req.headers["x-hubspot-signature-v3"] as string;
const timestamp = req.headers["x-hubspot-request-timestamp"] as string;
if (!signature || !timestamp) return false;
// Reject requests older than 5 minutes
if (Math.abs(Date.now() - parseInt(timestamp)) > 300_000) return false;
const rawBody = JSON.stringify(req.body);
const expected = crypto
.createHmac("sha256", process.env.HUBSPOT_CLIENT_SECRET!)
.update(req.method + req.originalUrl + rawBody + timestamp)
.digest("base64");
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
export async function hubspotWebhookHandler(req: Request, res: Response) {
if (!verifyHubSpotSignature(req)) {
return res.status(401).json({ error: "Invalid signature" });
}
// Respond immediately process asynchronously
res.status(200).json({ received: true });
const events: Array<{ subscriptionType: string; objectId: number; propertyName?: string; propertyValue?: string }> = req.body;
for (const event of events) {
await queue.add("hubspot-event", event, {
attempts: 5,
backoff: { type: "exponential", delay: 2000 },
});
}
}HubSpot retries failed webhooks up to 10 times over 24 hours. Always implement idempotency in your event processor using the event's objectId and subscriptionType as the deduplication key. Without idempotency, retries will process the same event multiple times.
Queue-Based Event Processing
import { Worker, Job } from "bullmq";
import { redis } from "../lib/redis";
const worker = new Worker(
"hubspot-event",
async (job: Job) => {
const { subscriptionType, objectId, propertyName, propertyValue } = job.data;
// Idempotency check
const key = `hs-event:${subscriptionType}:${objectId}:${job.id}`;
const processed = await redis.get(key);
if (processed) return;
switch (subscriptionType) {
case "contact.creation":
await handleContactCreated(objectId);
break;
case "deal.propertyChange":
if (propertyName === "dealstage") {
await handleDealStageChange(objectId, propertyValue!);
}
break;
}
await redis.set(key, "1", "EX", 86400); // TTL 24h
},
{ connection: redis, concurrency: 10 }
);
worker.on("failed", (job, err) => {
console.error(`HubSpot event job ${job?.id} failed:`, err.message);
});Automation Workflows
Workflow 1: Website Lead Capture to Sales Notification
- 1User submits contact form on your marketing website
- 2Frontend POSTs to your backend API /api/leads
- 3Backend creates HubSpot contact with source property set to 'website'
- 4Backend creates a HubSpot deal in the New Leads pipeline stage associated with the contact
- 5HubSpot Workflow triggers: contact.creation where source = website fires an internal email notification to the assigned sales rep
- 6Sales rep sees the lead in HubSpot with full context and the deal already created
Workflow 2: Trial Signup to CRM and Email Sequence
- 1User completes trial signup in your product
- 2Your backend upserts the HubSpot contact with lifecycle stage set to 'lead' and a custom property trial_started_at
- 3HubSpot Workflow triggers: contact.propertyChange where trial_started_at is known triggers a 5-email onboarding sequence
- 4When the user activates a key feature, your backend updates the HubSpot contact lifecycle stage to 'salesqualifiedlead'
- 5HubSpot Workflow creates a task for the assigned rep to follow up within 24 hours
Workflow 3: Customer Upgrade to Deal and Revenue Tracking
import { upsertContact, createDeal, updateDeal } from "../lib/hubspot";
export async function handleCustomerUpgrade(userId: string, newPlan: string, mrr: number) {
const user = await db.users.findUnique({ where: { id: userId } });
// Update contact lifecycle stage
await upsertContact(user.email, {
lifecyclestage: "customer",
current_plan: newPlan,
mrr: String(mrr),
});
// Check for existing deal
const existingDeal = await db.hubspotDeals.findFirst({ where: { userId } });
if (existingDeal) {
await updateDeal(existingDeal.hubspotId, {
dealstage: "closedwon",
amount: String(mrr * 12),
closedate: String(Date.now()),
});
} else {
const deal = await createDeal({
dealname: `${user.email} ${newPlan} upgrade`,
pipeline: "default",
dealstage: "closedwon",
amount: String(mrr * 12),
closedate: String(Date.now()),
});
await db.hubspotDeals.create({ data: { userId, hubspotId: deal.id } });
}
}Workflow 4: Support Ticket to CRM Activity
When a user submits a support ticket in your product, log it as a HubSpot Note activity associated with their contact. This gives the sales team visibility into support interactions when reviewing an account before a call, without requiring them to switch to a separate support tool.
import { Client } from "@hubspot/api-client";
const hs = new Client({ accessToken: process.env.HUBSPOT_ACCESS_TOKEN });
export async function logTicketAsCrmActivity(
contactId: string,
ticketSubject: string,
ticketBody: string
) {
await hs.crm.objects.notes.basicApi.create({
properties: {
hs_note_body: `Support ticket: ${ticketSubject}\n\n${ticketBody}`,
hs_timestamp: String(Date.now()),
},
associations: [{
to: { id: contactId },
types: [{ associationCategory: "HUBSPOT_DEFINED", associationTypeId: 202 }],
}],
});
}Production Deployment
Rate Limits
HubSpot API rate limits depend on your plan tier. The standard limit is 100 requests per 10 seconds per portal. Burst traffic can exhaust this quickly if you are syncing large data sets. Implement a rate limiter in your integration layer using token bucket or leaky bucket algorithm, and use HubSpot's batch endpoints (contacts batch create, batch update) to reduce request count for bulk operations.
HubSpot returns HTTP 429 when rate limits are exceeded, with a Retry-After header indicating when to retry. Log every 429 response and implement exponential backoff. Do not retry immediately a tight retry loop will keep you rate-limited.
Caching
Cache HubSpot contact lookups by email in Redis with a 5-minute TTL. For most workflows, the contact already exists and you need only the HubSpot ID to create associations. A cache hit eliminates a search API call entirely, which significantly reduces rate limit consumption for high-volume integrations.
Monitoring and Observability
- Track HubSpot API call volume, error rate, and P95 latency as metrics in your monitoring stack
- Log every outbound API call with the endpoint, response status, and duration
- Alert when error rate on HubSpot API calls exceeds 2% over a 5-minute window
- Monitor queue depth for the HubSpot event worker a growing queue indicates the worker cannot keep up with webhook volume
- Set up a dead-letter queue for events that fail after all retry attempts; review it daily
Common Challenges
Challenge 1: Duplicate Contacts
HubSpot deduplicates contacts by email by default, but race conditions in high-volume sign-up flows can create duplicates if two requests for the same email arrive simultaneously. Solution: implement an upsert pattern that searches before creating, adds an idempotency key to your contact creation call, and uses a distributed lock (Redis) around the search-then-create sequence for critical flows.
Challenge 2: Data Synchronization Drift
Over time, the contact properties in your database and in HubSpot diverge. Sales reps update HubSpot manually. Your product updates your database. Neither side knows about the other's changes. Solution: treat HubSpot as the system of record for CRM properties and your database as the system of record for product data. Use webhooks to receive HubSpot changes and sync them back to your database. Run a daily reconciliation job for high-value contacts to detect and resolve drift.
Challenge 3: Webhook Delivery Failures
HubSpot retries failed webhooks, but if your endpoint is consistently unavailable for more than 24 hours, events are permanently lost. Solution: deploy your webhook endpoint with high availability (multiple instances behind a load balancer), process webhooks asynchronously (respond 200 immediately, queue for processing), and implement a daily polling job that fetches recent HubSpot activity for your most important contacts as a fallback.
Challenge 4: Rate Limit Exhaustion
A bulk import, a missed batch endpoint, or a retry storm can exhaust your rate limit quickly. Solution: use HubSpot's batch endpoints for operations involving more than 10 contacts, implement a rate limiter that tracks requests per 10-second window, and move bulk sync operations to scheduled background jobs that run during low-traffic periods.
Challenge 5: Field Mapping Mismatches
HubSpot has strict property type validation. Sending a string to a number property or a value outside a defined enumeration's allowed list returns a 400 error. Solution: define a typed mapping layer in your integration that validates and transforms data before sending it to HubSpot. Store the mapping configuration in a central location and version it. Log every 400 validation error with the full payload so mismatches are visible immediately.
Challenge 6: OAuth Token Expiration
HubSpot OAuth access tokens expire after 30 minutes. An integration that does not handle refresh correctly will fail for any account whose token has expired. Solution: implement automatic token refresh before every API call by checking the stored expiry timestamp and refreshing proactively if expiry is within 60 seconds. Store both access and refresh tokens encrypted and update both on every refresh.
Best Practices
- 1Always use webhooks for inbound HubSpot events never poll the API for changes on a schedule
- 2Respond to webhooks within 3 seconds with a 200 status and process the event asynchronously via a queue
- 3Verify every webhook signature using HMAC-SHA256 before processing any event
- 4Implement idempotency in your event processor using objectId and subscriptionType as the deduplication key
- 5Store HubSpot OAuth tokens encrypted at rest; never store them in plain text
- 6Use HubSpot batch endpoints (batch create, batch update) for any operation involving more than 10 records
- 7Cache HubSpot contact ID lookups in Redis with a short TTL to reduce API call volume
- 8Track HubSpot API call count, error rate, and latency in your monitoring stack
- 9Set alerts for HubSpot API error rate above 2% and for dead-letter queue depth above zero
- 10Define a typed field mapping layer between your data model and HubSpot properties
- 11Log every outbound HubSpot API call with endpoint, status code, duration, and any error details
- 12Run a daily reconciliation job for high-value contacts to detect and resolve property drift between your database and HubSpot
- 13Use HubSpot Private App tokens for single-portal integrations; implement the full OAuth flow only when connecting to multiple customer portals
- 14Rotate Private App tokens quarterly and store them in a secrets manager rather than environment variable files
- 15Keep HubSpot-specific logic isolated in a dedicated service class or module never scatter HubSpot calls across business logic layers
Need help implementing a HubSpot integration, building CRM synchronization systems, automating workflows, or designing a scalable integration architecture? Nurture Technologies specializes in connecting HubSpot with custom software products, automating CRM workflows end-to-end, and building integration layers that are reliable, observable, and maintainable at scale. Talk to us about your CRM integration.