Founding keys are opena year free at 6,000 req/hour, for the first 1,000 developersClaim yours
Documentation 29

HubSpot API quickstart · 3 min read

A HubSpot base URL you can point at, with a whole CRM behind it.

31 of 32 read operations are served: companies, contacts, deals and tickets — with list, get-by-id, search, batch read and associations — plus the property catalogue, both pipelines and the owner roster. The live host carries the CRM data itself; the oldest pinned snapshot was frozen before it and answers those rows with a 404 that names the generation. This page says exactly what is here, what is not, and how to tell the two apart.

01Point your client at hubspot.
02Read the CRM objects
03Pin a snapshot for CI

01 / Read this first

The CRM objects, and one 404 that means “not on this snapshot”.

The four CRM object types are served in full — companies, contacts, 0-3 (deals) and tickets — each with its collection, its get-by-id, its search and its batch/read, with paging.next.after cursors, ?properties=, ?propertiesWithHistory= and ?associations= resolving to the records they really link to. Alongside them: the property catalogue and its groups (/crm/v3/properties/…), the deal and ticket pipelines with their stages (/crm/v3/pipelines/…), the owner roster (/crm/v3/owners) and the custom-object schema rows.

The pinned snapshots are a separate promise: a pin never changes, so hubspot-v3-g8 will go on answering that same 404 forever, and hubspot-v3-g11 will go on serving the CRM. That is exactly what a pinned CI run wants — the bytes you tested against do not move.

See the full row-by-row coverage → — every read operation is enumerated, each with the pinned document its shape is validated against.

02 / Base URL + auth

One origin, any token.

Requests go to https://hubspot.sandboxapis.dev, which replaces https://api.hubapi.com. Paths are HubSpot's own — /crm/v3/objects/contacts, /crm/v3/owners, and /crm-object-schemas/v3/schemas for object schemas, which live under their own root rather than under /crm/. Auth is Authorization: Bearer …; any token passes here, and so does none.

request.sh
curl "https://hubspot.sandboxapis.dev/crm-object-schemas/v3/schemas"

Using a key? Keys & rate limits → — a SandboxAPIs key rides the same Bearer slot and lifts the anonymous limit.

03 / A HubSpot quirk we honour

search and batch/read are reads.

HubSpot sends some reads as POST: /crm/v3/objects/contacts/search takes a filter body, and …/batch/read takes a list of ids. Ten paths work that way. This universe is read-only, but those ten are not treated as writes — they are served as the reads they are, never met with the read-only refusal, and the coverage manifest counts them as read operations rather than as rejected writes. A test binds the renderer's allowlist to the manifest's so the two can never disagree.

search is real rather than nominal: all thirteen Filter.operator members (EQ, NEQ, LT/LTE/GT/GTE, BETWEEN, IN/NOT_IN, HAS_PROPERTY/NOT_HAS_PROPERTY, CONTAINS_TOKEN/NOT_CONTAINS_TOKEN), filter groups OR-ed with filters AND-ed inside them, query, sorts and cursor paging. Filters run against the same property values the object read serves, so a filter written against what you can see selects what you expect.

search.sh
curl -X POST "https://hubspot.sandboxapis.dev/crm/v3/objects/0-3/search" \
  -H 'content-type: application/json' \
  -d '{"filterGroups":[{"filters":[{"propertyName":"dealstage","operator":"EQ","value":"closed-lost"}]}],"properties":["dealname","amount","closed_lost_reason"],"sorts":[{"propertyName":"amount","direction":"DESCENDING"}]}'

04 / Property values

Ask the properties API what the values mean.

A HubSpot portal's option sets are portal configuration — which is why the properties API exists and why every serious client reads it before it reads an object. This portal is no different: GET /crm/v3/properties/tickets is the list of every property a ticket can carry, and each enumerated one declares its own options. A deal's dealstage is one of the nine stages GET /crm/v3/pipelines/deals publishes, with the probability and closed-ness of each; a ticket's hs_pipeline_stage is one of the six GET /crm/v3/pipelines/tickets publishes, each marked OPEN or CLOSED.

Those values are this organization's own rather than HubSpot's product defaults, and deliberately so: none of HubSpot's default option strings appears in any document they publish, so serving them would mean printing values transcribed from memory. It also means the value you read here is the same string the Salesforce and Zendesk hosts render for the same underlying record, which is what makes a cross-provider check possible at all.

05 / Provenance

Pinned to HubSpot's own documents, fetched at build time.

Eight OpenAPI documents from HubSpot's public spec collection are the shape of record here — companies, contacts, deals, tickets, owners, properties, pipelines and schemas — each pinned to an exact commit and an exact sha256. They are not committed to our repository: HubSpot publishes no licence file with them, and an absent grant is not a permissive default, so the build fetches them from their pinned URLs and refuses any bytes whose hash is not the pinned one. The guarantee is unchanged — our responses validate against the document HubSpot publishes — only the location of the bytes moved.

One upstream detail you will see in the coverage table: HubSpot's own document writes the deals object type as its numeric id, so the rows read /crm/v3/objects/0-3 rather than /crm/v3/objects/deals. Real HubSpot serves both spellings; the document describes one. We carry the document's.

06 / Read-only + coverage

Every refusal is HubSpot-shaped.

This universe never changes. A write returns HubSpot's own Error envelope at 403, naming what you tried and where the roadmap is. Every error carries a correlationId, as HubSpot's do — ours is derived from the request rather than random, so the same request always reports the same id.

There are four refusals and they say different things, which is the point of having four. A path we do not serve is 404 with x-sandboxapis-coverage-kind: endpoint. A row we do serve on a snapshot without the data is 404 naming the generation, with the coverage pointer but no -kind — nothing is missing from the replica, so it is not counted as demand. A record that does not exist is 404 with no coverage headers at all: a missing resource is not a coverage miss, and your catch (404) should not read as one. A request we cannot honour — a paging cursor from another collection, a search past the documented 200-object maximum — is 400.

refusal.json
{
  "status": "error",
  "message": "This universe predates the CRM canon (companies, contacts, deals, tickets and owners ship with generation hello-14) …",
  "correlationId": "…",
  "category": "VALIDATION_ERROR"
}

Two deviations we would rather you heard from us. Real api.hubapi.com answers an unrouted path with an HTML error page from its edge; we answer the API's own JSON envelope instead, because an HTML body breaks every SDK's parser and carries no machine-readable coverage pointer. And category reads VALIDATION_ERROR because that is the only category value any of the eight pinned documents prints — HubSpot's real vocabulary is wider, the field is required, and we would rather print the one the document shows than invent the one that sounds right.

07 / Pin for CI

A frozen universe, by hostname.

Point CI at https://hubspot-v3-g11.snap.sandboxapis.dev and the bytes stop moving — permanently. This pin was frozen on generation hello-14, the generation the CRM ships with, so the objects on this page resolve there exactly as they do live.

hubspot-v3-g8 is still registered and still older than the CRM: its CRM rows answer the generation 404 and always will. That is a pin doing its job rather than a gap waiting to be filled — each pin was registered under its own name precisely so nothing you have already tested against shifts underneath you.

ci.sh
export HUBSPOT_BASE_URL="https://hubspot-v3-g11.snap.sandboxapis.dev"