agentleFS
ShanksgShanksg/clawskills/skills/hubspot/skill.md

hubspot

Skill2 starsChanged 3 months agoMITView on GitHub

Last validated: 2026-10-14 | API: HubSpot CRM API v3 (date-based versioning GA — March 30, 2026; latest dated version 2026-09, released Sep 8, 2026) Base URL: https://api.hubapi.com/crm/v3/objects/{objectType} Assumed products: CRM (Contacts, Companies, Deals) + Marketing Hub + Sales Hub ⚠️ Changes since 2024: - Rate limits raised (Sep 2024): Pro/Enterprise burst = 190 req/10s (was 150); Enterprise daily = 1M (was 500k). - CRM Search API max page size raised to 200 records (was 100); rate limit raised to 5…

  • Sends data out

11 other projects use a version of this, 1 of them edited.

Adopted by

Most recently taken up by

Used with

Projects with it also use posthog and discord.

Other files people use alongside this one.

Files found in the same public projects as a version of this one, more often than they turn up anywhere else. Each project counts once, large collections count for less, and this file's own repository is left out.
  • OpenAISkill

    posthog

    Analyze product data and manage product tooling in PostHog. Use when the user wants product analytics or insights, HogQL/SQL queries, feature flags, experiments and A/B tests, error tracking, session replay, surveys, LLM analytics, dashboards, data warehouse, or PostHog documentation.

    Also in 3 projects that use this

  • jiulingyunSkill

    discord

    Discord ops via the message tool (channel=discord).

    Also in 3 projects that use this

The file

Show its rules and text

We split this skill into the rules it gives an agent

24 rules. 0 are common, 13 are distinctive, and 11 are very specific to this project.

  • 0

    Common

    Most of the 11 edited versions we read keep these.

  • 13

    Distinctive

    This author's own choices. Worth a look.

  • 11

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Create and upsert Contacts and Companies from any external system without duplicates (dedup by email).

    line 30
  2. Distinctive:

    Search CRM objects using flexible filter rules with cursor-based pagination.

    line 33
  3. Very specific:

    All objects have a numeric id (HubSpot internal ID) returned on create.

    line 75
  4. Distinctive:

    Goal: Create or update a Contact.

    line 157
  5. Very specific:

    If total = 0 → create. If total > 0 → PATCH.

    line 171
  6. Very specific:

    Pagination: Use after cursor from paging.next.after.

    line 327
  7. Very specific:

    Use batch/create or batch/update for API-driven writes where you already have structured JSON and need immediate responses.

    line 408
  8. Distinctive:

    Use Imports for backfills, CSV-based migrations, or cases where users export flat files from another system.

    line 409
  9. Distinctive:

    Always inspect the import summary and error rows before marking the source batch as complete.

    line 413
  10. Distinctive:

    Treat duplicate email collisions as data-quality events, not transient API failures.

    line 414
  11. Distinctive:

    Search first when deduplicating by email.

    line 419
  12. Very specific:

    Search Contacts by email.

    line 428
  13. Very specific:

    If found, PATCH /crm/v3/objects/contacts/{id} with mapped fields.

    line 429
  14. Very specific:

    If not found, POST /crm/v3/objects/contacts.

    line 430
  15. Very specific:

    Goal: When a Deal enters closedwon, create onboarding work in Asana.

    line 453
  16. Distinctive:

    Create the Asana task or project with the customer name, ARR, owner, and launch date.

    line 459
  17. Distinctive:

    Upsert by email: Search by email first; create or PATCH based on result.

    line 535
  18. Very specific:

    Custom external ID property: Add a custom property (e.g., integration_source_id) and search by it before creating.

    line 536
  19. Distinctive:

    Batch create: Not idempotent — a duplicate batch create will create duplicate records.

    line 537
  20. Very specific:

    Search returning 0 results for known record: Check that you're filtering on the correct property name (internal name, not label).

    line 560
  21. Very specific:

    Data minimization: Always use properties parameter in GET/search requests to fetch only needed fields.

    line 569
  22. Distinctive:

    Admin tokens: Do not use HubSpot Super Admin user tokens for integrations.

    line 571
  23. Distinctive:

    Search API: https://developers.hubspot.com/docs/api/crm/search

    line 598
  24. Distinctive:

    Import API: https://developers.hubspot.com/docs/api/crm/imports

    line 600

