Nurture TechnologiesNurture Tech
Integrations22 min read·July 25, 2026

HubSpot CRM Integration GuideContacts, Deals, Workflows, Webhooks, and Automation

HubSpotReactNext.jsVueAngularSvelteAstroNode.jsNestJSFastAPILaravelDjangoGoSpring BootASP.NET CoreRust

A complete HubSpot CRM integration guide covering contacts, deals, webhooks, OAuth 2.0, and workflow automation across every major frontend and backend stack.

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.

hubspot-architecture.mmd
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

  1. 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
  2. 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
  3. 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
  4. 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.

hooks/useHubSpotContact.ts
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.

app/actions/hubspot.ts
"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.

composables/useHubSpot.ts
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.

services/crm.service.ts
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.

+page.server.ts
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.

pages/api/contact.ts
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-client
lib/hubspot.ts
import { 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.

crm/hubspot.service.ts
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 httpx
services/hubspot.py
import 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

app/Services/HubSpotService.php
<?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

services/hubspot.py
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

hubspot/client.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.

HubSpotService.java
@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.

Services/HubSpotService.cs
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.

hubspot/client.rs
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.

auth/hubspot-oauth.ts
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.

webhooks/hubspot.ts
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

workers/hubspot-event.worker.ts
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

  1. 1User submits contact form on your marketing website
  2. 2Frontend POSTs to your backend API /api/leads
  3. 3Backend creates HubSpot contact with source property set to 'website'
  4. 4Backend creates a HubSpot deal in the New Leads pipeline stage associated with the contact
  5. 5HubSpot Workflow triggers: contact.creation where source = website fires an internal email notification to the assigned sales rep
  6. 6Sales rep sees the lead in HubSpot with full context and the deal already created

Workflow 2: Trial Signup to CRM and Email Sequence

  1. 1User completes trial signup in your product
  2. 2Your backend upserts the HubSpot contact with lifecycle stage set to 'lead' and a custom property trial_started_at
  3. 3HubSpot Workflow triggers: contact.propertyChange where trial_started_at is known triggers a 5-email onboarding sequence
  4. 4When the user activates a key feature, your backend updates the HubSpot contact lifecycle stage to 'salesqualifiedlead'
  5. 5HubSpot Workflow creates a task for the assigned rep to follow up within 24 hours

Workflow 3: Customer Upgrade to Deal and Revenue Tracking

events/customer-upgraded.ts
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.

events/support-ticket.ts
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

  1. 1Always use webhooks for inbound HubSpot events never poll the API for changes on a schedule
  2. 2Respond to webhooks within 3 seconds with a 200 status and process the event asynchronously via a queue
  3. 3Verify every webhook signature using HMAC-SHA256 before processing any event
  4. 4Implement idempotency in your event processor using objectId and subscriptionType as the deduplication key
  5. 5Store HubSpot OAuth tokens encrypted at rest; never store them in plain text
  6. 6Use HubSpot batch endpoints (batch create, batch update) for any operation involving more than 10 records
  7. 7Cache HubSpot contact ID lookups in Redis with a short TTL to reduce API call volume
  8. 8Track HubSpot API call count, error rate, and latency in your monitoring stack
  9. 9Set alerts for HubSpot API error rate above 2% and for dead-letter queue depth above zero
  10. 10Define a typed field mapping layer between your data model and HubSpot properties
  11. 11Log every outbound HubSpot API call with endpoint, status code, duration, and any error details
  12. 12Run a daily reconciliation job for high-value contacts to detect and resolve property drift between your database and HubSpot
  13. 13Use HubSpot Private App tokens for single-portal integrations; implement the full OAuth flow only when connecting to multiple customer portals
  14. 14Rotate Private App tokens quarterly and store them in a secrets manager rather than environment variable files
  15. 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.

Nurture Technologies

NEED HELP WITH YOUR STACK?

Nurture Technologies builds and maintains production-quality software for startups and businesses. If engineering problems are slowing you down, our team can help.

Talk to our team →
FAQ

FREQUENTLY ASKED QUESTIONS

What is HubSpot CRM and what does it provide?+

HubSpot CRM is a cloud-based customer relationship management platform that centralizes contact management, sales pipelines, marketing automation, customer service, and reporting. It provides a REST API, webhook system, and workflow automation engine that software teams use to sync product data with sales and marketing systems, automate customer lifecycle communications, and give revenue teams visibility into customer behavior.

How does the HubSpot API work?+

The HubSpot API is a REST API versioned at v3 for CRM objects. It uses JSON for request and response bodies and supports contacts, companies, deals, tickets, activities, and associations between them. Authentication uses either Private App access tokens (for single-portal integrations) or OAuth 2.0 access tokens (for multi-portal apps). The API enforces rate limits of 100 requests per 10 seconds by default, with batch endpoints available for bulk operations.

How do HubSpot webhooks work?+

HubSpot webhooks send HTTP POST requests to your specified endpoint URL when contact, deal, or company events occur in HubSpot. Each request contains an array of event objects with the event type, object ID, and changed properties. HubSpot signs requests with HMAC-SHA256 using your app's client secret and retries failed deliveries up to 10 times over 24 hours. Always verify the signature and respond within 3 seconds by returning 200 immediately and processing asynchronously.

What are HubSpot API rate limits?+

The standard HubSpot API rate limit is 100 requests per 10 seconds per portal for Private Apps. Higher limits are available on Enterprise plans. HubSpot returns HTTP 429 with a Retry-After header when the limit is exceeded. Use batch endpoints to reduce request count for bulk operations, implement exponential backoff for retries, and cache contact ID lookups to avoid repeated search calls.

How do I integrate HubSpot with a SaaS application?+

The standard pattern: your backend holds the HubSpot credentials and makes all API calls. Your frontend calls your backend API, which calls HubSpot. Product events (sign-up, upgrade, activation) trigger background jobs that create or update HubSpot contacts and deals. HubSpot webhooks notify your backend when CRM data changes, which your webhook handler queues for asynchronous processing. Never call HubSpot directly from the frontend or expose API tokens to the browser.

Which backend frameworks work best with HubSpot?+

Node.js and NestJS have the best first-party support through HubSpot's official @hubspot/api-client SDK, which provides typed methods for all API endpoints. Python (FastAPI, Django), Laravel, Go, Spring Boot, ASP.NET Core, and Rust all work well using direct HTTP calls to HubSpot's REST API via their respective HTTP client libraries. The choice of framework matters less than the architectural decision to centralize all HubSpot logic in a dedicated service class.

What is the difference between HubSpot Private App tokens and OAuth 2.0?+

Private App tokens are for integrations that connect to a single HubSpot portal your company's own. They are simpler to implement, do not expire, and are the right choice for most SaaS integrations. OAuth 2.0 is for public apps or integrations that need to connect to multiple customers' HubSpot portals, requiring each customer to authorize your app. OAuth tokens expire after 30 minutes and must be refreshed using the stored refresh token.

How do I prevent duplicate contacts in HubSpot?+

Implement an upsert pattern that searches for the contact by email before creating. For high-volume flows where race conditions are possible, use a distributed lock (Redis) around the search-then-create operation for the same email address. HubSpot's own deduplication catches most cases, but concurrent requests for the same email can still create duplicates before HubSpot's deduplication runs.

How do I handle HubSpot API errors and retries?+

For 429 (rate limit exceeded): read the Retry-After header and wait the specified duration before retrying. For 5xx errors: retry with exponential backoff (start at 1 second, double each attempt, cap at 60 seconds, maximum 5 attempts). For 400 errors: log the full request payload and error details these are usually field mapping or validation problems that require a code fix, not a retry. The HubSpot Node.js SDK has built-in retry logic for 429 and 5xx errors when numberOfApiCallRetries is configured.

How do I sync data from my product to HubSpot without polling?+

Use event-driven synchronization. When a product event occurs (user signs up, upgrades, activates a feature), emit an internal event or enqueue a background job that calls the HubSpot API to create or update the relevant contact or deal. This push-based approach is real-time, eliminates unnecessary API calls, and scales efficiently because jobs only run when there is something to sync.

How do I map my product's data model to HubSpot properties?+

Create a typed mapping layer that translates your internal data model to HubSpot property names and values. HubSpot has standard properties for contacts (firstname, lastname, email, phone, company, lifecyclestage) and deals (dealname, amount, closedate, dealstage). Create custom HubSpot properties for product-specific data like plan tier, MRR, trial start date, and activation status. Store the mapping configuration centrally and validate property types before sending to avoid 400 errors.

What HubSpot scopes do I need for a standard SaaS integration?+

For a standard SaaS CRM integration: crm.objects.contacts.read and crm.objects.contacts.write for contact management, crm.objects.deals.read and crm.objects.deals.write for deal tracking, crm.objects.companies.read and crm.objects.companies.write for company association, crm.objects.notes.write for logging activities, and oauth for the authorization flow. Add additional scopes only as needed HubSpot shows users the requested scopes during authorization.

How do I test a HubSpot integration in development?+

Create a free HubSpot developer account and a test portal it has all API features enabled at no cost. Use sandbox portals for integration testing to avoid affecting production CRM data. For webhook testing locally, use ngrok or a similar tunnel to expose your local server to the internet and register the tunnel URL as your webhook endpoint in HubSpot settings. Rotate credentials when the tunnel is closed to prevent stale webhook registrations.

How do I handle webhook idempotency in HubSpot?+

HubSpot retries failed webhooks, so the same event can arrive multiple times. Use a combination of the event's objectId, subscriptionType, and a timestamp window to deduplicate. On receipt, check whether you have already processed this event by looking up a Redis key composed of these values. If the key exists, skip processing. If not, process the event and set the key with a TTL of 24 hours (matching HubSpot's maximum retry window).

