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

# HubSpot

> CRM objects, search, associations, properties, pipelines, owners, OAuth, and webhooks.

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`](https://github.com/HubSpot/hubspot-api-nodejs) (14.0.1).

## At a glance

| | |
| - | - |
| Package | `@deploybaseline/hubspot` |
| Port | `4000` when started alone; named services take consecutive ports |
| Config key | `hubspot` |
| HTTP operations | 98, browsable at `http://localhost:3999/docs/hubspot` while it runs ([API reference](/api-reference.md)) |

```bash CLI theme={null}
npx @deploybaseline/cli start --service hubspot
```

```ts Programmatic theme={null}
import { createEmulator } from "@deploybaseline/cli";

const hubspot = await createEmulator({ service: "hubspot" });
// hubspot.url is http://localhost:4000
```

```json baseline.config.json theme={null}
{
  "hubspot": {
    "portal": {
      "id": 12345678,
      "name": "Acme"
    },
    "owners": [
      {
        "email": "owner@example.com",
        "first_name": "Olivia",
        "last_name": "Owner"
      }
    ],
    "private_apps": [
      {
        "name": "Local app",
        "token": "pat-na1-baseline-local",
        "scopes": [
          "crm.objects.contacts.write",
          "crm.objects.companies.write",
          "crm.objects.deals.write",
          "tickets",
          "crm.schemas.contacts.write",
          "crm.objects.owners.read"
        ]
      }
    ],
    "contacts": [
      {
        "email": "ada@example.com",
        "firstname": "Ada",
        "lastname": "Lovelace",
        "lifecyclestage": "lead"
      }
    ],
    "companies": [
      {
        "name": "Acme",
        "domain": "acme.com"
      }
    ],
    "deals": [
      {
        "dealname": "Acme renewal",
        "amount": 12000,
        "dealstage": "qualifiedtobuy"
      }
    ],
    "associations": [
      {
        "from": {
          "contacts": "ada@example.com"
        },
        "to": {
          "companies": "acme.com"
        },
        "labels": ["Primary"]
      },
      {
        "from": {
          "deals": "Acme renewal"
        },
        "to": {
          "companies": "acme.com"
        }
      }
    ]
  }
}
```

## Start

```bash theme={null}
npx @deploybaseline/cli start --service hubspot
```

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 `admin@example.com` 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`:

```ts theme={null}
import { Client } from "@hubspot/api-client";

const hubspot = new Client({
  accessToken: "pat-na1-baseline-local",
  basePath: process.env.HUBSPOT_EMULATOR_URL,
});

const contact = await hubspot.crm.contacts.basicApi.create({
  properties: { email: "ada@example.com", firstname: "Ada" },
  associations: [],
});
```

## URL mapping

| Real HubSpot URL | Emulator URL |
| - | - |
| `https://api.hubapi.com/crm/v3/objects/...` | `$HUBSPOT_EMULATOR_URL/crm/v3/objects/...` |
| `https://api.hubapi.com/crm/v4/objects/.../associations/...` | `$HUBSPOT_EMULATOR_URL/crm/v4/objects/.../associations/...` |
| `https://api.hubapi.com/crm/v4/associations/...` | `$HUBSPOT_EMULATOR_URL/crm/v4/associations/...` |
| `https://api.hubapi.com/crm/v3/associations/...` | `$HUBSPOT_EMULATOR_URL/crm/v3/associations/...` |
| `https://api.hubapi.com/crm/v3/properties/...` | `$HUBSPOT_EMULATOR_URL/crm/v3/properties/...` |
| `https://api.hubapi.com/crm/v3/pipelines/...` | `$HUBSPOT_EMULATOR_URL/crm/v3/pipelines/...` |
| `https://api.hubapi.com/crm/v3/owners/...` | `$HUBSPOT_EMULATOR_URL/crm/v3/owners/...` |
| `https://api.hubapi.com/oauth/v2/private-apps/get/access-token-info` | `$HUBSPOT_EMULATOR_URL/oauth/v2/private-apps/get/access-token-info` |
| `https://app.hubspot.com/oauth/authorize` (and `/oauth/{accountId}/authorize`) | `$HUBSPOT_EMULATOR_URL/oauth/authorize` |
| `https://api.hubapi.com/oauth/v1/...` and `/oauth/v3/...` | `$HUBSPOT_EMULATOR_URL/oauth/v1/...` and `/oauth/v3/...` |
| `https://api.hubapi.com/appinstalls/v3/external-install` | `$HUBSPOT_EMULATOR_URL/appinstalls/v3/external-install` |
| `https://api.hubapi.com/webhooks/v3/{appId}/...` | `$HUBSPOT_EMULATOR_URL/webhooks/v3/{appId}/...` |

## 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](#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](#webhooks) takes it, for the developer account's key.