“Common” is measured: kept by at least 6 in 10 of the 11 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
skill.md

HubSpot Skill

Last validated: 2026-10-14 | API: HubSpot CRM API v3 (date-based versioning GA — March 30, 2026; latest dated version 2026-09, released Sep 8, 2026) Base URL: https://api.hubapi.com/crm/v3/objects/{objectType} Assumed products: CRM (Contacts, Companies, Deals) + Marketing Hub + Sales Hub

⚠️ Changes since 2024:

  • Rate limits raised (Sep 2024): Pro/Enterprise burst = 190 req/10s (was 150); Enterprise daily = 1M (was 500k).
  • CRM Search API max page size raised to 200 records (was 100); rate limit raised to 5 req/s (was 4).
  • X-HubSpot-RateLimit-Secondly and X-HubSpot-RateLimit-Secondly-Remaining headers deprecated — use X-HubSpot-RateLimit-Remaining instead.
  • Contact Lists API v1 sunset April 30, 2026 — endpoints now return 404. Use the v3 Lists API.
  • Date-based API versioning is GA (March 30, 2026) — dated versions use a /YYYY-MM/ path segment; unpinned calls follow the latest stable. The legacy v3 path remains valid and is what every recipe below uses.

⚠️ Changes since 2026-05:

  • Legacy public app creation sunset May 26, 2026 — you can no longer create new legacy public apps. Existing ones keep working; new integrations must use the current app model.
  • Official SDKs support date-based versioning as of June 10, 2026, generated from HubSpot's central API spec. If you pin SDK versions, upgrade before adopting /YYYY-MM/ paths.
  • hs_customer_agent_lead_status became read-only July 13, 2026 — writes to this contact property now fail.
  • Additive (July 2026): higher owner-property limits, larger Webhooks Journal batch sizes, and a historical API-usage chart in the developer portal.

⚠️ Changes since 2026-08:

  • Legacy /v1/–/v3/ API paths moved to unsupported status Sep 15, 2026 (no more bug, reliability, or security fixes); enforcement September 2027. The /crm/v3/ paths in the recipes below still work today, but plan to migrate straight to date-based /YYYY-MM/ paths — HubSpot says not to use v3/v4 as an intermediate step. v4 (e.g., Associations v4) support ends March 30, 2027.
  • Legacy private app creation disabled — Sep 28, 2026 for new accounts, Oct 26, 2026 for existing accounts. Existing private apps keep working; new in-account integrations use Service Keys (see Authentication below).
  • 2026-09 API version (Sep 8, 2026): admin-configured validation rules (conditional required properties, record-creator required properties, "Edit Associations" permission) are enforced on all CRM write paths in /2026-09/ and later — expect 400 VALIDATION_ERROR with codes like MISSING_CONDITIONAL_REQUIRED_PROPERTY. Pipeline/stage deletes also validate references by default from 2026-09.
  • Pipelines API V1 sunset December 4, 2026 — migrate to the 2026-09 Pipelines API (/crm/pipelines/2026-09/{objectType}).

What this skill enables

  • Create and upsert Contacts and Companies from any external system without duplicates (dedup by email).
  • Move Deals through pipeline stages programmatically to keep revenue tracking accurate.
  • Log all customer interactions (calls, emails, notes, meetings) as Engagements against CRM records.
  • Search CRM objects using flexible filter rules with cursor-based pagination.
  • Associate objects (Contact ↔ Company ↔ Deal) bidirectionally via the Associations API.
  • Subscribe to real-time CRM events (contact created, deal stage changed) via the Webhooks API.
  • Bulk-process up to 100 records per request using the Batch APIs.

Best-fit use cases

