Integrations No. 408

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.

  • namehubspot-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

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.

~/.claude/skills/hubspot-crm/SKILL.md

--- name: hubspot-crm description: Integrates applications with the HubSpot CRM API - private app and OAuth tokens, contacts, companies, deals, tickets and custom objects,… license: MIT ---

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

SituationUse
Internal integration with one HubSpot accountPrivate 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 marketplaceOAuth app; store access + refresh token per portal ID; access tokens are short-lived
Quick scripts and admin tasksA 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

ObjectNatural unique keyRecommendation
Contactemail (enforced unique)Also store your app_user_id in a unique-value custom property
Companydomain (not enforced unique)Add app_account_id as a unique-value property; do not rely on domain
DealnoneAdd app_subscription_id or similar unique property
Custom objectdefined by schemaMark 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 use hs_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_ids on the winner. Subscribe to merge events and remap stored IDs.
  • Deletes: handle *.deletion webhooks; archived records can be listed with archived=true for reconciliation. GDPR deletion is permanent; mirror it.
  • Owners: hubspot_owner_id is 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.

esc