Scopes per operation:

| Operation | Accepted scopes |
| - | - |
| Read contacts, companies, deals | `crm.objects.<type>.read` or `crm.objects.<type>.write` |
| Write contacts, companies, deals | `crm.objects.<type>.write` |
| Read property definitions | `crm.schemas.<type>.read`, `crm.schemas.<type>.write`, or the object read and write scopes |
| Change property definitions | `crm.schemas.<type>.write` |
| Read deal pipelines | `crm.objects.deals.read` or `crm.objects.deals.write` |
| Change deal pipelines | `crm.objects.deals.write` or `crm.schemas.deals.write` |
| Anything on tickets, ticket properties, or ticket pipelines | `tickets` |
| Owners | `crm.objects.owners.read` |
| Search | The read scopes of the object searched |
| Read associations | The read scopes of both objects |
| Create or remove associations | The write scopes of both objects |
| Association labels and limits | Reading labels takes the read scopes of both objects, reading a pair's limits their schema read scopes, and changing labels or limits their schema write scopes |

`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](https://developers.hubspot.com/docs/apps/legacy-apps/authentication/working-with-oauth) 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:

| Case | `error` | `status` |
| - | - | - |
| Unknown, used, or expired code | `invalid_grant` | `BAD_AUTH_CODE` |
| `redirect_uri` differs from the install's | `invalid_grant` | `BAD_REDIRECT_URI` |
| Unknown, revoked, or another app's refresh token | `invalid_grant` | `BAD_REFRESH_TOKEN` (`refresh token is invalid, expired or revoked`) |
| Unknown client id | `invalid_client` | `BAD_CLIENT_ID` |
| Wrong client secret | `invalid_client` | `BAD_CLIENT_SECRET` |
| Any other grant type | `unsupported_grant_type` | `BAD_GRANT_TYPE` |

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.

```ts theme={null}
import { Client } from "@hubspot/api-client";

const base = process.env.HUBSPOT_EMULATOR_URL!;
const installUrl = new Client().oauth
  .getAuthorizationUrl(clientId, redirectUri, "oauth crm.objects.contacts.read")
  .replace("https://app.hubspot.com", base);
// After the user picks an account, your redirect URL receives ?code=...
const tokens = await new Client({ basePath: base }).oauth.tokensApi.create(
  "authorization_code",
  code,
  redirectUri,
  clientId,
  clientSecret,
);
const hubspot = new Client({ accessToken: tokens.accessToken, basePath: base });
```

## 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](#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.

## 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 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:

| From | To | Unlabeled | Labels |
| - | - | - | - |
| contact | company | 279 | 1 Primary (one per contact) |
| company | contact | 280 | 2 Primary |
| contact | deal / ticket / contact | 4 / 15 / 449 | |
| company | deal | 342 | 6 Primary |
| deal | company | 341 | 5 Primary (one per deal) |
| company | ticket | 340 | 25 Primary |
| ticket | company | 339 | 26 Primary (one per ticket) |
| company | company | 450 | 14 Parent Company (one per company), 13 Child Company |
| deal | contact / ticket / deal | 3 / 27 / 451 | |
| ticket | contact / deal / ticket | 16 / 28 / 452 | |

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, `admin@example.com`, 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](https://developers.hubspot.com/docs/api-reference/legacy/webhooks/guide) 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.

| Endpoint | Behavior |
| - | - |
| `GET`, `PUT`, `DELETE /webhooks/v3/{appId}/settings` | `targetUrl` and `throttling.maxConcurrentRequests`, which must be a whole number greater than five as HubSpot documents. `GET` answers HTTP 404 until settings exist. |
| `GET`, `POST /webhooks/v3/{appId}/subscriptions` | Lists as `{ results }`. Creates with `eventType`, `propertyName` (required for and only allowed on `*.propertyChange`), and `active`; new subscriptions start paused. HTTP 201. |
| `GET`, `PATCH`, `PUT`, `DELETE /webhooks/v3/{appId}/subscriptions/{id}` | `PATCH` (the client) and `PUT` (the guide) both set `active`. |
| `POST /webhooks/v3/{appId}/subscriptions/batch/update` | `inputs` of `{ id, active }`; HTTP 207 with an `OBJECT_NOT_FOUND` entry for unknown ids. |

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:

| Event | When |
| - | - |
| `<object>.creation` | A record is created (`changeFlag: "NEW"`) |
| `<object>.propertyChange` | A watched property gets a new value, including when a record is created with it; `propertyValue` is the new value |
| `<object>.deletion` | A record is archived |
| `<object>.associationChange` | The unlabeled or Primary association between two records is added or removed. Both records' sides fire, and adding a primary company sends one event for the unlabeled type and one with `isPrimaryAssociation: true`, as HubSpot's guide notes. Custom labels send nothing. |

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:

```ts theme={null}
import { Signature } from "@hubspot/api-client";