Use caseWhy it mattersPrimary objects/entitiesTypical triggerSuccess criteria
Contact upsert from external formEliminate duplicate contacts from multiple ingestion pointsContactForm submission, ad clickContact created or updated; no duplicate by email
Company creation and Contact associationKeep company hierarchy correctCompany, ContactNew account in another CRMCompany created; Contact associated
Deal stage updateAccurate pipeline for forecasting and reportingDealStage change in quoting tool or CRMDeal's dealstage property updated
Log a call or email as EngagementFull interaction history for sales repsEngagement (Note, Call, Email)Call ended, email sentEngagement created; associated with Contact and Deal
Sync contacts from CSV importBulk historical data loadContactOne-time migration or scheduled importAll rows upserted; errors reported
Webhook on deal closed-wonTrigger downstream workflow (invoice, onboarding)Dealdealstage = closedwonWebhook received; downstream system triggered
Lifecycle stage progressionTrack MQL → SQL → CustomerContactLead score threshold reachedlifecyclestage property updated
Associate multiple contacts with a dealEnterprise deals with multiple stakeholdersContact, Deal, AssociationContact added to accountAssociation created; visible in deal's contact list
List membership syncKeep segment lists accurateContact, ListProperty changeContact added to or removed from list
Pipeline reporting pullAggregate deal data for dashboardsDeal, Contact, CompanyScheduled report jobAll open deals fetched with associated company names
Batch read contacts by IDEfficiently retrieve known recordsContactData enrichment jobAll 100 contacts returned per batch call
Custom property updateSync business-specific metadataAny CRM objectExternal system eventCustom property value set correctly

Key concepts & data model

Core CRM objects

Object typeAPI nameDescriptionKey properties
ContactcontactsIndividual personemail, firstname, lastname, phone, lifecyclestage, hs_lead_status
CompanycompaniesOrganizationname, domain, industry, numberofemployees, annualrevenue
DealdealsSales opportunitydealname, dealstage, amount, closedate, pipeline
TicketticketsSupport casesubject, content, hs_ticket_priority, hs_pipeline_stage
ProductproductsProduct catalog itemname, price, description
Line Itemline_itemsProduct attached to a Dealname, quantity, price, hs_product_id
Engagementnotes / calls / emails / meetings / tasksLogged activitySee Engagement section below

Identifying records

  • All objects have a numeric id (HubSpot internal ID) returned on create.
  • Contacts: also identified by email (unique) — use as dedup key.
  • Companies: identified by domain or name (domain is unique; name is not).
  • Store HubSpot IDs in your external system after create/upsert.

Properties vs Associations

  • Properties are fields on an object (e.g., email, dealstage, custom integration_id).
  • Associations are relationships between objects (e.g., Contact ↔ Company). Managed separately via the Associations API.

Engagements (logged activities)

ActivityAPI objectKey fields
Notenoteshs_note_body
Callcallshs_call_title, hs_call_duration, hs_call_direction, hs_call_recording_url
Emailemailshs_email_subject, hs_email_html, hs_email_direction
Meetingmeetingshs_meeting_title, hs_meeting_start_time, hs_meeting_end_time
Tasktaskshs_task_subject, hs_task_status, hs_task_type

Custom properties

Custom properties are created via the Properties API and referenced by their name (internal name, e.g., integration_source). The internal name is set on creation and cannot be changed.


Authentication & permissions

Supported auth methods

MethodUse caseNotes
Private App TokenServer-to-server automations (recommended)Scoped by object/action; no OAuth dance needed
OAuth 2.0Multi-portal or third-party appsRequired for apps in the HubSpot marketplace

Private App Token (recommended for automation)

⚠️ Changed 2026-09-28: creating new legacy private apps is disabled (Sep 28, 2026 for accounts created on/after that date; Oct 26, 2026 for existing accounts). Existing private app tokens keep working. For new in-account integrations, create a Service Key instead (Development → Keys → Service keys → Create service key, add scopes, copy the key) and send it exactly the same way: Authorization: Bearer {service_key}. Service keys are REST-only — they cannot authenticate webhooks or UI extensions; use an app for those. Rotating a key expires the old one (immediately or after 7 days). Docs: https://developers.hubspot.com/docs/apps/developer-platform/build-apps/authentication/account-service-keys

  1. In HubSpot → Settings → Integrations → Private Apps → Create a private app.
  2. Select scopes (see table below).
  3. Copy the generated token — it is shown only once.
curl -s "https://api.hubapi.com/crm/v3/objects/contacts" \
  -H "Authorization: Bearer {private_app_token}" \
  -H "Content-Type: application/json"

Required scopes (least privilege)

ScopeAccess granted
crm.objects.contacts.readRead contact records and properties
crm.objects.contacts.writeCreate/update/delete contacts
crm.objects.companies.readRead company records
crm.objects.companies.writeCreate/update/delete companies
crm.objects.deals.readRead deal records
crm.objects.deals.writeCreate/update/delete deals
crm.schemas.contacts.readRead contact property definitions
crm.schemas.contacts.writeCreate custom contact properties
ticketsRead/write ticket objects
e-commerceRead/write line items and products
sales-email-readRead engagement emails