What HubSpot workflow automation is possible via API?+

Via the API you can trigger HubSpot-native workflows by updating contact or deal properties that those workflows are configured to respond to. You can also create and update contacts, deals, and activities directly, which native HubSpot workflows treat as events. For full workflow management via API (creating and modifying workflows programmatically), the Automation API is available but typically used only for advanced use cases. Most SaaS integrations use a hybrid: the API for data sync, HubSpot's native workflow builder for automation logic.

How do I monitor a HubSpot integration in production?+

Track four key signals: API success rate (alert below 98%), webhook processing queue depth (alert when growing for more than 5 minutes), dead-letter queue size (alert immediately on any entry), and sync latency (time between a product event and the HubSpot record being updated). Add these as metrics to your existing monitoring stack. Log every outbound API call with duration and status code so you can diagnose failures and identify rate limit patterns.

How do I handle the case where a HubSpot contact already exists when I try to create one?+

HubSpot returns a 409 Conflict error when you try to create a contact with an email that already exists, and the error body contains the ID of the existing contact. Catch this error, extract the existing contact ID from the response, and switch to an update call using that ID. The HubSpot Node.js SDK exposes the existing contact ID as part of the error object. Build this upsert pattern into your createContact function so it handles both new and existing contacts transparently.

What is the HubSpot batch API and when should I use it?+

HubSpot's batch API endpoints allow creating, updating, or reading up to 100 records in a single request. Use them any time you are syncing more than 10 records at once daily sync jobs, bulk imports, or initial data migrations. A batch operation counts as a single API request against your rate limit regardless of how many records it contains, making it dramatically more efficient than individual calls at scale.

How do I associate HubSpot contacts with companies and deals?+

Associations in HubSpot connect CRM records across object types. When creating a contact, pass an associations array specifying the company or deal to associate with and the association type ID (1 for contact-to-company, 4 for contact-to-deal). Alternatively, use the Associations API after creation. Always associate contacts with their company on creation if you know the company it enables account-level reporting in HubSpot without manual configuration.

How do I keep HubSpot data consistent when both my product and sales team can update records?+

Define a clear system-of-record rule for each property type. Product-generated properties (sign-up date, plan tier, feature usage) should be owned by your product and written by your integration prevent sales from editing them in HubSpot to avoid overwrite conflicts. Sales-owned properties (deal stage, notes, associated contacts) should be owned by HubSpot and synced back to your database via webhooks. Run a daily reconciliation job that compares high-value contact properties across both systems and logs discrepancies.