HubSpot template
A real HubSpot CRM backend — generic objects, v4 associations, properties, pipelines, owners, OAuth tokens, and HubSpot-accurate rate limiting.
The HubSpot template (apps/hubspot-clone, name hubspot) is an API-only
clone of the HubSpot CRM API:
a NestJS backend on Postgres that models the CRM the way HubSpot does — every
object (contacts, companies, deals, tickets) is a generic record carrying a
free-form properties map, wired together by an associations graph, with
properties metadata, pipelines + stages, and owners on the side.
It's faithful at the path + wire-format level: HubSpot's CRM API is REST with
stable routes, so the clone matches GET /crm/v3/objects/contacts path-for-path,
uses HubSpot's { results, paging: { next: { after } } } collection envelope, the
{ total, results } search envelope, the { status, message, correlationId, category } error envelope, and returns numeric-string ids. Associations are on
the v4 surface (/crm/v4/…), and requests are metered by a HubSpot-accurate
rate limiter (see below).
hone env spin hubspotWhat's inside
| Piece | Notes |
|---|---|
backend/ | NestJS REST API, Postgres via pg. Health at /api/health. Port 3005. |
supabase/migrations/ | 001_initial_schema.sql — the single DDL migration (schema only), run on spin and replayed on reset. |
seeds/ | acme.sql — the bundled deterministic fixture (stock properties, pipelines, stages, owners, token, sample records). |
Modes
| Mode | Services | Use it for |
|---|---|---|
api (default) | backend | Agents that talk to the HubSpot-style REST API. |
This is an API-only template — there is no frontend.
Authentication — private-app tokens
Real HubSpot apps authenticate with a private-app access token
(pat-na1-…) in the Authorization header. The clone mirrors that: the token is
the canonical credential, sha256-hashed and resolved to its owning portal
(tenant). Every spin applies the acme fixture, which seeds a deterministic
token:
pat-na1-00000000-0000-4000-8000-000000000001Use it as a bearer token against the REST API — no signup/login step:
curl -s http://127.0.0.1:3005/api/crm/v3/objects/contacts \
-H "authorization: Bearer pat-na1-00000000-0000-4000-8000-000000000001"The clone also implements HubSpot's real OAuth token-exchange route,
POST /api/oauth/v1/token, and a parity POST /api/auth/login
(admin@acme.test / password123) that issues a portal-scoped JWT so the
web/CLI path works — HubSpot itself has no email/password login route. Mint
additional private-app tokens via POST /api/account-info/v3/api-keys (plaintext
shown once).
Rate limiting
Requests pass through a HubSpot-accurate limiter (a global interceptor behind the auth guard). Successful responses carry the exact five HubSpot headers:
X-HubSpot-RateLimit-Max
X-HubSpot-RateLimit-Remaining
X-HubSpot-RateLimit-Interval-Milliseconds
X-HubSpot-RateLimit-Daily
X-HubSpot-RateLimit-Daily-RemainingTiers match HubSpot's published limits: private-app 100 / 190 / 250 requests
per 10 s (Free·Starter / Pro·Enterprise / API-add-on), public OAuth 110 /
10 s, with daily caps of 250k / 625k / 1M. Tripping the window returns a
real 429 whose body uses HubSpot's documented policyName values
(TEN_SECONDLY_ROLLING, DAILY):
{
"status": "error",
"message": "You have reached your ten-secondly rolling limit.",
"category": "RATE_LIMIT",
"policyName": "TEN_SECONDLY_ROLLING"
}Seeding
hone env spin hubspot --seed acme # deterministic fixture on create
hone env seed hubspot-a1b2 --ai # realistic data via the REST APIAI seeding generates contacts, companies, and deals (the template's
declared entities) through real CRM v3 create calls. See
Seeding.
The object model — read this before pointing an agent at it
Everything in HubSpot's CRM is a record with a property bag. The clone keeps that shape exactly:
# create a contact
curl -s -X POST http://127.0.0.1:3005/api/crm/v3/objects/contacts \
-H "authorization: Bearer pat-na1-…" -H 'content-type: application/json' \
-d '{"properties":{"email":"jane@example.com","firstname":"Jane"}}'
# search with HubSpot filterGroups → { total, results }
curl -s -X POST http://127.0.0.1:3005/api/crm/v3/objects/contacts/search \
-H "authorization: Bearer pat-na1-…" -H 'content-type: application/json' \
-d '{"filterGroups":[{"filters":[{"propertyName":"lastname","operator":"EQ","value":"Doe"}]}]}'
# associate a deal with a company (v4 default association)
curl -s -X PUT \
http://127.0.0.1:3005/api/crm/v4/objects/deals/1003/associations/default/companies/1001 \
-H "authorization: Bearer pat-na1-…"Beyond plain CRUD, objects also expose batch/{read,create,update,archive,upsert},
merge, and gdpr-delete. Associations are the full v4 surface — default and
labeled PUT (the label body array is honored), DELETE, the four
batch/{create,read,archive,labels/archive} endpoints, and GET …/labels.
Properties carry CRUD plus batch/{read,create,archive} and property
groups CRUD; pipelines and their stages have full read and write paths.
Fidelity
The template ships an API_PARITY.md re-audited 2026-06-28 against HubSpot's
live docs. Scope is the CRM resource groups the clone targets (objects,
associations, properties, pipelines, owners) plus the OAuth token endpoint —
55 endpoints:
| Status | Count |
|---|---|
| ✅ exact (path + shape align) | 50 |
| ⚠️ shape-mismatch | 0 |
| ❌ missing | 5 |
That's 91% (50/55) on both capability and shape — they're equal because every
implemented endpoint is also shape-exact. The 5 missing are narrow: association
label-definition writes (POST / PUT / DELETE …/associations/{ft}/{tt}/labels)
and the two property-group batch endpoints. Seven extra routes are clone-local
control-plane (/auth/*, /account-info/v3/api-keys, /health); an eighth is a
deprecated v3 association-read alias retained for back-compat.
On the strict dimensions the audit grades: id-format passes (numeric-string
record ids, plus HubSpot's own mixed string-id / numeric-toObjectId convention
inside association payloads) and rate-limits passes (the limiter above).
Webhooks/events and an MCP server are unimplemented, so those two dimensions are
N/A — the clone never claims to emit events or expose MCP. Whole HubSpot
families outside the core — engagements/timeline, workflows, marketing, CMS,
conversations, files, quotes, custom object schemas — are unimplemented by design.
See apps/hubspot-clone/API_PARITY.md for the per-endpoint breakdown.
Inspect a running clone
hone env query hubspot-a1b2 "select object_type, count(*) from crm_objects group by 1"
hone env db hubspot-a1b2 # prints a psql shell command
hone env logs hubspot-a1b2 -f # stream the backendThe schema (crm_objects, property_defs, property_groups, pipelines,
pipeline_stages, associations, owners, …) comes straight from
001_initial_schema.sql — read it to know what's queryable.