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 throughPOST /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 a409 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
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.