Token storage

  • Private App tokens do not expire but can be rotated/revoked.
  • Rotate tokens when personnel changes occur. New tokens are immediately active.
  • Store tokens in a secrets manager — not in code or environment files checked into source control.

Multi-tenant / multi-portal

  • Each HubSpot portal has a unique portalId. OAuth apps must store tokens per portal.
  • Private App tokens are portal-specific — one token per portal.

Common workflows (recipes)

Recipe 1: Upsert a Contact by email

Goal: Create or update a Contact; if a Contact with the given email already exists, update it.

# Check for existing contact first
curl -s -X POST "https://api.hubapi.com/crm/v3/objects/contacts/search" \
  -H "Authorization: Bearer $HS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "filterGroups": [{ "filters": [{ "propertyName": "email", "operator": "EQ", "value": "jane@acme.com" }] }],
    "properties": ["email", "firstname", "lastname", "hs_object_id"],
    "limit": 1
  }'

If total = 0 → create. If total > 0 → PATCH.

# Create
curl -s -X POST "https://api.hubapi.com/crm/v3/objects/contacts" \
  -H "Authorization: Bearer $HS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "properties": {
      "email": "jane@acme.com",
      "firstname": "Jane",
      "lastname": "Smith",
      "phone": "+15551234567",
      "lifecyclestage": "lead",
      "hs_lead_status": "NEW"
    }
  }'
# Response: {"id": "1234567", "properties": {...}}

# Update existing
curl -s -X PATCH "https://api.hubapi.com/crm/v3/objects/contacts/{id}" \
  -H "Authorization: Bearer $HS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "properties": { "lifecyclestage": "marketingqualifiedlead" } }'
# 200 with updated object

Pitfall: lifecyclestage values are ordered; setting it to a lower stage (e.g., lead when already customer) is silently ignored. Use hs_lead_status for fine-grained status.


Recipe 2: Create a Company and associate with a Contact

# Create Company
curl -s -X POST "https://api.hubapi.com/crm/v3/objects/companies" \
  -H "Authorization: Bearer $HS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "properties": {
      "name": "Acme Corp",
      "domain": "acme.com",
      "industry": "TECHNOLOGY",
      "numberofemployees": "500"
    }
  }'
# Response: {"id": "7654321", ...}

COMPANY_ID="7654321"
CONTACT_ID="1234567"

# Associate Contact → Company
curl -s -X PUT "https://api.hubapi.com/crm/v4/objects/contacts/$CONTACT_ID/associations/companies/$COMPANY_ID" \
  -H "Authorization: Bearer $HS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '[{ "associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 279 }]'
# 200 on success (v4 Associations API)

Association type IDs (HUBSPOT_DEFINED common values):

  • Contact → Company: 279 (primary)
  • Deal → Contact: 3
  • Deal → Company: 5
  • Ticket → Contact: 15

Full list: GET https://api.hubapi.com/crm/v4/associations/{fromObjectType}/{toObjectType}/labels


Recipe 3: Create a Deal and update its stage

# Create Deal
curl -s -X POST "https://api.hubapi.com/crm/v3/objects/deals" \
  -H "Authorization: Bearer $HS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "properties": {
      "dealname": "Acme Corp - Enterprise 2026",
      "dealstage": "appointmentscheduled",
      "pipeline": "default",
      "amount": "85000",
      "closedate": "2026-06-30T00:00:00.000Z"
    }
  }'
# Response: {"id": "9999999", ...}

# Update stage
DEAL_ID="9999999"
curl -s -X PATCH "https://api.hubapi.com/crm/v3/objects/deals/$DEAL_ID" \
  -H "Authorization: Bearer $HS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "properties": { "dealstage": "closedwon" } }'

Pipeline stage IDs: Stage identifiers (e.g., appointmentscheduled, closedwon) are pipeline-specific. Fetch valid values: GET https://api.hubapi.com/crm/v3/pipelines/deals


Recipe 4: Log a Note Engagement against a Contact and Deal

