
Still hunting for a HubSpot API key to wire up your HubSpot API integration? Stop looking. HubSpot killed static API keys on November 30, 2022, and the current Node SDK (@hubspot/api-client, now at v14) won't accept one anyway. The right credential for a single-account internal tool is a private app access token, and this guide builds a real HubSpot-to-internal-tool sync in both Node and Python, from your first create contact call to a signature-validated webhook.
Quick answer: A HubSpot API integration lets a custom internal tool read and write CRM data through HubSpot's v3 REST API. For a single-account internal tool, authenticate with a private app access token (HubSpot sunset API keys in 2022), then sync changes in real time with webhooks instead of polling.
Here's what you'll build:
- Private-app-token auth plus your first
create contactcall in Node and Python - A webhook receiver that validates
X-HubSpot-Signature-v3before trusting a payload - A 429-safe, batch-100 sync into an internal ticketing or ERP record
How Does HubSpot API Integration Work for Custom Internal Tools?
A HubSpot API integration connects a custom internal tool (a ticketing app, an ERP, a billing dashboard, a customer portal) to HubSpot's CRM through its v3 REST API. Your tool reads and writes CRM objects (contacts, deals, companies, or custom objects) over HTTPS with a private app access token, and real-time changes flow back in through webhooks.
Think of HubSpot's CRM as a database you talk to over HTTP. Every record is an object with a type and an ID. The hubspot crm api integration you're building does two jobs: it pushes data into HubSpot (create a contact when a ticket opens) and pulls data out of it (read a deal when your internal dashboard renders).
Sync runs in one of two directions. A one-way sync copies changes from HubSpot into your tool, or from your tool into HubSpot. A two-way sync does both and needs loop protection, which we cover later. And instead of asking HubSpot "anything new?" every minute (polling), you register a webhook so HubSpot tells you the instant a record changes.
If you'd rather own your data outright than integrate a hosted CRM at all, self-hosting an open-source CRM is a different path worth weighing before you commit. But if HubSpot is already your source of truth, the API is how everything else talks to it.
For a single-account internal tool, you don't need OAuth or an app-marketplace listing. A private app token and a webhook is the whole integration.
Authentication in 2026: Why There's No HubSpot API Key Anymore
For HubSpot API authentication on a single-account internal tool, use a private app access token. It's a static bearer token you generate once in your HubSpot account, scoped to exactly the objects your tool touches. There is no refresh flow and no expiry. OAuth exists for public, multi-account apps, not for the dashboard your ops team runs internally.
Private App Token vs the Deprecated API Key
Here's the gotcha that trips up half the developers who hit this SERP. HubSpot sunset API keys on November 30, 2022, and they're fully unsupported now. Autocomplete still suggests "hubspot api key" because the muscle memory hasn't caught up, but there is no key to fetch. Reach for a hubspot private app instead: create it in Settings, grant the scopes it needs, and copy the access token from the Auth tab. HubSpot's private apps overview covers the setup.
| Method | Use case | Expires or refresh? | Best for |
|---|---|---|---|
| API key | Removed | Sunset Nov 2022 | Nothing, it is deprecated |
| Private app access token | Single-account internal tool | No, static, no refresh | Your internal tool, the default here |
| OAuth 2.0 | Public or multi-account app | Yes, tokens expire in about 6 hours and need refresh | Apps you list for other companies' portals |
| Service Key (public beta, Feb 2026) | Account-level, data-only credential | Account-scoped, per docs | Data-only server jobs, still beta |
Two rules on the token itself. Grant least privilege: if your tool only reads deals and writes contacts, request crm.objects.contacts.write and crm.objects.deals.read, nothing more. And keep the token in an environment variable or a secret manager, sent in the Authorization: Bearer header, never hard-coded and never shipped to the browser.
The verdict is simple. For an internal tool, use a private app access token. Reach for OAuth only if this later becomes a public, multi-account app that other companies install into their own portals.
Your First HubSpot API Call: Create a Contact in Node and Python
The canonical first call is create contact, and the official SDKs make it a few lines. Install the client, initialize it with your private app token from the environment, then create a contact and read a deal back. This is the same pattern you'll reuse for companies, tickets, and hubspot custom objects api calls, only the object type changes.
Here's the Node version with @hubspot/api-client (v14):
// npm i @hubspot/api-client (v14.x)
import { Client } from "@hubspot/api-client";
// Private app token from a secret manager or env var, never hard-coded
const hubspot = new Client({ accessToken: process.env.HUBSPOT_PRIVATE_APP_TOKEN });
// Create a contact
const { id } = await hubspot.crm.contacts.basicApi.create({
properties: {
email: "[email protected]",
firstname: "Ada",
lastname: "Lovelace",
lifecyclestage: "lead",
},
associations: [],
});
console.log("Created contact", id);
// Read a deal by ID
const deal = await hubspot.crm.deals.basicApi.getById(
"1234567890",
["dealname", "amount", "dealstage"],
);
console.log(deal.properties.dealname, deal.properties.amount);And the same thing in Python with hubspot-api-client (v12):
# pip install hubspot-api-client (v12.x)
import os
from hubspot import HubSpot
from hubspot.crm.contacts import SimplePublicObjectInputForCreate
# Private app token from the environment, not source control
client = HubSpot(access_token=os.environ["HUBSPOT_PRIVATE_APP_TOKEN"])
# Create a contact
contact = client.crm.contacts.basic_api.create(
simple_public_object_input_for_create=SimplePublicObjectInputForCreate(
properties={
"email": "[email protected]",
"firstname": "Ada",
"lastname": "Lovelace",
"lifecyclestage": "lead",
}
)
)
print("Created contact", contact.id)
# Read a deal by ID
deal = client.crm.deals.basic_api.get_by_id(
deal_id="1234567890",
properties=["dealname", "amount", "dealstage"],
)
print(deal.properties["dealname"], deal.properties["amount"])Pro tip: test against a HubSpot developer sandbox, never production first. A malformed create call in production leaves real junk records your sales team has to clean up. The token, scopes, and object model behave identically in the sandbox.
How Do You Sync HubSpot to an Internal Tool in Real Time?
Use webhooks, not polling. Register a webhook subscription in your private app for the object and event you care about (say, deal.propertyChange), point it at an HTTPS endpoint you host, and HubSpot POSTs you a small JSON array the moment a matching change happens. Poll only when no subscription exists for what you need to watch.
The payoff is efficiency. Polling asks "anything new?" every minute and burns your rate limit doing it; a webhook just tells you the moment a deal changes. That difference matters at scale, and webhooks are mainstream now, not exotic: Postman's 2025 State of the API Report, a survey of more than 5,700 developers, found roughly half of teams rely on them.
Register the subscription under your private app's Webhooks tab, set the target URL, and pick the events. HubSpot sends an array of event objects, each carrying the subscriptionType, the objectId, and what changed. Here's a receiver stub in Node with Express:
import express from "express";
const app = express();
// Capture the raw body: you need the exact bytes to validate the signature next
app.use(express.json({
verify: (req, _res, buf) => { req.rawBody = buf.toString("utf8"); },
}));
// HubSpot POSTs an array of events to this URL
app.post("/webhooks/hubspot", (req, res) => {
const events = req.body; // [{ subscriptionType: "deal.propertyChange", objectId: 1234, ... }]
for (const event of events) {
console.log("HubSpot event:", event.subscriptionType, event.objectId);
// Do NOT trust this payload yet. The next section validates it before we act.
}
res.sendStatus(200);
});
app.listen(3000, () => console.log("Listening on :3000"));This is where the build-along gets real. Say you're syncing a deal into a small manufacturer's ERP record: the webhook fires, your handler creates or updates the matching ERP ticket, and your ops team sees the change without touching HubSpot. It's the same real-time approach we use to sync a voice agent to a CRM, only the trigger is a property change instead of a phone call. One warning: that stub above trusts anything POSTed to it. Fix that before you go live.
Validating Webhook Signatures (v3) So You Never Trust a Forged Payload
Validate every incoming webhook with the v3 signature. HubSpot signs each request with your app secret and sends two headers, X-HubSpot-Signature-v3 and X-HubSpot-Request-Timestamp. Reject anything older than 5 minutes, rebuild the source string as method + full URL + raw body + timestamp, HMAC-SHA256 it with the app secret, base64-encode, and compare in constant time.
If you skip this, anyone who guesses your webhook URL can forge a deal update. Validation is not optional. HubSpot's validating requests doc and the v3 signatures changelog spell out the exact recipe. Here it is as drop-in Express middleware:
import crypto from "crypto";
const CLIENT_SECRET = process.env.HUBSPOT_APP_SECRET; // from your private app settings
const MAX_AGE_MS = 5 * 60 * 1000; // reject anything older than 5 minutes
export function validateHubSpotSignature(req, res, next) {
const signature = req.header("X-HubSpot-Signature-v3");
const timestamp = req.header("X-HubSpot-Request-Timestamp");
// 1. Reject stale requests (replay protection)
if (!signature || !timestamp || Date.now() - Number(timestamp) > MAX_AGE_MS) {
return res.sendStatus(401);
}
// 2. Rebuild the exact source string: method + full URL + raw body + timestamp
const uri = `https://${req.get("host")}${req.originalUrl}`;
const source = `${req.method}${uri}${req.rawBody}${timestamp}`;
// 3. HMAC-SHA256 with the app secret, base64-encoded
const hash = crypto
.createHmac("sha256", CLIENT_SECRET)
.update(source, "utf8")
.digest("base64");
// 4. Constant-time compare against the header
const expected = Buffer.from(hash);
const received = Buffer.from(signature);
if (expected.length !== received.length ||
!crypto.timingSafeEqual(expected, received)) {
return res.sendStatus(401);
}
next();
}The same check as a Python function, so both stacks are covered:
import base64
import hashlib
import hmac
import os
import time
CLIENT_SECRET = os.environ["HUBSPOT_APP_SECRET"].encode("utf-8")
MAX_AGE_MS = 5 * 60 * 1000 # 5 minutes
def is_valid_signature(method, uri, body, signature, timestamp):
# 1. Reject stale requests
if not signature or not timestamp:
return False
if int(time.time() * 1000) - int(timestamp) > MAX_AGE_MS:
return False
# 2. method + full URL + raw body + timestamp
source = f"{method}{uri}{body}{timestamp}".encode("utf-8")
# 3. HMAC-SHA256, base64
digest = hmac.new(CLIENT_SECRET, source, hashlib.sha256).digest()
expected = base64.b64encode(digest).decode("utf-8")
# 4. Constant-time compare
return hmac.compare_digest(expected, signature)The gotcha that costs people an afternoon: HubSpot signs the full target URL, scheme and host and path together. Behind a proxy, a load balancer, or an ngrok tunnel, req.get("host") can report the internal host instead of the public one HubSpot signed. If validation keeps failing on a payload you're sure is legit, log the exact URI you rebuilt and compare it to your public webhook URL, character for character.
Rate Limits, 429s, and the Batch API: What We Ran in Production
Private apps get roughly 10 requests per second (100 per 10 seconds on Free/Starter, 190 per 10 seconds on Pro/Enterprise) with a daily cap between 250,000 and 1,000,000. The trap: CRM Search is separately capped at 4 requests per second, and batch endpoints accept a maximum of 100 records per request. HubSpot's usage guidelines list the tiers.
| Tier | Per 10s | Per second | Daily cap | Notes |
|---|---|---|---|---|
| Free / Starter (private app) | 100 | ~10 | 250,000 | CRM Search separately capped at 4 req/s |
| Pro / Enterprise (private app) | 190 | ~19 | up to 1,000,000 | Batch endpoints max 100 records per request |
Here's where theory met a red staging dashboard. During a backfill this spring, we pushed roughly 8,000 existing records into HubSpot from an internal ticketing tool and enriched each one with a CRM Search lookup. We were running @hubspot/api-client v14 on the Node side and hubspot-api-client v12 for a Python enrichment worker. The bulk writes were fine. The Search calls fell over inside a minute, because our worker was firing Search at around 15 req/s against a hard 4 req/s ceiling we hadn't budgeted for separately.
Two changes fixed it. First, a retry wrapper that reads the X-HubSpot-RateLimit-* response headers and backs off on a 429:
// Wrap any HubSpot call; retries on 429 with exponential backoff
async function withRetry(fn, maxRetries = 5) {
let attempt = 0;
while (true) {
try {
return await fn();
} catch (err) {
const status = err.code ?? err.response?.status;
if (status !== 429 || attempt >= maxRetries) throw err;
// Honor HubSpot's reset window if the header is present
const headers = err.response?.headers ?? {};
const resetMs = Number(headers["x-hubspot-ratelimit-interval-milliseconds"]) || 0;
const backoff = Math.max(resetMs, 2 ** attempt * 500); // 0.5s, 1s, 2s, 4s...
console.warn(`429 hit, retry ${attempt + 1} in ${backoff}ms`);
await new Promise((r) => setTimeout(r, backoff));
attempt++;
}
}
}Second, we stopped writing records one at a time. The batch endpoint takes up to 100 records per POST /crm/v3/objects/{objectType}/batch/create, so we chunked the backfill into 80 batch calls instead of 8,000 single POSTs:
// HubSpot batch endpoints accept at most 100 records per request
function chunk(arr, size = 100) {
const out = [];
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
return out;
}
// POST /crm/v3/objects/contacts/batch/create, chunked to 100 at a time
async function batchCreateContacts(records) {
for (const group of chunk(records, 100)) {
const inputs = group.map((r) => ({
properties: { email: r.email, firstname: r.firstName, lastname: r.lastName },
associations: [],
}));
await withRetry(() => hubspot.crm.contacts.batchApi.create({ inputs }));
console.log(`Wrote ${group.length} contacts`);
}
}Gating the Search worker to 4 req/s and batching the writes turned a run that was drowning in retries into one that finished quietly. If you remember one number from this section, make it 4: the CRM Search cap is the limit that bites in production, and it's the one every roundup post forgets to mention. The old "10 for contacts" batch ceiling, by the way, is gone; current is 100 across object types.
Going Two-Way: Writing Changes Back to HubSpot Without Infinite Loops
A two-way sync writes changes back to HubSpot from your internal tool as well as reading them in. The danger is a feedback loop: your write-back triggers the very webhook that fired your handler, which writes again, forever. Prevent it with an idempotency key (skip changes you've already applied) and a source flag (ignore inbound events your own tool caused).
const processed = new Set(); // use Redis or a unique DB constraint in production
async function writeBackToHubSpot(record) {
// Dedup key: object id + a hash of the change we're about to apply
const key = `${record.id}:${record.updatedHash}`;
if (processed.has(key)) return; // already synced this exact change
processed.add(key);
await withRetry(() =>
hubspot.crm.contacts.basicApi.update(record.id, {
// Tag the source so the resulting webhook is ignored by our own receiver
// (check for source: "internal-tool" before acting on an inbound event)
properties: { internal_status: record.status, last_sync_source: "internal-tool" },
})
);
}The pattern is small, but skipping it is how a sync quietly doubles your write volume overnight. Once the data's clean in both directions, teams often feed it downstream into an AI SDR pipeline or a reporting layer. Nango's HubSpot integration tutorial is a solid Node-only reference if you want a second take on two-way sync, though you'll need to port the loop-prevention idea yourself.
Should You Build This In-House or Hire an Integration Partner?
Build in-house when the sync is small, stable, and owned: a one-way flow, a handful of objects, and an engineer who can absorb HubSpot's roughly twice-a-year breaking changes. Hire a partner when you need bidirectional sync, custom-object modeling, or when nobody on the team can own ongoing maintenance. The deciding factor is rarely the initial build; it's who watches it a year from now.
Here's an honest checklist. Build it yourself if: the direction is one-way, you're syncing standard objects, you have a developer who can host a webhook endpoint, and someone will notice when a payload starts failing. Everything above is your blueprint.
Hire a partner if: you need two-way sync with loop prevention across several objects, you're modeling custom objects with typed associations, you're wiring multiple systems (HubSpot plus an ERP plus billing), or the person who'd maintain it is already at capacity. HubSpot uses date-based API versioning with breaking changes only about twice a year, which sounds gentle until one lands during your busiest week and there's no owner. That maintenance tail, not the first deploy, is what quietly sinks internal integrations. If you'd rather not own it, that's where our custom CRM integration services come in.
Key Takeaways
- There is no HubSpot API key anymore. Use a private app access token for a single-account internal tool; OAuth is only for public, multi-account apps.
- Always validate
X-HubSpot-Signature-v3before you trust a webhook payload. Rebuild the source string with the full target URL. - Respect the 4 req/s CRM Search cap and batch large writes in chunks of 100 with a 429 backoff.
- Prefer webhooks over polling for real-time sync, and a HubSpot integration is one piece of a broader AI tools for business stack.
Stuck on the maintenance side, or want a second pair of eyes before you ship? Book a free integration consultation. No pressure either way; the code above is yours to run regardless.
About the Author
Mert Batur Gurbuz is Co-Founder of Techsy.io, where the team ships AI agents, automation systems, and voice/SDR pipelines for B2B clients. He studies at the University of Birmingham and writes about the LLM tooling stack the Techsy team actually uses in production. Credentials: Co-Founder, Techsy.io, University of Birmingham. Connect on LinkedIn.
Frequently Asked Questions
Do I still need a HubSpot API key in 2026?
No. HubSpot sunset static API keys on November 30, 2022, and they're fully unsupported. Autocomplete still suggests "hubspot api key" out of habit, but there's nothing to fetch. For a single-account internal tool, create a private app in Settings and use its access token instead.
What's the difference between a private app token and OAuth for HubSpot?
A private app access token is a static credential for a single HubSpot account, with no expiry and no refresh flow, ideal for an internal tool. OAuth 2.0 is for public, multi-account apps that other companies install into their own portals; its tokens expire in about six hours and require a refresh cycle.
What are HubSpot's API rate limits in 2026?
Private apps get about 10 requests per second (100 per 10 seconds on Free/Starter, 190 on Pro/Enterprise) with a daily cap of 250,000 to 1,000,000. The CRM Search API is separately capped at 4 requests per second, and batch endpoints accept a maximum of 100 records per request.
How do I validate a HubSpot webhook signature?
Use the v3 recipe: reject requests where X-HubSpot-Request-Timestamp is older than five minutes, then build a source string of method plus full target URL plus raw body plus timestamp. HMAC-SHA256 it with your app secret, base64-encode the result, and compare it to X-HubSpot-Signature-v3 in constant time.
Which HubSpot SDK should I use, Node or Python?
Both are official and maintained. Node uses @hubspot/api-client (v14) and Python uses hubspot-api-client (v12). They expose the same v3 CRM object model, so pick whichever matches your stack. This guide ships identical auth and signature-validation code in both languages.
How do I sync HubSpot with a custom internal tool in real time?
Register a webhook subscription in your private app for the object and event you care about, then host an HTTPS endpoint that HubSpot POSTs to when a matching change happens. Validate the signature, then write the change into your internal tool. Poll only when no webhook subscription covers what you need.
What is a HubSpot Service Key and should I use it?
A Service Key is an account-level, data-only credential HubSpot put into public beta in February 2026. It's aimed at server-side jobs that only touch data. For a standard internal tool today, a private app access token is still the safer, better-documented default; treat Service Keys as beta until they graduate.
Can I test a HubSpot integration without touching production?
Yes. Create a HubSpot developer sandbox and point your private app token at it. The scopes, object model, webhooks, and rate limits behave the same as production, so you can create test contacts and fire webhooks without leaving junk records for your sales team to clean up later.
How many records can the HubSpot batch API take at once?
The batch endpoints (POST /crm/v3/objects/{objectType}/batch/create and its update and upsert siblings) accept a maximum of 100 records per request. Chunk larger payloads into groups of 100. The older "10 records for contacts" ceiling some tutorials still cite has been removed; 100 is current across object types.
Should I build this in-house or hire an agency?
Build in-house when the sync is one-way, uses standard objects, and has an owner who can absorb HubSpot's twice-a-year breaking changes. Hire a partner for bidirectional sync, custom-object modeling, or when nobody can own the maintenance. The first deploy is easy; the year of upkeep after it is the real cost.