Table of Contents
HubSpot’s data model looks simple from the CRM interface, but underneath the contact records and deal pipelines sits a structured system of objects, properties, and associations that behaves a lot like a relational database. Data engineers building extraction pipelines against HubSpot need to understand that structure before writing a single API call. The model determines how data has to be joined, paginated, and reconciled once it lands in a warehouse. Objects map to tables, records map to rows, properties map to columns, and associations work as the foreign keys connecting everything together. That framing holds up well in practice, and it is the fastest way to reason about what the CRM API returns.
HubSpot’s Core Object Model
HubSpot organizes all CRM data into objects, and every object type carries a unique identifier called an `objectTypeId`, formatted as `0-N` for standard objects. Contacts use `0-1`, Companies use `0-2`, Deals use `0-3`, and Tickets use `0-5`. These IDs appear throughout the API, from endpoint paths to association definitions.
The table below lists the standard objects most extraction pipelines touch.
| Object | objectTypeId | Notes |
|---|---|---|
| Contacts | 0-1 | Individual person records |
| Companies | 0-2 | Business or organization records |
| Deals | 0-3 | Sales opportunities tracked through pipeline stages |
| Tickets | 0-5 | Support requests tracked through pipeline statuses |
| Products | 0-7 | Cannot associate directly with other objects; line items reference them |
| Line items | 0-8 | Individual products or services sold within a deal |
| Quotes | 0-14 | Pricing documents shared with buyers |
| Marketing events | 0-54 | Events synced from connected marketing integrations |
| Calls, Meetings, Notes, Tasks | 0-48, 0-47, 0-46, 0-27 | Engagement/activity objects |
| Emails | 0-49 | One-to-one email activity |
Beyond this core set, HubSpot exposes commerce and operational objects such as Invoices (`0-53`), Orders (`0-123`), Payments (`0-101`), and Users (`0-115`), the last of which cannot be associated with other objects at all. Every standard object follows the same endpoint pattern: create with `POST /crm/objects/{version}/{objectTypeId}`, retrieve with `GET`, and update by record ID or by a natural identifier such as email using the `idProperty` query parameter.
Custom Objects on Enterprise Tiers
Custom objects extend this model for businesses whose data does not fit neatly into contacts, companies, and deals. A logistics company might need a Shipments object, or a real estate firm a Properties object, and HubSpot lets Enterprise-tier accounts define these through the Schemas API with `POST /crm-object-schemas/v3/schemas`. The request body defines the object’s internal name, display labels, which properties are searchable or required, and which existing objects it can associate with through an `associatedObjects` array.
Custom objects automatically associate with emails, meetings, notes, tasks, calls, and conversations, though associations to contacts, companies, deals, or tickets have to be declared explicitly. Published limits vary by source, ranging from 10 custom objects per portal on Enterprise up to 100 objects and 10,000 properties each at full Enterprise depth. Because these numbers shift between releases, checking the in-app Data Model Overview beats trusting a blog post, including this one.
Calling `GET /crm-object-schemas/v3/schemas` at pipeline startup returns every custom object defined in the portal along with its `objectTypeId`, `fullyQualifiedName`, property list, and declared associations. That metadata drives dynamic target-table generation in a warehouse, the same approach commercial connectors use to auto-discover custom-object streams.
Properties, Types, and Calculated Fields
Properties are where most of the business data lives, and every object type has its own property set retrievable through `GET /crm/v3/properties/{objectType}`. Each property carries a `type` (string, number, date, datetime, or enumeration) and a `fieldType` controlling how it renders in the UI, such as text, textarea, select, checkbox, or a calculation type.
A few property behaviors catch extraction pipelines off guard often enough to call out directly.
- Immutability of type. Once a property is created as text, it cannot become a dropdown or a number later. Changing the type means creating a new property, migrating data, and updating every workflow and report that referenced the old one.
- Floating point storage for numbers. HubSpot stores numeric properties as floating point, so currency values held directly, rather than as integer cents, are exposed to rounding errors during aggregation.
- Read-only signals. The `modificationMetadata` object on each property exposes `readOnlyValue` and `readOnlyDefinition` flags, marking whether the value can be set through the API and whether the definition itself can be edited.
- HubSpot-defined versus user-defined. The `hubspotDefined` boolean marks system properties, usually prefixed `hs_` and undeletable, separate from custom properties an admin created.
Calculated properties deserve a separate note because their API behavior is inconsistent with what the UI allows. Simple calculated formulas that do not reference other properties can be created directly through the API, but formulas referencing other properties cannot. The documented workaround is building the calculation shell in the HubSpot UI first, including a placeholder formula, then patching the real `calculationFormula` field through the API afterward. Only one non-number property can appear in a formula, and built-in fields like `hs_object_id` are off limits entirely.
Rollup properties add another layer of ambiguity. A HubSpot-defined rollup property might declare `type: number` in its schema but return a semicolon-delimited string at runtime when multiple values roll up together. Pipelines that assume strict type consistency will eventually break on one of these fields.
Associations Between HubSpot Objects
Associations are the mechanism that connects a contact to a company, a deal to a set of line items, or a ticket to the company that filed it. HubSpot’s current association model, version 4 of the Associations API, replaced an older version that lacked labels entirely. Migrating any new integration work to V4 is the right call, since V3 is documented purely as legacy reference.
Every association carries a category and a label. Primary marks the main related record for list and workflow purposes, and Unlabeled is present by default whenever two records are linked with no other label attached. Custom labels come in two styles: single labels using the same word in both directions, like “Partner,” and paired labels using different words per direction, like Manager and Employee. HubSpot enforces up to 10 association types per object pair, though third-party sources cite higher figures, worth confirming against the live portal before assuming a hard number.
All associations in HubSpot are structurally many-to-many, even in cases like Contact to Company where the practical behavior looks many-to-one because only one company can hold the Primary label per contact at a time. Retrieving association labels happens through `GET /crm/v4/associations/{fromObjectType}/{toObjectType}/labels`, which returns each label’s category as either HubSpot-defined or user-defined along with a numeric type ID used when creating the association.
A few association type IDs are worth knowing for common pairs: Contact to Company Primary is `2`, Deal to Company Primary is `5`, and Deal to Line item is `19`. Pulling labels dynamically at runtime through the labels endpoint is more resilient than hardcoding IDs, since custom labels vary by portal.
For bulk relationship pulls, `POST /crm/v4/associations/{fromObjectType}/{toObjectType}/batch/read` accepts up to 1,000 IDs per call and is the right tool for reconstructing full relational bridge tables. The `associations` parameter on object GET calls is not supported on batch reads and is meant for small, targeted lookups on individual records instead.
Record Identifiers and Portal-Level IDs
Every HubSpot record carries an `hs_object_id`, generated automatically at creation. It looks numeric but should be treated as a string in code, and it is only unique within its own object type, so a Contact and a Company can share the same numeric ID without conflict.
Beyond the record ID, a few other identifiers show up constantly in extraction work. The `portalId`, also called the Hub ID, identifies the HubSpot account and appears in the app URL, in webhook payloads, and in custom-object `fullyQualifiedName` strings formatted as `p{Hub_ID}_{object_name}`. It is retrievable through the Account Information API at `GET /integrations/v1/me`. A separate `user_id` identifies the specific HubSpot user tied to an OAuth token, with no numeric relationship to the hub_id. The `hubspot_owner_id` property stores which user owns a record, and owner lookups should go through the paginated `GET /crm/v3/owners` endpoint rather than the deprecated, unpaginated v2 version.
Extracting Data With the CRM API
HubSpot gives engineers three main paths for pulling data out of the CRM, and each fits a different extraction scenario. The Search API handles filtered subsets, the List and Batch endpoints handle full or ID-based crawls, and the Exports API handles portable file generation for large or one-time pulls.
Search runs through `POST /crm/objects/{version}/{object}/search` and supports filter operators including `EQ`, `NEQ`, range comparisons, `IN`/`NOT_IN`, and token-based text matching with wildcard support. It allows up to five filter groups, six filters per group, and eighteen filters total, with a maximum page size of 200 and a hard ceiling of 10,000 total results per query. Paging past that ceiling returns a 400 error rather than more data, so Search is unsuitable for extracting an entire large object end to end. The endpoint is also throttled separately from the rest of the API, at 5 requests per second per account, and its responses skip the standard rate-limit headers.
List and Batch endpoints work differently. `GET /crm/objects/{objectTypeId}` supports cursor-based pagination through an `after` token with up to 100 records per page and no upper bound on total results, making it the better choice for a full incremental crawl. Batch read at `POST /crm/objects/{objectTypeId}/batch/read` accepts up to 100 record IDs per call and supports lookup by a custom `idProperty`.
The Exports API takes a different shape, built around an async job pattern rather than direct pagination. A request to `POST /crm/exports/{version}/export/async` kicks off either a VIEW export, built from a search-style filter definition, or a LIST export tied to an existing HubSpot list. Status checks poll a task endpoint, and a completed job returns a download URL that expires five minutes after issuance. Files exceeding a million rows in CSV or XLSX get split into a ZIP automatically, and accounts are capped at 30 exports per rolling 24-hour window with only one export running at a time.
Choosing Between Search, List, and Exports
Picking the right extraction path comes down to two questions: how much data needs to move, and how much filtering logic is required to select it.
| Need | Best API | Why it fits |
|---|---|---|
| Full historical backfill or one-time bulk export | Exports API | No 10,000 record cap, async, handles millions of rows via ZIP delivery |
| Targeted filtered subset under 10,000 results | Search API | Rich filter and sort logic, capped and rate-limited to 5 requests per second |
| Full incremental crawl, code-managed pagination | List (GET) with `after` | Simple cursor pagination, up to 100 per page, no result ceiling |
| Already have a batch of known record IDs | Batch Read | Up to 100 IDs per request, supports custom `idProperty` |
| Relationship or association data | Associations v4 batch read | Purpose-built, V3 is legacy and lacks the label model |
| Metadata for dynamic schema mapping | Schemas API plus Properties API | Required to drive dynamic ingestion for custom objects |
Rate Limits and Scale Considerations
Rate limits vary by subscription tier and app distribution type, applying at two levels: a short burst window and a daily account-wide cap. Enterprise accounts using privately distributed apps get 190 requests per 10 seconds and 1,000,000 per day, while Free and Starter accounts are capped at 100 per 10 seconds and 250,000 per day. The burst limit applies per app, but the daily cap is shared across every app on the account.
The Search API’s 5 requests per second limit sits outside this general scheme, and the Associations and GraphQL APIs carry stricter limits without a published number. Testing against the live account is the only reliable way to know the real ceiling. Practitioners extracting millions of records report that naive single-threaded pagination routinely times out or gets throttled. Common mitigations include batching, running multiple API keys in parallel, and caching responses locally.
Incremental Extraction Patterns
Most standard and engagement objects expose an `hs_lastmodifieddate` property that works well as an incremental cursor, while Contacts also support the legacy `lastmodifieddate` field. A common scale pattern sorts by HubSpot ID and filters with something like `readHubspotIdBiggerThan`, incrementing that value with each page. This approach only holds up if the underlying dataset stays fixed for the run. Records changing mid-run call for a continuously syncing tool instead of a one-shot cursor crawl.
Handling deletes is the part incremental pipelines get wrong most often. Archived records never appear in Search API results, so any pipeline relying on Search alone will silently drift from the true record count as records get archived. Webhooks fill that gap by emitting deletion events per object type, along with restore and merge events, plus a GDPR-style privacy deletion event that also triggers a normal deletion notification, so pipelines should expect duplicate signals. Merge payloads carry `primaryObjectId` for the surviving record and `mergedObjectIds` for the ones absorbed into it, information that matters for reconciling foreign keys downstream.
Property projection is a smaller but consequential detail. Omitted properties do not appear in the API response at all, rather than showing up as null. Treating an absent field the same as an explicit null can quietly break schema-inference logic in downstream tools that expect every column to be present on every row.
Common Pitfalls in HubSpot Data Extraction
Several mistakes show up repeatedly across production pipelines, tracing back to assumptions that hold for small test datasets but break at real scale.
| Pitfall | Why it happens |
|---|---|
| Hitting the Search API’s 10,000 record cap | Paging past the limit returns a 400 error instead of more data; Search was never designed for full-table extraction |
| Missing archived records | Search silently excludes archived records with no error, causing pipelines to drift from true counts |
| Skipping property history | Standard GET calls return only current values; reconstructing point-in-time state requires `propertiesWithHistory` or dedicated history streams |
| Calculated-property cursor drift | Some calculated fields carry a last-sync timestamp rather than a true last-change timestamp, causing incremental cursors to skip real updates |
| Treating property names as changeable | Internal property names are permanent once created, turning a bad name into long-term technical debt |
| Orphaned records | A contact missing a company association silently drops out of association-dependent workflows with no error surfaced |
Modeling HubSpot Data Downstream
Once data leaves HubSpot, the modeling decisions made in the warehouse matter as much as the extraction method. HubSpot’s `properties` payload arrives as a semi-structured object, and production ETL tools handle it differently. Some flatten every property into its own column, producing easy-to-query tables that can balloon into hundreds of columns and break whenever HubSpot adds a new property. Others leave the JSON unflattened, trading query simplicity for resilience against schema drift.
Associations model cleanly as bridge tables, with each object pair getting its own table keyed by `from_id`, `to_id`, `association_type_id`, `category`, and `label`. This mirrors a standard star-schema junction table and avoids parsing nested association arrays out of every object payload individually. Property history fits a similar append-only pattern, structured as `object_type`, `hs_object_id`, `property_name`, `value`, and `timestamp`, supporting Type 2 slowly changing dimension queries by joining against the current-state table rather than deriving history from a single overwritten row. Anyone comparing modeling patterns for facts and dimensions more broadly will recognize this as a direct application of dimensional data modeling principles applied to a CRM source.
Building and maintaining this pipeline by hand, from schema discovery through associations and property history, is a substantial engineering investment revisited every time HubSpot changes its API version or an admin adds new custom properties. Teams that want HubSpot data in Power BI without owning that burden typically look at a managed option like Power BI Connector for HubSpot, which handles the object, association, and property mapping so analysts can build reports directly against a modeled dataset.
Schema evolution on the source side remains a constant risk regardless of the extraction tool. Pipelines that treat schema drift as an expected event rather than an exception tend to survive HubSpot’s API changes with far less rework. The same logic applies to the extraction layer itself: choosing the right data pipeline pattern up front determines how much rework a HubSpot integration needs every time the CRM schema shifts underneath it.
HubSpot Data Model FAQ
What is the difference between HubSpot objects, properties, and associations?
Objects are record types like Contacts or Deals, properties are the individual fields on each record, and associations are the links connecting records across objects, functioning much like foreign keys in a relational database.
Can I extract more than 10,000 records with the Search API?
No. The Search API returns a 400 error once a query’s total matching results exceed 10,000, regardless of page size. Use the Exports API or List endpoint with `after` pagination instead.
How do I detect deleted records in HubSpot?
Search results never include archived records, and List endpoints require an explicit archived-state query to surface them. Webhooks provide the most reliable deletion signal through dedicated deletion, merge, and restore events per object type.
Is Associations API V3 still usable?
It still functions but is documented as legacy. V3 lacks the label and category schema introduced in V4, along with the batch endpoints needed for labeled associations at scale. New integrations should target V4 directly, a decision worth pairing with a broader enterprise BI tool evaluation framework when deciding how HubSpot data ultimately reaches reporting tools.