HubSpot CRM Mirror (Adoption & Subscription Sync)
Who this guide is for: Embay platform staff (RevOps / CS Ops) who administer the HubSpot portal. This is a platform-side integration — it is not configured per tenant. See also: Platform Admin Guide.
Updated 2026-06-24 (ADR-016): platform billing now runs on direct Stripe Billing — Stripe is the commercial source of truth (subscriptions, invoices, MRR). HubSpot is a one-way CRM mirror fed from Stripe + the LMS adoption rollup; it receives non-cardholder deal/MRR/renewal + adoption data for AE/CS and never writes billing state back. The deal/property writes described below are this mirror.
Overview
EmbayLMS owns each tenant’s enforceable entitlement (tier, seat cap, storage cap,
feature flags, trial, status) on the billing_subscriptions record and enforces it in
real time. Stripe Billing is the commercial source of truth (MRR, contracts, invoices —
ADR-016). HubSpot is a one-way CRM mirror that keeps AE/CS visibility aligned with both
(ADR-013, PRD §7.4).
Three flows run in the billing-sync worker (apps/worker):
| Flow | Direction | Trigger | Ticket |
|---|---|---|---|
| Entitlement/subscription mirror | LMS → HubSpot | On every console subscription write + Stripe webhook reconcile (ADR-016) | LMS-374, LMS-487 |
| Adoption write-back | LMS → HubSpot | Hourly sweep (:30), after the metrics rollup | LMS-375 |
| Inbound sync (retired) | HubSpot → LMS | None — retired in LMS-497, superseded by ADR-016 (see Step 4) | LMS-380 |
ADR-016 note: the inbound HubSpot → LMS billing sync (LMS-380) is superseded — billing state now flows into the LMS from the Stripe webhook (the authoritative reconcile path), not from HubSpot. The sync to HubSpot is one-way (LMS/Stripe → HubSpot) for CRM visibility only.
Enforcement-first (ADR-013 Option B): a console write updates the entitlement immediately (enforcement is local and instant), then reconciles to HubSpot out-of-band. HubSpot is never on the enrollment/feature-gate hot path.
Step 1 — Create the HubSpot custom properties (run the bootstrap script)
The worker writes to custom properties, and every one it can emit must exist in the portal before the integration is enabled. Create them with the sanctioned script:
-
Run the dry run. It lists every declared contact, deal and company property that is missing from the portal
HUBSPOT_API_KEYpoints at, and changes nothing:pnpm tsx infra/scripts/hubspot-bootstrap-properties.ts -
Create the missing properties:
pnpm tsx infra/scripts/hubspot-bootstrap-properties.ts --execute
These commands create the core set. The lifecycle CRM properties (see Lifecycle
journey sync below) need the decision #15 validation first, and the validator is a dry run
too until you pass --execute:
pnpm tsx infra/scripts/hubspot-validate-decision-15.ts # report only
pnpm tsx infra/scripts/hubspot-validate-decision-15.ts --execute # log the test engagement
# If only bookkeeping fields moved, wait, then re-read that run's contact (writes nothing):
pnpm tsx infra/scripts/hubspot-validate-decision-15.ts --recheck=<contactId>Record the finding, then add --decision-15-validated to both bootstrap commands above. Do
this before turning the lifecycle sync on, or its company writes fail. The validator leaves
its test contact in the portal for you to inspect and delete by hand.
The script is idempotent — safe to re-run after upgrades that add properties. The token
needs the crm.schemas.contacts.write, crm.schemas.deals.write and
crm.schemas.companies.write scopes.
Why this matters (LMS-815): a HubSpot deal PATCH that references a property the portal doesn’t have fails the whole request with a 400 — it does not partially apply, and it does not drop the unknown field. LMS-487 added two payload properties that were never created in the portal, which left the entire CRM mirror dead (not degraded) until worker job-failure capture surfaced it.
hubspot.deal-props.test.tsnow asserts every emitted property is either declared for the bootstrap script or native to HubSpot, so the drift cannot silently recur. (Contact/company writes behave differently — HubSpot drops unknown properties there; the hard-fail is specific to deals.)
The tables below document what the script creates.
Deal properties — entitlement (LMS-374)
| Property (internal name) | Type | Written from |
|---|---|---|
embaylms_tenant | Single-line text | Tenant slug |
embaylms_plan | Single-line text | Tier (free/starter/growth/enterprise) |
embaylms_seat_cap | Single-line text | Seat cap, or unlimited |
embaylms_status | Single-line text | active/trialing/past_due/cancelled |
embaylms_trial_ends_at | Single-line text | Trial end, epoch ms ("" when not on trial) |
Deal properties — commercial mirror (LMS-487)
Written on every Stripe subscription event (created / updated / deleted). Stripe is the source of truth; these are never written back to from HubSpot.
| Property (internal name) | Type | Written from |
|---|---|---|
amount (native deal field) | Number | Monthly recurring revenue in dollars — the live Stripe subscription amount (annual prices normalised to monthly) |
embaylms_billing_cycle | Single-line text | monthly / annual ("" when unknown) |
embaylms_next_renewal_date | Single-line text | Next renewal / current-period-end, epoch ms ("" when unknown) |
Deal properties — adoption (LMS-375)
| Property (internal name) | Type | Written from |
|---|---|---|
embaylms_mau | Number | Monthly active users (L30D) |
embaylms_seats_used | Number | Provisioned users (seat consumption) |
embaylms_seats_entitled | Single-line text | Seat cap, or unlimited |
embaylms_completions_30d | Number | Course completions, last 30 days |
embaylms_last_active | Single-line text | Last activity, epoch ms ("" if never) |
embaylms_health | Single-line text | green / amber / red |
The deal stage is also set:
closedwon(active),qualifiedtobuy(trialing),closedlost(cancelled).
Step 2 — Configure the API key
The worker authenticates with a HubSpot private app token in HUBSPOT_API_KEY
(Railway worker service variable today; AWS Secrets Manager after the ECS cutover). The
private app needs scopes: crm.objects.deals.read, crm.objects.deals.write.
When HUBSPOT_API_KEY is unset, all flows no-op cleanly — local development and preview
environments need no HubSpot configuration.
Step 3 — How reconciliation handles conflicts
The conflict rule (ADR-013, as revised by ADR-016) is: Stripe wins for commercial fields; the LMS wins for enforcement fields; and a reconciliation job never overwrites a newer enforcement value with a stale commercial echo (timestamps compared). HubSpot is a downstream mirror and is never authoritative.
- Outbound jobs read the live entitlement at run time, so a queued job never pushes a stale snapshot — enforcement fields are always authoritative.
- The first reconciliation for a tenant creates the deal and stores its id back on the
subscription with
sync-workerprovenance (so it does not echo back into the queue).
Step 4 — Inbound sync (retired)
HubSpot → LMS billing sync was retired in LMS-497. Billing state reaches the LMS through the Stripe webhook only (ADR-016), and there is nothing to configure. Tier changes made in HubSpot are not read back; make them in the platform console.
Lead capture & attribution (LMS-712)
Contact and demo-request submissions from the marketing surfaces are mirrored into HubSpot as leads. Two mechanisms run side by side:
- CRM path (
HUBSPOT_API_KEY) — finds or creates the contact and records UTM attribution. First-touch attribution is only set when the contact is created; an existing contact only has its last touch advanced (a later visit never rewrites first touch). - Forms API path — additionally submits the lead through a HubSpot form so
the visitor’s prior anonymous page views are stitched onto the contact
record (keyed on the
hutktracking cookie, which exists only when the visitor consented to analytics cookies).
| Variable | Required | Purpose |
|---|---|---|
HUBSPOT_PORTAL_ID | Optional | HubSpot portal id the form submission targets. Unset ⇒ the Forms-API stitch no-ops; the CRM path still records the lead. |
HUBSPOT_LEAD_FORM_GUID | Optional | GUID of the HubSpot form the contact/demo submissions are mirrored to. Unset ⇒ same clean no-op. |
Both are optional — with neither set, lead sync degrades gracefully rather than failing (see the CLAUDE.md §13.2 environment reference). Session stitching also requires the visitor’s analytics-cookie consent; without it the lead is still captured with its UTM attribution, just without the anonymous-pageview history.
Lifecycle journey sync (LMS-907, LMS-909)
The free→paid lifecycle engine (ADR-024) mirrors its journey into HubSpot so the CRM view matches what the product decided. It is still a one-way mirror with one narrow exception, described below.
Pushed to HubSpot (gap-only — existing values are never overwritten with blanks):
| Object | Properties | When |
|---|---|---|
| Company | embaylms_lifecycle_stage, embaylms_fit_tier, embaylms_cap_pressure | after each daily evaluation |
| Contact | embaylms_last_touchpoint, embaylms_last_touch_at | after each dispatched touch |
| Email engagement | one timeline engagement per sent touch (subject, touchpoint id, provider message id) | on sent |
| Task | native hs_task_subject / hs_task_body / hs_task_priority / hs_timestamp | when the escalation router opens a founder task |
Read back from HubSpot — exactly two contact fields, nothing else (HUBSPOT_READBACK_FIELDS, test-enforced): hs_language (a rep can flip the send language) and hs_email_optout (a rep-recorded opt-out is honoured as a suppression source). No HubSpot value changes a plan, an entitlement or a journey stage.
Bootstrap. The custom properties are created by the same bootstrap script as Step 1; the lifecycle set is gated behind --decision-15-validated because the property names were fixed by open decision #15 of the lifecycle design and must not be created before the naming is confirmed in the portal.
Dry-run. While LIFECYCLE_DISPATCH_ENABLED is unset nothing is sent, so no engagements are written; company/contact stage properties are still pushed, which is how a dry-run week is visible in the CRM.
Embay Academy adoption properties (LMS-1070)
The hourly adoption write-back also tells AE/CS how far a customer’s own staff have got in Embay Academy (ADR-027 §2.7). It writes three custom properties on the HubSpot company record, in the same request as the other adoption properties. It is still one-way: nothing is read back from HubSpot.
The three properties
| Property (internal name) | Type | What it means | Blank or zero when |
|---|---|---|---|
embaylms_academy_admin_track_completed_at | Single-line text | When an Owner or Admin of the tenant first completed the Administrator track, as epoch ms (the same representation as embaylms_last_active) | "" until an Owner or Admin completes the track |
embaylms_academy_staff_trained_pct | Number | Share of eligible staff who have completed at least one track, 0 to 100, rounded to a whole number | "" when the tenant has no eligible staff |
embaylms_academy_certifications_active | Number | Academy certifications the tenant’s staff hold today | 0 when there are none |
“Eligible staff” means active users whose roles come with an Academy track: Owner, Admin, E-commerce Admin, Group Admin, Manager, Instructor, and any custom role that grants a user-management permission. Learners are never counted.
Where the numbers come from
The values are computed from the seven academy_* columns of the tenant’s tenant_metrics
row (LMS-1069). Four of them feed HubSpot. The other three stay in the platform console.
tenant_metrics column | Meaning | Sent to HubSpot as |
|---|---|---|
academy_staff_eligible | Active staff whose roles map to a track | Denominator of embaylms_academy_staff_trained_pct |
academy_staff_entered | Eligible staff who have entered the Academy at least once | Not sent |
academy_staff_track_completed | Entered staff who have completed at least one track | Numerator of embaylms_academy_staff_trained_pct |
academy_owner_track_completed_at | Earliest Administrator-track completion by an Owner or Admin | embaylms_academy_admin_track_completed_at |
academy_certifications_active | Certifications currently held | embaylms_academy_certifications_active |
academy_certifications_expiring_30d | Held certifications that expire within 30 days | Not sent |
academy_last_activity_at | Latest Academy activity by the tenant’s staff | Not sent |
The milestone also feeds embaylms_health: a tenant created on or after 2026-09-25 turns
amber once it is more than 14 days old and no Owner or Admin has completed the
Administrator track. It never turns a tenant red.
When the values update
- Academy progress is recorded when a staff member enters the Academy and on every lesson
they progress. An hourly reconcile at
:40repairs anything that was missed. - The hourly metrics rollup at
:15recomputes the seven columns for every tenant. The platform Academy tenant itself is skipped. If the Academy step fails for one tenant, that tenant keeps its previous Academy values and the rest of its rollup still runs. - The hourly adoption write-back at
:30sends the three properties to the company.
A completed track therefore reaches HubSpot within about 75 minutes. Each sweep writes the current values, so there is nothing to backfill after an outage.
A tenant is skipped when it has no stored HubSpot company id yet (no_company) or no
metrics row yet (no_metrics). The entitlement reconcile stores the company id; the next
:30 sweep then picks the tenant up.
Precondition: create the properties in HubSpot first
The three properties must exist in the portal before HubSpot will accept them. They are in the core set of the Step 1 bootstrap script, on the company object.
-
Confirm the private app token in
HUBSPOT_API_KEYhas thecrm.schemas.companies.writescope. -
Run the dry run and check that the three
embaylms_academy_*properties are listed as missing:pnpm tsx infra/scripts/hubspot-bootstrap-properties.ts -
Create them:
pnpm tsx infra/scripts/hubspot-bootstrap-properties.ts --execute -
Wait for the next
:30sweep, then open a customer’s company record in HubSpot and check that the three properties are filled.
What happens if you skip this: HubSpot rejects a whole company update that names a
property the portal does not have. The worker guards against that: when the rejection names
one of the three Academy properties, it logs the warning
hubspot.adoption.academy_props_missing, then retries once without them. The other
adoption properties (embaylms_mau, embaylms_health and the rest) keep flowing, and the
Academy properties stay empty until you run the script. No redeploy is needed afterwards.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Deal updates all fail with 400 / properties never populate | Custom deal properties not created in HubSpot — an unknown deal property 400s the entire PATCH (LMS-815) | Run the Step 1 bootstrap script, then let the next reconcile retry |
| Nothing syncs at all | HUBSPOT_API_KEY unset, or no Redis on the worker | Set the key; confirm REDIS_URL on the worker (jobs are skipped without Redis) |
| Academy properties empty while the other adoption properties update | The three embaylms_academy_* company properties were never created in the portal; the worker logs hubspot.adoption.academy_props_missing and writes the rest | Run the bootstrap script with --execute (see Embay Academy adoption properties above); the next :30 sweep fills them |
| Adoption fields stale | Tenant has no deal id yet | Adoption write-back only runs for tenants with a deal; trigger any console subscription write to create the deal first |
| Repeated reconcile failures | HubSpot 4xx/5xx | Jobs retry (5 attempts, exponential backoff); final failures surface to Sentry / logger.error (CloudWatch). Check scopes + property types |
Related
- Platform Admin Guide — the console subscription actions
- ADR-013 — Tenant Entitlements (ownership split, conflict rule, sequencing)
- PRD §6.3.4 / §7.4