Signature.isValid({
  signatureVersion: "v3",
  signature: req.headers["x-hubspot-signature-v3"],
  clientSecret,
  requestBody: rawBody,
  url: "https://your-app.example.com/hubspot/webhooks",
  timestamp: Number(req.headers["x-hubspot-request-timestamp"]),
});
```

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

```json theme={null}
{
  "hubspot": {
    "portal": {
      "id": 12345678,
      "name": "Acme",
      "domain": "acme.com"
    },
    "owners": [
      {
        "email": "owner@example.com",
        "first_name": "Olivia",
        "last_name": "Owner",
        "id": "501",
        "teams": [
          {
            "name": "Sales"
          }
        ]
      }
    ],
    "private_apps": [
      {
        "name": "Local app",
        "token": "pat-na1-baseline-local",
        "scopes": ["crm.objects.contacts.write", "crm.objects.deals.read", "tickets"]
      }
    ],
    "developer_api_key": "dev-api-key",
    "oauth_apps": [
      {
        "name": "CRM Sync",
        "client_id": "1b2c3d4e-0000-4000-8000-00000000c1d1",
        "client_secret": "5e6f7a8b-0000-4000-8000-0000000005ec",
        "app_id": 777001,
        "redirect_uris": ["http://localhost:3000/oauth-callback"],
        "scopes": ["oauth", "crm.objects.contacts.read", "crm.objects.contacts.write"],
        "optional_scopes": ["crm.objects.deals.read"],
        "installed": true,
        "webhooks": {
          "target_url": "http://localhost:3000/hubspot/webhooks",
          "max_concurrent_requests": 10,
          "subscriptions": [
            {
              "event_type": "contact.creation"
            },
            {
              "event_type": "contact.propertyChange",
              "property_name": "lifecyclestage"
            },
            {
              "event_type": "deal.creation",
              "active": false
            }
          ]
        }
      }
    ],
    "property_groups": {
      "deals": [
        {
          "name": "partner_info",
          "label": "Partner info"
        }
      ]
    },
    "properties": {
      "deals": [
        {
          "name": "partner",
          "label": "Partner",
          "type": "enumeration",
          "field_type": "radio",
          "group_name": "partner_info",
          "options": ["acme", "globex"]
        }
      ],
      "companies": [
        {
          "name": "external_id",
          "label": "External ID",
          "type": "string",
          "field_type": "text",
          "has_unique_value": true
        }
      ]
    },
    "pipelines": {
      "deals": [
        {
          "id": "partners",
          "label": "Partners",
          "stages": [
            {
              "id": "intro",
              "label": "Intro",
              "probability": 0.1
            },
            {
              "label": "Won",
              "probability": 1
            }
          ]
        }
      ],
      "tickets": [
        {
          "label": "Escalations",
          "stages": [
            {
              "label": "Open"
            },
            {
              "label": "Done",
              "ticket_state": "CLOSED"
            }
          ]
        }
      ]
    },
    "contacts": [
      {
        "email": "ada@example.com",
        "firstname": "Ada",
        "lastname": "Lovelace",
        "lifecyclestage": "lead"
      }
    ],
    "companies": [
      {
        "name": "Acme",
        "domain": "acme.com",
        "external_id": "acme-1"
      }
    ],
    "deals": [
      {
        "dealname": "Partner deal",
        "pipeline": "partners",
        "dealstage": "intro",
        "partner": "globex",
        "hubspot_owner_id": "501"
      }
    ],
    "tickets": [
      {
        "subject": "Printer on fire",
        "hs_pipeline_stage": "1",
        "hs_ticket_priority": "HIGH"
      }
    ],
    "association_labels": [
      {
        "from": "contacts",
        "to": "companies",
        "name": "billing",
        "label": "Billing contact"
      },
      {
        "from": "companies",
        "to": "companies",
        "name": "reseller",
        "label": "Reseller",
        "inverse_label": "Supplier"
      }
    ],
    "associations": [
      {
        "from": {
          "contacts": "ada@example.com"
        },
        "to": {
          "companies": "acme.com"
        },
        "labels": ["Primary", "Billing contact"]
      },
      {
        "from": {
          "deals": "Partner deal"
        },
        "to": {
          "companies": "Acme"
        }
      }
    ]
  }
}
```

* `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

| | |
| - | - |
| Routes | 98 supported or partial, 0 planned |
| Route gate | fail |
| Spec gate | no public spec |
| SDKs tested | `@hubspot/api-client@14.0.1` |
| Dual mode | not yet |

See [CONFORMANCE.md](https://github.com/deploybaseline/cli/blob/main/CONFORMANCE.md) for every provider.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.