Skip to main content
Stateful HubSpot CRM emulation: contacts, companies, deals, and tickets through the CRM objects API v3 with batch create, read, update, upsert, and archive; CRM search with filter groups, sorts, and paging; associations v4 with labels and limits (and the legacy v3 associations API); custom properties and property groups; deal and ticket pipelines with stages and audit history; owners; private app tokens with scopes; OAuth for public apps (install page, authorization codes, access and refresh tokens, token info, introspection, uninstall); and the webhooks API with signed, batched, retried event deliveries. The tests drive it with the official @hubspot/api-client (14.0.1).

At a glance

Start

HubSpot starts only when named in config or with --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

Send Authorization: Bearer <token>, as HubSpot takes private app and OAuth tokens.
  • Private app tokens come from private_apps in the seed. Each works only for the operations its scopes allow. A missing scope gets HTTP 403 with category MISSING_SCOPES and the scopes that would have worked in errors[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_AUTHENTICATION and HubSpot’s message The 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.
Requests without a bearer token get HTTP 401 with category 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 under oauth_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 a redirect_uri that 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: scope must include every required scope and nothing outside the app’s required and optional scopes, and optional_scope only 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.
An install grants 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 with limit (default 10, at most 100), after, properties, propertiesWithHistory, and archived. paging.next.after is the id of the next record and paging.next.link the full URL, so the client’s getAll walks every page.
  • POST /crm/v3/objects/{objectType} creates a record (HTTP 201) and answers with every property that has a value. associations links it to existing records in the same request (see Associations).
  • GET /crm/v3/objects/{objectType}/{id} reads one, with properties, propertiesWithHistory, associations, archived, and idProperty.
  • PATCH /crm/v3/objects/{objectType}/{id} updates one (idProperty works 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 with archived=true and keep archivedAt.
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:
  • number values must be numeric (INVALID_INTEGER), bool values true or false (INVALID_BOOLEAN).
  • date values take YYYY-MM-DD or a UTC midnight timestamp in milliseconds and are stored as YYYY-MM-DD; datetime values 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 email must look like an address (INVALID_EMAIL) and is stored in lower case. hubspot_owner_id must 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) get READ_ONLY_VALUE.
  • lifecyclestage only moves forward (subscriber, lead, marketing qualified lead, sales qualified lead, opportunity, customer, evangelist, other). Clear it first to set an earlier stage.
Bad values get HTTP 400 with HubSpot’s message, 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’s email 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. Without pipeline (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 400 Some required properties were not set. with context.properties. Entering a stage whose ticketState is CLOSED sets closed_date; reopening clears it.
  • Deals get hs_deal_stage_probability, hs_is_closed, and hs_is_closed_won from the stage. Entering a closed stage sets closedate when 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 with numErrors and an errors entry per failed input, its trace id in context.objectWriteTraceId, as HubSpot documents.
  • read takes properties, propertiesWithHistory, idProperty, and the archived query parameter. Missing ids give HTTP 207 with one OBJECT_NOT_FOUND error listing them in context.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 idProperty and id per input (a unique property other than hs_object_id), creates or updates, and marks each result with new. Upserts by email are all or nothing; other unique properties allow partial success with trace ids.
  • archive answers HTTP 204 and skips ids that do not exist.
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 level filters list is read as one group.
  • Operators: EQ, NEQ, LT, LTE, GT, GTE, BETWEEN (value and highValue, both ends included), IN and NOT_IN (values), HAS_PROPERTY, NOT_HAS_PROPERTY, CONTAINS_TOKEN, and NOT_CONTAINS_TOKEN. Without operator, a filter is EQ.
  • 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.
  • IN and NOT_IN match string properties only for values given in lower case, as HubSpot documents.
  • CONTAINS_TOKEN matches a whole word, or with * wildcards the whole value or any word (*@example.com).
  • NEQ, NOT_IN, and NOT_CONTAINS_TOKEN match records with no value; the other comparisons never do.
  • associations.{object} (for example associations.contact) matches records associated with the given record id, with EQ or IN.
  • query looks for every word in the type’s default searchable properties: firstname, lastname, email, phone, mobilephone, and company for contacts; website, phone, name, and domain for companies; dealname, pipeline, dealstage, description, and dealtype for deals; subject, content, hs_pipeline_stage, and hs_ticket_category for tickets. At most 3,000 characters.
  • sorts takes 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.
  • limit defaults to 10 and may be at most 200; after is the offset from paging.next.after. Results stop at 10,000.
  • properties picks the properties returned, as reads do.
An unknown property, operator, or a value that does not suit the property’s type gets HTTP 400 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 as Primary. 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, with limit (at most 500) and after.
  • 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), and batch/labels/archive (some types) take up to 2,000 inputs; batch/read takes up to 1,000 and answers HTTP 207 with a crm.associations.NO_ASSOCIATIONS_FOUND error for each record without associations. Batch create reports a missing record or a bad type per input (HTTP 207).
Object types in these paths may be plural, singular, or type ids. As in HubSpot’s v4 responses, 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}/labels and DELETE .../labels/{associationTypeId} (also under /crm/associations/v4). A new label takes name (letters, digits, and underscores, not starting with a digit), label, and for a paired label inverseLabel. Each direction gets its own USER_DEFINED type 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, with userEnforcedMaxToObjectIds where set), and GET .../configurations/all (the limits set). An association that would pass a limit, on either record, gets HTTP 400 with subCategory crm.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}/types lists { 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 its name.
  • POST /crm/v3/associations/{fromType}/{toType}/batch/{create,read,archive} with { from, to, type }, where type is a v3 name or a type id. Read lists one { id, type } per association type.
  • GET /crm/v3/objects/{type}/{id}/associations/{toType}, and PUT/DELETE .../associations/{toType}/{toId}/{associationType}. PUT answers with the record and its associations of that object.
Where HubSpot’s wording is not published (limits, refused types, missing records), the messages are Baseline’s.

Properties

  • GET/POST /crm/v3/properties/{objectType} and GET/PATCH/DELETE /crm/v3/properties/{objectType}/{propertyName}. GET takes archived and properties (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}/groups and GET/PATCH/DELETE .../groups/{groupName}. A group that still holds properties, and each type’s HubSpot group (contactinformation, companyinformation, dealinformation, ticketinformation), cannot be archived.
Every type starts with a set of HubSpot’s own properties (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} and GET/PUT/PATCH/DELETE /crm/v3/pipelines/{objectType}/{pipelineId}.
  • Stages: GET/POST .../{pipelineId}/stages and GET/PUT/PATCH/DELETE .../stages/{stageId}.
  • Audits: GET .../{pipelineId}/audit and GET .../stages/{stageId}/audit, newest first, with action, message, and the pipeline or stage as rawObject. The client’s stage audit method sends {pipelineId} unfilled; the emulator finds the stage’s pipeline.
Only deals and tickets have pipelines. The default deal 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) with email, after, limit (default 100, at most 500), and archived.
  • GET /crm/v3/owners/{ownerId} with idProperty (id or userId) and archived.
An admin owner, [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:
HubSpot uses the v2 signature only for workflow webhook actions and CRM cards, which the emulator does not serve, so deliveries do not carry it. Retries. A request that fails to connect, takes longer than five seconds, or gets a 4xx or 5xx answer is retried up to ten times with the same events, the next 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 out
  • id: optional; generated when left out
  • developer_api_key: the webhooks API’s ?hapikey; any key works when left out
  • app_id: optional; generated when left out
  • installed: installed from the start, so webhooks flow without an OAuth round trip
Records are maps of property names to values, checked as API writes are, so a seed HubSpot would refuse stops the emulator with a message naming the record. Seeding again does not duplicate records: contacts match on 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. Generic object.* 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. maxConcurrentRequests is stored, but deliveries for one app go one at a time.
  • Search has no phone number normalization, and query matches 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.