HubSpot CRM skill for Claude Code
Upserts, labeled associations and loop-free CRM sync. Claude builds HubSpot integrations around a unique external ID per record, so every write is an idempotent batch upsert instead of search-then-create. It decides field ownership up front, creates v4 associations with looked-up label IDs, pulls changes incrementally within the search API's 10,000-result cap, and verifies v3 webhook signatures before handling events, merges and deletes.
- name
hubspot-crm - size224 lines · ≈2,550 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Integrates applications with the HubSpot CRM API - private app and OAuth tokens, contacts, companies, deals, tickets and custom objects, property design, batch create and upsert by unique ID, v4 associations and labels, the search API and its result caps, date-based API versions, rate limits, v3 webhook signature validation, and two-way sync without echo loops or duplicate records. Use when pushing signups or product data into HubSpot, syncing deals or companies with your database, building a HubSpot app, or debugging 429s, duplicate contacts, or missing associations. Try asking
- Push new signups and their companies into HubSpot and keep plan changes in sync
- We have duplicate companies in HubSpot after our sync ran - find the cause and fix the integration
- Add a HubSpot webhook endpoint for deal stage changes and update our database
Use it when
- Writing app users, accounts or subscriptions into HubSpot contacts, companies or deals
- Building two-way sync between HubSpot and your database
- Handling HubSpot webhooks or building a HubSpot app
- Seeing 429s, duplicate records or missing associations from an existing integration
Skip it when
- The CRM is Salesforce - use salesforce-api instead
- You need product event analytics rather than CRM records - use product-analytics instead
How to check the result
A second sync run creates zero records, a tampered webhook gets 401 while a replayed one is processed once, and per-object counts match between HubSpot and your database.
The common mistake it prevents
Searching for a record and creating it when not found, which races with HubSpot's search index lag and with company auto-creation, filling the CRM with duplicates.
Done well means: every record has a stable external ID so writes are upserts, field ownership is decided per property so the two systems never fight, associations are created explicitly, sync is incremental and capped below rate limits, and webhooks are signature-verified and deduplicated before they change anything.
Ground rules
- Tokens come from a secret manager or env (
HUBSPOT_ACCESS_TOKEN,HUBSPOT_CLIENT_SECRET). Legacy API keys (hapikey) are retired; never add them. - Request only the scopes the integration uses (
crm.objects.contacts.write,crm.objects.deals.read, …). Scope creep makes marketplace review and customer security reviews harder. - Use internal property names (
firstname,hs_lead_status), never labels. Labels are editable by any admin. - Pin the API version you code against and check the reference before upgrading.
HubSpot now publishes date-based versions (paths like
/crm/objects/2026-03/contacts, released each March and September with roughly 18 months of support) alongside the older numbered paths (/crm/v3/objects/contacts). Use the latest date version for new work when the endpoint you need exists there; keep one version per integration. - HubSpot is not your database. Read-heavy features should query your own store.
Choose the auth model
| Situation | Use |
|---|---|
| Internal integration with one HubSpot account | Private app access token (legacy private app or a static-auth app on the developer platform) |
| App installed into many customer accounts or listed on the marketplace | OAuth app; store access + refresh token per portal ID; access tokens are short-lived |
| Quick scripts and admin tasks | A private app token with read-only scopes |
Developer platform apps (2025.2 and later) are built and deployed with the HubSpot CLI as projects; legacy private apps are still supported and are the fastest path when you only need a token. Keep the HubSpot account ID (portal ID) next to every token.
Workflow
- [ ] 1. Define the object and property model, and who owns each field
- [ ] 2. Create custom properties (including a unique external ID) via API or UI
- [ ] 3. Build the client with token from env; set version; add rate-limit handling
- [ ] 4. Write path: batch upsert by unique property, then associations
- [ ] 5. Read path: incremental pull via search or webhooks + fetch
- [ ] 6. Webhook endpoint: verify v3 signature, dedupe, enqueue
- [ ] 7. Loop prevention, merges, deletes
- [ ] 8. Verify in a developer test account, then reconcile counts in production
1–2. Model and properties
| Object | Natural unique key | Recommendation |
|---|---|---|
| Contact | email (enforced unique) | Also store your app_user_id in a unique-value custom property |
| Company | domain (not enforced unique) | Add app_account_id as a unique-value property; do not rely on domain |
| Deal | none | Add app_subscription_id or similar unique property |
| Custom object | defined by schema | Mark the external ID property hasUniqueValue: true at creation |
Create properties programmatically so environments match:
curl -sS -X POST "https://api.hubapi.com/crm/v3/properties/companies" \
-H "Authorization: Bearer $HUBSPOT_ACCESS_TOKEN" -H "Content-Type: application/json" \
-d '{"name":"app_account_id","label":"App account ID","type":"string","fieldType":"text",
"groupName":"companyinformation","hasUniqueValue":true}'
Field ownership table (write it down in the repo): for each property, which system wins. Product usage fields: app owns, HubSpot read-only. Lifecycle stage and owner: HubSpot owns, app never overwrites.
3. Client
import { Client } from '@hubspot/api-client';
const hubspot = new Client({
accessToken: process.env.HUBSPOT_ACCESS_TOKEN!,
numberOfApiCallRetries: 3, // SDK retries 429/5xx with backoff
});
Rate limits are a burst window per 10 seconds per app per account plus a daily cap, and
they vary by subscription tier and app type. Read the X-HubSpot-RateLimit-* response
headers, keep concurrency low (a queue with 5-10 workers per portal is plenty), and back
off on 429. The search endpoints have their own, lower per-second limit.
4. Write path
Upsert in batches of up to 100 by a unique property instead of search-then-create:
await hubspot.apiRequest({
method: 'POST',
path: '/crm/v3/objects/companies/batch/upsert',
body: {
inputs: accounts.map((a) => ({
idProperty: 'app_account_id',
id: a.id,
properties: { name: a.name, domain: a.domain, app_plan: a.plan },
})),
},
});
Associations (v4) connect records and can carry labels:
// default (unlabeled) association
await hubspot.apiRequest({
method: 'PUT',
path: `/crm/v4/objects/contacts/${contactId}/associations/default/companies/${companyId}`,
});
// batch, labeled: look up typeIds first with GET /crm/v4/associations/deals/contacts/labels
await hubspot.apiRequest({
method: 'POST',
path: '/crm/v4/associations/deals/contacts/batch/create',
body: { inputs: [{ from: { id: dealId }, to: { id: contactId },
types: [{ associationCategory: 'USER_DEFINED', associationTypeId: champTypeId }] }] },
});
Never hardcode association type IDs for custom labels; they differ per account. Fetch and
cache them at startup. You can also pass associations inline when creating a record.
Value formats: all property values are strings on the wire; date properties take midnight-UTC epoch milliseconds; datetime takes epoch ms or ISO 8601; enumeration values must match an existing option’s internal value or the write fails.
5. Read path
Incremental pull with the search API:
POST /crm/v3/objects/deals/search
{
"filterGroups": [{ "filters": [
{ "propertyName": "hs_lastmodifieddate", "operator": "GT", "value": "1767225600000" }
]}],
"sorts": [{ "propertyName": "hs_lastmodifieddate", "direction": "ASCENDING" }],
"properties": ["dealname", "amount", "dealstage", "app_subscription_id"],
"limit": 200
}
- Contacts use
lastmodifieddate; most other objects usehs_lastmodifieddate. - Filters inside a group are AND; groups are OR (with caps on groups and filters).
- Search paging stops at 10,000 results per query. When a window has more, narrow it by time and use the last record’s timestamp as the next lower bound (handle ties by ID).
- Search results lag writes by a few seconds; do not search for a record you just wrote, use the ID from the write response.
GET /crm/v3/objects/{type}?properties=...&after=lists everything when you need a full export; request only the properties you need.
6. Webhooks
Subscriptions are configured on the app (contact.creation, contact.propertyChange, deal.propertyChange, *.deletion, *.merge, association changes). HubSpot batches up to 100 events per POST and retries failed deliveries for about a day.
Validate signature v3 (legacy apps; developer platform apps follow the platform’s own validation guide, check it before assuming this scheme):
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyHubSpotV3(req: { method: string; url: string; rawBody: string;
headers: Record<string, string> }, clientSecret: string): boolean {
const ts = req.headers['x-hubspot-request-timestamp'];
const sig = req.headers['x-hubspot-signature-v3'];
if (!ts || !sig || Date.now() - Number(ts) > 5 * 60 * 1000) return false; // reject >5 min old
const uri = decodeHubSpotUri(`https://${req.headers.host}${req.url}`); // exact public URL
const expected = createHmac('sha256', clientSecret)
.update(`${req.method}${uri}${req.rawBody}${ts}`, 'utf8').digest('base64');
const a = Buffer.from(expected), b = Buffer.from(sig);
return a.length === b.length && timingSafeEqual(a, b);
}
decodeHubSpotUri must decode only the characters HubSpot lists (%3A %2F %3F %40 %21 %24 %27 %28 %29 %2A %2C %3B), and the URL must be the public one HubSpot called, not
the internal URL behind your proxy. Then: dedupe on eventId, order by occurredAt,
enqueue, return 200 fast, and fetch the current record in the worker (events carry only
the changed property).
7. Loop prevention, merges, deletes
- Echo loops: when your app writes to HubSpot, the resulting propertyChange webhook comes back. Ignore events whose change source indicates your own integration (inspect the event’s change source fields in a test delivery), and skip writes where the value is unchanged.
- Merges: merging two contacts keeps one ID; the loser’s ID appears in
hs_merged_object_idson the winner. Subscribe to merge events and remap stored IDs. - Deletes: handle
*.deletionwebhooks; archived records can be listed witharchived=truefor reconciliation. GDPR deletion is permanent; mirror it. - Owners:
hubspot_owner_idis an owner ID, not a user ID; map via the owners API.
Gotchas
- Companies get auto-created and auto-associated from contact email domains when that account setting is on, producing duplicates of the companies your sync creates. Coordinate with the HubSpot admin before launch.
- Batch endpoints can partially succeed; read per-item errors (the response may be 207 Multi-Status) and retry only failures.
- Search does not return associations; fetch them via the v4 associations API or the
object GET with
associations=. - Property history and large property lists bloat responses; ask for what you use.
- Sandbox and developer test accounts have separate portal IDs, tokens, and property definitions; script property creation so they stay identical.
Verification
- In a developer test account, run the sync twice: the second run creates zero records (upserts only) and associations count stays the same.
- Change a field in HubSpot owned by HubSpot and one owned by your app; confirm only the correct direction flows and no echo write occurs.
- Replay a captured webhook with one byte changed: 401. Replay it unchanged: processed once.
- Compare counts per object between HubSpot and your DB with a reconciliation script; a persistent gap means a missed delete, merge, or 10k search cap.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.