@hubspot/api-client (14.0.1).
At a glance
Start
--service. When it is the only enabled service, it listens on http://localhost:4000.
Without seed config, the account has the admin owner [email protected] and the default deal and ticket pipelines, and any token the CLI accepts acts with every scope.
Point the official client at the emulator with basePath:
URL mapping
Auth
SendAuthorization: Bearer <token>, as HubSpot takes private app and OAuth tokens.
- Private app tokens come from
private_appsin the seed. Each works only for the operations its scopes allow. A missing scope gets HTTP 403 with categoryMISSING_SCOPESand the scopes that would have worked inerrors[0].context.requiredGranularScopes, as HubSpot answers. - OAuth access tokens come from the OAuth flow and work for the scopes the install granted, checked the same way. They last 30 minutes; an expired one gets HTTP 401 with category
EXPIRED_AUTHENTICATIONand HubSpot’s messageThe OAuth token used to make this call expired N second(s) ago. - Any other token the CLI accepts acts with every scope, so a quick start needs no setup.
INVALID_AUTHENTICATION. The CRM APIs do not take the legacy hapikey query parameter, as HubSpot no longer does; only the webhooks API takes it, for the developer account’s key.
Scopes per operation:
POST /oauth/v2/private-apps/get/access-token-info with { "tokenKey": "<token>" } returns userId, hubId (the seeded portal.id), appId, and scopes for a private app token, and HTTP 404 for any other token. It needs no other credentials.
OAuth
Public apps install through OAuth, as HubSpot’s OAuth guide describes. Seed them underoauth_apps with a client id, secret, redirect URLs, required scopes, and optional_scopes.
Install page. GET /oauth/authorize (or /oauth/{accountId}/authorize for one account) takes client_id, redirect_uri, scope (space separated), optional_scope, and state. The client’s oauth.getAuthorizationUrl() always builds an https://app.hubspot.com URL, so swap its origin for the emulator’s. The page lists the scopes and the account’s active users; choosing one redirects to redirect_uri with code and state, and declining redirects with error=access_denied. These show an error page instead of redirecting:
- an unknown
client_id, or aredirect_urithat is not one of the app’s redirect URLs (compared by origin and path, ignoring a trailing slash); - an account id in the path other than the seeded
portal.id; - scopes that do not fit the app:
scopemust include every required scope and nothing outside the app’s required and optional scopes, andoptional_scopeonly the app’s scopes. This page uses HubSpot’s wording,Authorization failed because there is a mismatch between the scopes in the install URL and the app's configured scopes.
oauth plus every scope asked for, optional ones included, since the emulated account has every product. Codes look like HubSpot’s (na1- and a UUID), work once, and last ten minutes; HubSpot only says the window is short.
Tokens. POST /oauth/v1/token (the client’s oauth.tokensApi.create()) and POST /oauth/v3/token take a form encoded body with grant_type authorization_code (code, redirect_uri, client_id, client_secret) or refresh_token (refresh_token, client_id, client_secret). Both answer token_type: "bearer", access_token, refresh_token, and expires_in: 1800; v3 adds hub_id and scopes. Exchanging a code installs the app. Refresh tokens do not expire or rotate: a refresh answers with the same refresh token, and earlier access tokens keep working until they expire.
Token errors are HTTP 400 with RFC 6749 fields plus HubSpot’s status and message, as HubSpot documents for a bad refresh token:
HubSpot publishes only the refresh token wording; the other messages follow its pattern and are Baseline’s.
Token info.
GET /oauth/v1/access-tokens/{token} returns token, user (the installing user’s email), hub_domain (the seeded portal.domain), scopes, signed_access_token, hub_id, app_id, expires_in, user_id, and token_type: "access". GET /oauth/v1/refresh-tokens/{token} returns the refresh token’s client_id, user, account, and scopes with token_type: "refresh". A token that is unknown, expired, or revoked gets HTTP 404. DELETE /oauth/v1/refresh-tokens/{token} deletes only the refresh token; its access tokens keep working and the app stays installed. POST /oauth/v3/token/introspect takes the client credentials, token_type_hint, and the token in a form, and answers with HubSpot’s fields (active, token_use, token_type: "Bearer", and for access tokens signed_access_token and expires_in), or { "active": false } for a token that is unknown, dead, or another app’s. The signature fields inside signed_access_token are opaque in HubSpot’s docs; Baseline fills them with HMACs of the token’s claims.
Uninstall. DELETE /appinstalls/v3/external-install with the app’s access token uninstalls it: every token the app holds stops working and its webhooks stop.
With no oauth_apps seeded, any client id is accepted on the install page and registered with its secret on its first token exchange, so a local app works without setup. Seeding any app switches on every check above.
Objects
Every type is served at/crm/v3/objects/{objectType}, where objectType is contacts, companies, deals, or tickets, the singular (contact), or the type id (0-1, 0-2, 0-3, 0-5). The client’s crm.contacts, crm.companies, crm.deals, crm.tickets, and crm.objects APIs all work. An unknown type gets HTTP 400 Unable to infer object type from: <type>.
GET /crm/v3/objects/{objectType}lists records by id withlimit(default 10, at most 100),after,properties,propertiesWithHistory, andarchived.paging.next.afteris the id of the next record andpaging.next.linkthe full URL, so the client’sgetAllwalks every page.POST /crm/v3/objects/{objectType}creates a record (HTTP 201) and answers with every property that has a value.associationslinks it to existing records in the same request (see Associations).GET /crm/v3/objects/{objectType}/{id}reads one, withproperties,propertiesWithHistory,associations,archived, andidProperty.PATCH /crm/v3/objects/{objectType}/{id}updates one (idPropertyworks here too). An empty string clears a value.DELETE /crm/v3/objects/{objectType}/{id}archives one (HTTP 204, also for ids that do not exist). Archived records read only witharchived=trueand keeparchivedAt.
associations on a read or list (associations=companies,deals) adds an associations member keyed by object, each { results: [{ id, type }] } with one entry per association type in v3 names (contact_to_company, contact_to_company_unlabeled). Objects without associations are left out.
Reads return the type’s default properties unless properties names others (repeated or comma separated). Named properties always come with createdate, hs_object_id, and the last modified date. Unknown names are left out; defined properties without a value are null. propertiesWithHistory lists each value newest first with sourceType: "API".
The default properties are createdate, email, firstname, hs_object_id, lastmodifieddate, lastname for contacts; createdate, domain, hs_lastmodifieddate, hs_object_id, name for companies; amount, closedate, createdate, dealname, dealstage, hs_lastmodifieddate, hs_object_id, pipeline for deals; and content, createdate, hs_lastmodifieddate, hs_object_id, hs_pipeline, hs_pipeline_stage, hs_ticket_category, hs_ticket_priority, subject for tickets.
Values
Values are strings, checked against the property’s definition:numbervalues must be numeric (INVALID_INTEGER),boolvaluestrueorfalse(INVALID_BOOLEAN).datevalues takeYYYY-MM-DDor a UTC midnight timestamp in milliseconds and are stored asYYYY-MM-DD;datetimevalues take ISO 8601 or milliseconds and are stored as ISO strings (INVALID_DATE).- Enumerations take one of their option values (
INVALID_OPTION). Multiple checkbox properties take values separated by semicolons; a leading semicolon (;b;c) adds to the current values instead of replacing them. - A contact’s
emailmust look like an address (INVALID_EMAIL) and is stored in lower case.hubspot_owner_idmust be an active owner’s id (INVALID_OWNER_ID). - Unknown properties get
PROPERTY_DOESNT_EXIST, and HubSpot’s own computed values (hs_object_id, the last modified date, deal closed state) getREAD_ONLY_VALUE. lifecyclestageonly moves forward (subscriber, lead, marketing qualified lead, sales qualified lead, opportunity, customer, evangelist, other). Clear it first to set an earlier stage.
Property values were not valid: [...] with one JSON entry per property, and an errors list with each code and context.propertyName. Nothing is written when any value is bad.
Unique values
A contact’semail and any custom property with hasUniqueValue are unique among live records of the type: taking a value another record has gets HTTP 409 Contact already exists. Existing ID: <id> (the label follows the type). These properties can identify records through idProperty in reads, updates, batch reads and updates, and upserts. Other properties used as idProperty get HTTP 400.
Deals and tickets
Deals and tickets live in a pipeline. Withoutpipeline (deals) or hs_pipeline (tickets), a new record goes into the first pipeline, default for deals and 0 for tickets. A stage (dealstage, hs_pipeline_stage) must belong to the record’s pipeline, also when the pipeline changes.
- Tickets need
hs_pipeline_stage: without it, HTTP 400Some required properties were not set.withcontext.properties. Entering a stage whoseticketStateisCLOSEDsetsclosed_date; reopening clears it. - Deals get
hs_deal_stage_probability,hs_is_closed, andhs_is_closed_wonfrom the stage. Entering a closed stage setsclosedatewhen it is empty.
Batch
POST /crm/v3/objects/{objectType}/batch/{create,read,update,upsert,archive} take up to 100 inputs and answer { status: "COMPLETE", results, startedAt, completedAt }.
- create (HTTP 201) is all or nothing, unless every input carries an
objectWriteTraceId: then valid inputs are created and the response is HTTP 207 withnumErrorsand anerrorsentry per failed input, its trace id incontext.objectWriteTraceId, as HubSpot documents. - read takes
properties,propertiesWithHistory,idProperty, and thearchivedquery parameter. Missing ids give HTTP 207 with oneOBJECT_NOT_FOUNDerror listing them incontext.ids. - update refuses repeated ids (
Duplicate IDs found in batch input), reports missing ids like read, and writes the rest; bad values fail the batch unless every input has a trace id. - upsert takes
idPropertyandidper input (a unique property other thanhs_object_id), creates or updates, and marks each result withnew. Upserts byemailare all or nothing; other unique properties allow partial success with trace ids. - archive answers HTTP 204 and skips ids that do not exist.
Search
POST /crm/v3/objects/{objectType}/search (the client’s searchApi.doSearch) answers { total, results, paging } over live records; archived records never appear.
filterGroups: groups are ORed and the filters in a group are ANDed. At most 5 groups, 6 filters in a group, and 18 filters in all. A top levelfilterslist is read as one group.- Operators:
EQ,NEQ,LT,LTE,GT,GTE,BETWEEN(valueandhighValue, both ends included),INandNOT_IN(values),HAS_PROPERTY,NOT_HAS_PROPERTY,CONTAINS_TOKEN, andNOT_CONTAINS_TOKEN. Withoutoperator, a filter isEQ. - Values compare by property type: numbers as numbers, dates and datetimes as instants (milliseconds or ISO 8601), strings without regard to case, enumerations exactly (case matters), as HubSpot documents. A multiple checkbox property matches when any chosen value does.
INandNOT_INmatch string properties only for values given in lower case, as HubSpot documents.CONTAINS_TOKENmatches a whole word, or with*wildcards the whole value or any word (*@example.com).NEQ,NOT_IN, andNOT_CONTAINS_TOKENmatch records with no value; the other comparisons never do.associations.{object}(for exampleassociations.contact) matches records associated with the given record id, withEQorIN.querylooks for every word in the type’s default searchable properties:firstname,lastname,email,phone,mobilephone, andcompanyfor contacts;website,phone,name, anddomainfor companies;dealname,pipeline,dealstage,description, anddealtypefor deals;subject,content,hs_pipeline_stage, andhs_ticket_categoryfor tickets. At most 3,000 characters.sortstakes one sort:{ propertyName, direction: "ASCENDING" | "DESCENDING" }, or a property name as a string ("-name"for descending), since the client types sorts as strings. Records without the value come last. Without a sort, records come oldest first.limitdefaults to 10 and may be at most 200;afteris the offset frompaging.next.after. Results stop at 10,000.propertiespicks the properties returned, as reads do.
VALIDATION_ERROR. These messages are Baseline’s.
Associations
Records of any two of the four objects can be associated, including records of the same object. Each association holds one or more association types in each direction: HubSpot’s unlabeled type, always present, plus labels such asPrimary. Adding a label adds the unlabeled type; removing the unlabeled type removes the association. Every association is stored both ways, so reading from either record finds it with the inverse type. Associations with archived records are hidden from reads.
HubSpot’s types between the four objects:
A record can have one primary company and one parent company: adding the label to another pair takes it from the old one, which keeps its unlabeled association.
v4
PUT /crm/v4/objects/{fromType}/{fromId}/associations/default/{toType}/{toId}adds the unlabeled association and answers with both directions.PUT /crm/v4/objects/{type}/{id}/associations/{toType}/{toId}with[{ associationCategory, associationTypeId }]sets the pair’s labels (HTTP 201{ fromObjectTypeId, fromObjectId, toObjectTypeId, toObjectId, labels }). The labels sent replace the pair’s labels; send the current ones too to keep them.GET /crm/v4/objects/{type}/{id}/associations/{toType}lists{ toObjectId, associationTypes: [{ category, typeId, label }] }by record id, withlimit(at most 500) andafter.DELETE /crm/v4/objects/{type}/{id}/associations/{toType}/{toId}removes every type between the two records.POST /crm/v4/associations/{fromType}/{toType}/batch/create(labels, replacing as above),batch/associate/default,batch/archive(whole associations), andbatch/labels/archive(some types) take up to 2,000 inputs;batch/readtakes up to 1,000 and answers HTTP 207 with acrm.associations.NO_ASSOCIATIONS_FOUNDerror for each record without associations. Batch create reports a missing record or a bad type per input (HTTP 207).
fromObjectId and toObjectId are numbers. A type that does not lead between the two objects, a missing or archived record (HTTP 404), and a record associated with itself are refused.
Labels and limits
GET/POST/PUT /crm/v4/associations/{fromType}/{toType}/labelsandDELETE .../labels/{associationTypeId}(also under/crm/associations/v4). A new label takesname(letters, digits, and underscores, not starting with a digit),label, and for a paired labelinverseLabel. Each direction gets its ownUSER_DEFINEDtype id, from 100 up; a single label between records of one object has one id. Ten labels per pair of objects; a repeated name gets HTTP 409. HubSpot’s types cannot be changed or deleted, and a label in use cannot be deleted.- Limits:
POST /crm/v4/associations/definitions/configurations/{fromType}/{toType}/batch/{create,update,purge}with{ category, typeId, maxToObjectIds },GET .../configurations/{fromType}/{toType}(every type, withuserEnforcedMaxToObjectIdswhere set), andGET .../configurations/all(the limits set). An association that would pass a limit, on either record, gets HTTP 400 withsubCategorycrm.associations.ASSOCIATION_LIMIT_EXCEEDED.
Associations on create
POST /crm/v3/objects/{objectType} and batch/create take associations: [{ to: { id }, types: [{ associationCategory, associationTypeId }] }]. The type ids say which object to is. Every association is checked before the record is written, so a missing record or a bad type creates nothing (in a batch with trace ids, only that input fails).
v3 (legacy)
GET /crm/v3/associations/{fromType}/{toType}/typeslists{ id, name }in v3 names: the primary type has the plain name (contact_to_company) and the unlabeled type ends in_unlabeled; a custom label uses itsname.POST /crm/v3/associations/{fromType}/{toType}/batch/{create,read,archive}with{ from, to, type }, wheretypeis a v3 name or a type id. Read lists one{ id, type }per association type.GET /crm/v3/objects/{type}/{id}/associations/{toType}, andPUT/DELETE .../associations/{toType}/{toId}/{associationType}.PUTanswers with the record and its associations of that object.
Properties
GET/POST /crm/v3/properties/{objectType}andGET/PATCH/DELETE /crm/v3/properties/{objectType}/{propertyName}.GETtakesarchivedandproperties(names to return).POST /crm/v3/properties/{objectType}/batch/{create,read,archive}. Batch create answers HTTP 207 with an error per refused definition; batch read reports missing names.- Groups:
GET/POST /crm/v3/properties/{objectType}/groupsandGET/PATCH/DELETE .../groups/{groupName}. A group that still holds properties, and each type’s HubSpot group (contactinformation,companyinformation,dealinformation,ticketinformation), cannot be archived.
hubspotDefined: true), which can be read but not changed or archived. Pipeline and stage properties list the current pipelines and stages as options. Custom properties need name (lowercase letters, digits, and underscores, not starting with hs_), label, type, fieldType, and groupName. The type and field type pair must be one HubSpot allows; enumerations need options with unique values; calculation_equation needs calculationFormula. An object can have ten custom unique properties. A name that exists, archived or not, gets HTTP 409 Property named '<name>' already exists. Archived properties no longer take or return values.
Pipelines
GET/POST /crm/v3/pipelines/{objectType}andGET/PUT/PATCH/DELETE /crm/v3/pipelines/{objectType}/{pipelineId}.- Stages:
GET/POST .../{pipelineId}/stagesandGET/PUT/PATCH/DELETE .../stages/{stageId}. - Audits:
GET .../{pipelineId}/auditandGET .../stages/{stageId}/audit, newest first, withaction,message, and the pipeline or stage asrawObject. The client’s stage audit method sends{pipelineId}unfilled; the emulator finds the stage’s pipeline.
default (Sales Pipeline) has HubSpot’s stages appointmentscheduled through closedwon and closedlost; the default ticket pipeline 0 (Support Pipeline) has stages 1 to 4, with 4 closed.
Deal stages need metadata.probability from 0.0 (closed lost) to 1.0 (closed won); isClosed follows unless given. Ticket stages take metadata.ticketState OPEN (the default) or CLOSED. Stage labels are unique within a pipeline, which has at most 100 stages. PUT replaces the stages and keeps the id of a stage with the same label. Deleting a pipeline or dropping stages with validateReferencesBeforeDelete=true while records use them gets HubSpot’s documented HTTP 400 (PipelineError.STAGE_ID_IN_USE, Stage IDs: [...] are being referenced by object IDs: [...]); deleting a stage that records use always gets it. The last pipeline of a type and the last stage of a pipeline cannot be deleted. PATCH with archived: false restores a deleted pipeline.
Owners
GET /crm/v3/owners(also with the trailing slash the client sends) withemail,after,limit(default 100, at most 500), andarchived.GET /crm/v3/owners/{ownerId}withidProperty(idoruserId) andarchived.
[email protected], always exists. Deactivated owners (archived: true in the seed) list only with archived=true, with userId: null and their id in userIdIncludingInactive.
Errors
Errors use HubSpot’s body:status: "error", message, a fresh correlationId, category (VALIDATION_ERROR, OBJECT_NOT_FOUND, CONFLICT, OBJECT_ALREADY_EXISTS, MISSING_SCOPES, INVALID_AUTHENTICATION), and errors, context, links, or subCategory where HubSpot sends them. A record that does not exist gets HTTP 404 resource not found, or Object not found. objectId are usually numeric. for a non-numeric id. Where HubSpot’s wording is not published (for example property definition checks and pipeline stage rules), the messages are Baseline’s.
Webhooks
The webhooks API configures an app’s target URL and subscriptions with the developer account’s key in?hapikey (the client’s developerApiKey option). Seed the key as developer_api_key; with none seeded, any key works. A wrong key gets HTTP 401 with HubSpot’s message, and an app id that is not a seeded or registered app gets HTTP 404.
Subscriptions are checked as HubSpot describes: the event type must be one the guide lists (contact, company, deal, ticket, product, line item, and conversation events), the app must be configured with the scope it needs (
crm.objects.<type>.read, tickets for tickets, e-commerce for products), hs_lastmodifieddate and num_unique_conversion_events cannot be watched, and an app holds at most 1000 (HubSpot’s documented 400 body). A second subscription for the same event and property gets HTTP 409, which is Baseline’s rule.
Deliveries. When a request changes the CRM, its events go out before the response is sent: one POST per app, with up to 100 events per request, to apps that are installed (a code was exchanged, or installed: true in the seed), have a target URL, and hold an active subscription for the event. These events fire:
Each event has HubSpot’s fields:
objectId, propertyName and propertyValue for property changes, changeSource (always API), eventId, subscriptionId, portalId, appId, occurredAt (milliseconds), subscriptionType, and attemptNumber. Association events carry associationType (such as CONTACT_TO_COMPANY), fromObjectId, toObjectId, associationRemoved, and isPrimaryAssociation instead of objectId.
Signatures. Every request carries both signatures HubSpot sends for CRM events: X-HubSpot-Signature with X-HubSpot-Signature-Version: v1 (SHA-256 hex of the client secret followed by the body) and X-HubSpot-Signature-v3 with X-HubSpot-Request-Timestamp (base64 HMAC-SHA256 of POST, the target URL, the body, and the timestamp, keyed with the client secret). Check them with the client:
attemptNumber, and fresh signatures. HubSpot spreads retries over 24 hours; Baseline waits 1, 2, 4, 8, 16, and 32 seconds and then a minute between tries, so all ten finish in about five minutes. Scheduled retries stop if the app is uninstalled or its settings are deleted, and are not resumed after a restart.
Seed config
domain: hub_domain in OAuth token info; example.com when left outid: optional; generated when left outdeveloper_api_key: the webhooks API’s ?hapikey; any key works when left outapp_id: optional; generated when left outinstalled: installed from the start, so webhooks flow without an OAuth round trip
email, companies on domain or name, deals on dealname, and tickets on subject. Pipelines from the seed join the default ones.
associations names each end by object and a value matched like seeded records are (or the record id), and adds labels by label or type id. association_labels adds custom labels; inverse_label makes a paired one. Both are checked at startup.
oauth_apps are checked at startup as the API checks them: redirect URLs must be http or https, scopes must be scope names, and webhook subscriptions follow the API’s rules (seeded subscriptions start active unless active: false). The seed never sends webhook events.
Inspector
Open the emulator’s root URL for tabs listing contacts, companies, deals, and tickets with their properties, associations with their types in both directions, custom labels, and limits, pipelines and their stages, custom properties, owners with private apps and their scopes, and OAuth apps with their installs, live tokens, webhook targets, subscriptions, and the last 50 webhook requests with each attempt and the events sent.Limits
- OAuth covers public apps installed by the account: user level apps, the client credentials grant, and the webhook journal API are not served, and the date versioned webhooks paths (
/app-webhooks/2026-09/...) are not served. Genericobject.*subscriptions are refused. - Webhook subscriptions to merge, restore, privacy deletion, product, line item, and conversation events are accepted but never fire, since those changes are not modeled.
maxConcurrentRequestsis stored, but deliveries for one app go one at a time. - Search has no phone number normalization, and
querymatches words anywhere in a value rather than by HubSpot’s tokenizer. Records appear in search as soon as they are written. - Association reads on records (
associations=and the v3 record path) are not paged. The date versioned paths (/crm/objects/2026-03/...) and the association usage report are not served. - Only contacts, companies, deals, and tickets exist. Custom objects, activities (calls, emails, meetings, notes, tasks), line items, products, quotes, and leads are not modeled.
- Calculation properties store their formula but never compute a value. Sensitive data properties, merges, GDPR delete, and the recycle bin restore are not modeled.
- Rate limit headers are not sent and requests are never limited.
Conformance
See CONFORMANCE.md for every provider.