curl -s -X POST "https://api.hubapi.com/crm/v3/objects/notes" \
  -H "Authorization: Bearer $HS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "properties": {
      "hs_note_body": "Discovery call completed. Customer needs: SSO, audit logs, enterprise SLA. Follow up with legal.",
      "hs_timestamp": "2026-02-19T14:30:00.000Z"
    },
    "associations": [
      { "to": { "id": "1234567" }, "types": [{ "associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 202 }] },
      { "to": { "id": "9999999" }, "types": [{ "associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 214 }] }
    ]
  }'

Association type IDs for notes: 202 (Note → Contact), 214 (Note → Deal).


Recipe 5: Search CRM objects with filters

Goal: Find all Contacts where lifecyclestage = lead and createdate > 7 days ago.

curl -s -X POST "https://api.hubapi.com/crm/v3/objects/contacts/search" \
  -H "Authorization: Bearer $HS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "filterGroups": [
      {
        "filters": [
          { "propertyName": "lifecyclestage", "operator": "EQ", "value": "lead" },
          { "propertyName": "createdate", "operator": "GTE", "value": "1708128000000" }
        ]
      }
    ],
    "properties": ["email", "firstname", "lastname", "lifecyclestage", "createdate"],
    "sorts": [{ "propertyName": "createdate", "direction": "DESCENDING" }],
    "limit": 200,
    "after": null
  }'

Updated: limit can now be up to 200 (raised from 100 in Sep 2024). The CRM Search API also allows up to 5 requests/second.

Response:

{
  "total": 847,
  "results": [...],
  "paging": { "next": { "after": "200", "link": "..." } }
}

Pagination: Use after cursor from paging.next.after. Pass it in the next request's "after" field.


Recipe 6: Batch read 100 contacts by ID

curl -s -X POST "https://api.hubapi.com/crm/v3/objects/contacts/batch/read" \
  -H "Authorization: Bearer $HS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "inputs": [
      { "id": "1234567" },
      { "id": "1234568" },
      { "id": "1234569" }
    ],
    "properties": ["email", "firstname", "lastname", "lifecyclestage"]
  }'

Max 100 inputs per batch read. For batch create/update, use /batch/create and /batch/update.


Recipe 7: Register a webhook subscription

Goal: Receive a push notification when a Deal's dealstage changes.

Precondition: A HubSpot app (can be a private app or developer app) must be created. Webhooks require an app.

# Create webhook subscription
curl -s -X POST "https://api.hubapi.com/webhooks/v3/{app_id}/subscriptions" \
  -H "Authorization: Bearer $HS_DEVELOPER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "eventType": "deal.propertyChange",
    "propertyName": "dealstage",
    "active": true
  }'

Common event types:

  • contact.creation, contact.deletion, contact.propertyChange
  • company.creation, company.propertyChange
  • deal.creation, deal.deletion, deal.propertyChange
  • ticket.creation, ticket.propertyChange

Webhook payload:

[{
  "eventId": 100,
  "subscriptionId": 12345,
  "portalId": 9876543,
  "occurredAt": 1708357200000,
  "eventType": "deal.propertyChange",
  "objectId": 9999999,
  "propertyName": "dealstage",
  "propertyValue": "closedwon",
  "changeSource": "CRM_UI"
}]

Security: Validate the X-HubSpot-Signature-v3 header using HMAC-SHA256 of {client_secret}{HTTP_method}{URL}{request_body}{timestamp}. Details: https://developers.hubspot.com/docs/api/webhooks#validating-requests


Recipe 8: Import contacts from CSV (bulk)

curl -s -X POST "https://api.hubapi.com/crm/v3/imports" \
  -H "Authorization: Bearer $HS_TOKEN" \
  -F 'importRequest={"name":"BulkContactImport","importOperations":{"0-1":"UPSERT"},"dateFormat":"YEAR_MONTH_DAY","files":[{"fileName":"contacts.csv","fileFormat":"CSV","fileImportPage":{"hasHeader":true,"columnMappings":[{"columnObjectTypeId":"0-1","columnName":"Email","propertyName":"email","idColumnType":"EMAIL"},{"columnObjectTypeId":"0-1","columnName":"First Name","propertyName":"firstname"},{"columnObjectTypeId":"0-1","columnName":"Last Name","propertyName":"lastname"}]}}]}' \
  -F "files=@contacts.csv;type=text/csv"

