> ## Documentation Index
> Fetch the complete documentation index at: https://docs.twinbay.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# HubSpot

> Contacts, companies, deals and associations — with the search index one step behind the write

The HubSpot CRM over the API an integration actually calls: contacts, companies,
deals, notes and tasks with string-typed properties, associations written in both
directions, batch writes that can come back part-failed, and a search index that is
behind the writes until the sandbox is advanced.

**Slug:** `hubspot`

## The Attio twin already covers "a CRM"

So this one is not another object-and-record model. It is the three behaviours a
HubSpot test portal cannot give you.

### 1. The search index lags the write

A contact created through `POST /crm/v3/objects/contacts` is not returned by
`POST /crm/v3/objects/contacts/search` until HubSpot has indexed it. Agents that
create a record and then search for it work on a developer's machine and fail in
production — and you cannot ask HubSpot to be slow on demand, so there is no way to
test the branch against a real portal.

Here the lag is state. **The default is one advance**, so create-then-search fails
out of the box; `POST /__twin__/v1/advance` closes the gap, and `set_search_lag`
makes it longer or turns it off for a test that does not want the behaviour. The
record is readable by id the whole time: it exists, only the index is behind.

### 2. Everything in `properties` is a string

Numbers, booleans, enumerations, datetimes — all of them. `"17"`, not `17`;
`"true"`, not `true`. A caller that assumes typed JSON breaks against HubSpot, and a
twin that returned typed JSON would break the pinned SDK, which annotates the map
`dict[str, str]`. Values are coerced to the string form their property's type has
and stored that way, so the round trip is stable in both directions.

### 3. Duplicate contacts and batch partial failures

A contact create carrying an email another contact holds is a `409` whose message
names the existing record's id — integrations parse it out to recover, which is why
it is load-bearing. And a batch can answer **`207` multi-status** with some rows
written and some refused, which the pinned SDK deserializes into a *different model*
from the success: a caller that only handled the success shape reads an
`AttributeError` rather than a failure, and because `207` is a `2xx`, nothing
raises. Neither is reachable on demand in a shared portal.

## Two versions in the paths is not a mistake

HubSpot versions its CRM **by path and by nothing else** — no dated version, no
version header. Objects, properties, owners and pipelines are `/crm/v3`;
associations are `/crm/v4`. The manifest publishes `v3+v4` so this is on the record
rather than looking like a typo.

## What it models

| Area                 | Modelled                                                                                                                                                                                                                                                                                                                                                            |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The portal           | One HubSpot account per container, configured and empty the way a fresh portal with a private app is: two owners, two deal pipelines with their stages — `default` with HubSpot's own seven and `enterprise` with three, so a stage from the wrong pipeline is reachable — and the property definitions of all five object types                                    |
| Authentication       | `Authorization: Bearer pat-na1-…` and nothing else, because the pinned SDK builds no other presentation. `401 INVALID_AUTHENTICATION` for a missing header, a scheme that is not `Bearer`, a token that is not shaped like one and a well-formed token nothing recognises                                                                                           |
| Scopes               | HubSpot's granular scopes as container state, with `403 MISSING_SCOPES` naming what the call needed in both places HubSpot puts them. Notes and tasks are covered by the *contacts* scopes, which is HubSpot's own grouping rather than an omission                                                                                                                 |
| Rate limits          | `429 RATE_LIMIT` with the `X-HubSpot-RateLimit-*` headers, a `Retry-After` and the `policyName` a caller branches on. The window is configurable rather than a claim about a subscription tier, because HubSpot's limits differ by tier — and the budget headers ride on every answer, not only the refusal                                                         |
| Objects              | Create, read, update, archive and list for contacts, companies, deals, notes and tasks, with `idProperty` lookups that address a contact by email address, `properties` and `propertiesWithHistory` selection, inline `associations` on create, and the `associations` expansion on a read                                                                          |
| Properties           | The full string coercion per type, unknown property names refused rather than stored, read-only and calculated properties refusing a write, enumeration options enforced, and a custom property created through `POST /crm/v3/properties/{type}` validated exactly like a stock one                                                                                 |
| Archiving            | HubSpot's delete is a soft archive, so "never existed" and "was deleted" are different states: a plain read is `404`, `archived=true` finds it, an archived record refuses a `PATCH`, and its email address is still taken                                                                                                                                          |
| Associations         | `v4` in both directions from one call, HubSpot's own defined type ids — contact→company `279` with its `Primary` label `1`, and the inverse pair `280` and `2` — the default endpoint writing the most generic type, labelled writes by id, a type id that does not exist for the pair refused, and a company's associated-contact count derived rather than stored |
| Search               | `filterGroups` ORed across groups and ANDed within one, every operator in HubSpot's enum, `sorts` in both spellings HubSpot answers to, numeric and datetime properties compared as numbers rather than as text, and the paging ceiling past which HubSpot refuses rather than answering an empty page                                                              |
| Batch                | `batch/create` at `201`, `batch/read` with a body-level `idProperty`, `batch/update`, `batch/upsert` with its `new` flag, `batch/archive` at `204`, the hundred-record cap refused *before anything is written*, and `207` per-row failures a directive can arm on demand                                                                                           |
| Owners and pipelines | Read-only, because a deal cannot be created without a stage and a record cannot be assigned without an owner. A pipeline carries its stages inline, the default pipeline reports the epoch as its create date, and a write to either is refused as not modelled rather than as missing                                                                              |
| Paging               | Forward-only, with `after` as the *position* it actually is — the id of the next record to read, in `hs_object_id` ascending order — `paging` **absent** on the last page rather than empty, a `limit` above the maximum refused rather than clamped, and `paging.next.link` built on the container's own host so a paginating client stays inside the sandbox      |

Advancing the sandbox steps five things, in order: the search index catches up by
one, any rate-limit window expires, deals move one stage along their own pipeline
stamping the stage's entered date, contacts move one step along the lifecycle stage
sequence stamping its date, and tasks past their due date become overdue. All five
are finite, so advancing repeatedly settles rather than reporting forever.

## Idempotency

HubSpot has **no idempotency keys**. It has two other mechanisms and both are
modelled: `batch/upsert` with an `idProperty`, where a replay updates rather than
duplicating and `new` says which happened; and the duplicate-contact `409` naming
the existing id, which is how a caller recovers without one. A replay of any other
create makes a second record, and so does this twin.

## Scenarios

`search-lagging`, `crm-with-pipeline`, `paged-contacts`, `throttled`,
`missing-scope`, `duplicate-contact`, `archived-contact`.

## Not modelled

OAuth app installs and token refresh, webhooks and the CRM card and extension
surfaces, Marketing Hub, tickets, line items, products, quotes and custom objects,
the timeline API, imports and exports, `merge` and `gdpr-delete`, the `v4`
association *batch* surface, association labels and limits, property groups and the
property batch endpoints, and HubSpot lists.

An endpoint HubSpot has and this twin has not built is refused `501` with
`category: "NOT_MODELED"` — never `404 OBJECT_NOT_FOUND`, which means a *record*
does not exist and which a caller could reasonably read as an endpoint that was
retired.

There is **no live HubSpot portal behind this twin**. Every path, status, required
member and page size came from the pinned `hubspot-api-client` 12.0.0 and HubSpot's
own published OpenAPI documents; the association type ids came from HubSpot's
association-details reference. The twin's README records every decision the sources
left open and which artefact settled the rest.