# Poll import status
GET https://api.hubapi.com/crm/v3/imports/{importId}
# importStatus: STARTED → PROCESSING → DONE

When to use Imports vs batch endpoints:

  • Use batch/create or batch/update for API-driven writes where you already have structured JSON and need immediate responses.
  • Use Imports for backfills, CSV-based migrations, or cases where users export flat files from another system.

Partial failure handling:

  • Imports can finish with row-level failures even when the job completes successfully.
  • Always inspect the import summary and error rows before marking the source batch as complete.
  • Treat duplicate email collisions as data-quality events, not transient API failures.

Batch write guardrails:

  • Max 100 inputs per batch request.
  • For large syncs, shard into batches of 100 and apply a short concurrency limit so you do not burst into 429s.
  • Search first when deduplicating by email; batch create is not idempotent.

Cross-tool recipe: Salesforce Lead -> HubSpot Contact sync

Goal: Mirror new or updated Salesforce Leads into HubSpot Contacts for marketing and lifecycle automation.

HubSpot-side pattern:

  1. Search Contacts by email.
  2. If found, PATCH /crm/v3/objects/contacts/{id} with mapped fields.
  3. If not found, POST /crm/v3/objects/contacts.
  4. Optionally stamp a custom property like salesforce_lead_id so future reconciliations do not depend solely on email.

Recommended property mapping:

HubSpot ContactSource from Salesforce
emailLead.Email
firstnameLead.FirstName
lastnameLead.LastName
companyLead.Company
phoneLead.Phone
lifecyclestagederived mapping from lead status

Failure policy:

  • 400 validation errors: quarantine the record with the source Lead ID and failing field names.
  • 409 or duplicate conflicts: re-run a search by email, then update the winning Contact instead of creating a second record.
  • 429: respect Retry-After and retry the shard, not the full sync job.

Cross-tool recipe: HubSpot Deal Won -> Asana project kickoff

Goal: When a Deal enters closedwon, create onboarding work in Asana.

Flow:

  1. Subscribe to deal.propertyChange for dealstage.
  2. Filter for the pipeline stage representing "closed won".
  3. Fetch the Deal plus associated Company and primary Contact.
  4. Create the Asana task or project with the customer name, ARR, owner, and launch date.
  5. Store the resulting Asana gid in HubSpot or in your integration DB.

Minimum payload to send to Asana:

  • name: Onboard {dealname}
  • notes: include the HubSpot deal URL, company, close date, and owner
  • due_on: derived from kickoff SLA or launch date
  • custom_fields: ARR, segment, onboarding tier, source deal ID

Query patterns & filtering

Search API filter operators

OperatorMeaningExample
EQEquals"operator": "EQ", "value": "lead"
NEQNot equals"operator": "NEQ"
CONTAINS_TOKENContains a word"operator": "CONTAINS_TOKEN", "value": "acme"
GTE / LTENumeric/date comparison"value": "1708128000000" (ms epoch)
HAS_PROPERTYField is not empty"operator": "HAS_PROPERTY"
NOT_HAS_PROPERTYField is empty"operator": "NOT_HAS_PROPERTY"
INMatches any in list"values": ["lead", "mql"]

Incremental sync

HubSpot properties createdate and lastmodifieddate (or hs_lastmodifieddate) are available on all objects. Use epoch milliseconds:

{ "propertyName": "lastmodifieddate", "operator": "GTE", "value": "1708300800000" }

Store the max lastmodifieddate from each run as the next sync boundary.


Reliability: rate limits, retries, idempotency

Rate limits (verified 2026-02-19 — updated Sep 2024)

Plan / App typeBurst limitDaily limit
Free / Starter100 req / 10s250,000 / day
Professional190 req / 10s650,000 / day
Enterprise190 req / 10s1,000,000 / day
API Limit Increase pack250 req / 10s+1,000,000 / day (stackable ×2)
Public OAuth apps (marketplace)110 req / 10s—
CRM Search API5 req / s—

Active headers (use these — the Secondly variants are deprecated):

  • X-HubSpot-RateLimit-Remaining — requests remaining in current window
  • X-HubSpot-RateLimit-Reset — Unix timestamp when window resets

Deprecated headers (still sent but do not rely on): X-HubSpot-RateLimit-Secondly, X-HubSpot-RateLimit-Secondly-Remaining.

import time

def hubspot_request(method, url, **kwargs):
    for attempt in range(5):
        resp = requests.request(method, url, **kwargs)
        if resp.status_code == 429:
            # Use X-HubSpot-RateLimit-Reset (X-HubSpot-RateLimit-Secondly* is deprecated)
            reset = int(resp.headers.get("X-HubSpot-RateLimit-Reset", time.time() + 10))
            time.sleep(max(reset - time.time(), 1))
            continue
        if resp.status_code >= 500:
            time.sleep(2 ** attempt)
            continue
        return resp
    raise Exception("Max retries exceeded")

Idempotency

  • Upsert by email: Search by email first; create or PATCH based on result. Email is unique per portal.
  • Custom external ID property: Add a custom property (e.g., integration_source_id) and search by it before creating.
  • Batch create: Not idempotent — a duplicate batch create will create duplicate records. Always search first.
  • Associations: Calling the same association PUT multiple times is safe — it's idempotent.

Error handling & troubleshooting

Common errors

HTTP StatusError categoryMeaningFix
400 VALIDATION_ERRORBad payloadMissing required property or invalid valueCheck property name spelling; verify property exists
400 PROPERTY_DOESNT_EXISTWrong property nameProperty name not foundCheck via GET /crm/v3/properties/{objectType}
401 INVALID_AUTHENTICATIONBad/expired tokenToken revoked or wrong scopeRegenerate token; add missing scope
403 FORBIDDENInsufficient scopeToken lacks required scopeAdd scope to Private App
404 OBJECT_NOT_FOUNDRecord doesn't existID doesn't exist in portalVerify ID; check correct portal
409 CONFLICTDuplicate on unique fieldEmail already exists (on Contact create)PATCH the existing record instead
429Rate limitedToo many requestsSleep per rate limit headers

"If you see X, do Y" playbook

  • Lifecycle stage update silently ignored: You're trying to set a lower stage than the current one. HubSpot lifecycle stages are ordered and only advance. Use hs_lead_status for fine-grained control.
  • Association type ID 400 error: Wrong associationTypeId for the object pair. Fetch valid types via GET /crm/v4/associations/{from}/{to}/labels.
  • Search returning 0 results for known record: Check that you're filtering on the correct property name (internal name, not label). Use GET /crm/v3/properties/{objectType} to verify.
  • Webhook events arrive out of order: Process events idempotently — store the occurredAt timestamp and reject events older than your last-processed timestamp.

Security, privacy, compliance

  • PII: Contact properties (email, phone, name, company) are PII. Never log property values.
  • GDPR: HubSpot supports GDPR tools: consent records (GDPR_DELETE_CONTACT), communication subscriptions. Use the /communication-preferences API for consent management.
  • Data minimization: Always use properties parameter in GET/search requests to fetch only needed fields.
  • Audit: HubSpot provides an Activity Log in Settings for API activity. Private Apps log all API calls with timestamps.
  • Admin tokens: Do not use HubSpot Super Admin user tokens for integrations. Use a Private App with scoped permissions.
  • Token rotation: Rotate Private App tokens when team members leave or tokens may have been exposed. Old tokens are immediately invalidated on rotation.

Testing checklist

  • Auth test: GET https://api.hubapi.com/crm/v3/objects/contacts?limit=1 — returns an object with results array.
  • CRUD test: Create a Contact → read by ID → update lifecyclestage → create a Note associated to it → delete the Contact.
  • Upsert dedup test: Create a Contact with email X; call create again with same email → expect 409 (or search first and PATCH).
  • Search pagination test: Search with limit=10 on an object with >10 records; follow paging.next.after cursors to completion; count matches total.
  • Batch test: Batch read 3 contacts by ID; batch update 3 contacts with same property change.
  • Webhook test: Register a contact.propertyChange subscription; update a property; verify payload received within 5s; validate HMAC signature.
  • Rate limit test: Log X-HubSpot-RateLimit-Remaining; trigger 429 in test; verify backoff logic.
  • Permission test: Remove a scope from the Private App; attempt the corresponding operation — expect 403.
  • Negative tests:
    • Non-existent object ID → 404
    • Wrong property name → 400 PROPERTY_DOESNT_EXIST
    • Revoked token → 401

Sources

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.