IntegrationsHubSpot — CRM Mirror (Adoption & Subscription Sync)

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

FlowDirectionTriggerTicket
Entitlement/subscription mirrorLMS → HubSpotOn every console subscription write + Stripe webhook reconcile (ADR-016)LMS-374, LMS-487
Adoption write-backLMS → HubSpotHourly sweep (:30), after the metrics rollupLMS-375
Inbound sync (retired)HubSpot → LMSNone — 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:

  1. Run the dry run. It lists every declared contact, deal and company property that is missing from the portal HUBSPOT_API_KEY points at, and changes nothing:

    pnpm tsx infra/scripts/hubspot-bootstrap-properties.ts
  2. 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.ts now 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)TypeWritten from
embaylms_tenantSingle-line textTenant slug
embaylms_planSingle-line textTier (free/starter/growth/enterprise)
embaylms_seat_capSingle-line textSeat cap, or unlimited
embaylms_statusSingle-line textactive/trialing/past_due/cancelled
embaylms_trial_ends_atSingle-line textTrial 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)TypeWritten from
amount (native deal field)NumberMonthly recurring revenue in dollars — the live Stripe subscription amount (annual prices normalised to monthly)
embaylms_billing_cycleSingle-line textmonthly / annual ("" when unknown)
embaylms_next_renewal_dateSingle-line textNext renewal / current-period-end, epoch ms ("" when unknown)

Deal properties — adoption (LMS-375)

Property (internal name)TypeWritten from
embaylms_mauNumberMonthly active users (L30D)
embaylms_seats_usedNumberProvisioned users (seat consumption)
embaylms_seats_entitledSingle-line textSeat cap, or unlimited
embaylms_completions_30dNumberCourse completions, last 30 days
embaylms_last_activeSingle-line textLast activity, epoch ms ("" if never)
embaylms_healthSingle-line textgreen / 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-worker provenance (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 hutk tracking cookie, which exists only when the visitor consented to analytics cookies).
VariableRequiredPurpose
HUBSPOT_PORTAL_IDOptionalHubSpot portal id the form submission targets. Unset ⇒ the Forms-API stitch no-ops; the CRM path still records the lead.
HUBSPOT_LEAD_FORM_GUIDOptionalGUID 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):

ObjectPropertiesWhen
Companyembaylms_lifecycle_stage, embaylms_fit_tier, embaylms_cap_pressureafter each daily evaluation
Contactembaylms_last_touchpoint, embaylms_last_touch_atafter each dispatched touch
Email engagementone timeline engagement per sent touch (subject, touchpoint id, provider message id)on sent
Tasknative hs_task_subject / hs_task_body / hs_task_priority / hs_timestampwhen 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)TypeWhat it meansBlank or zero when
embaylms_academy_admin_track_completed_atSingle-line textWhen 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_pctNumberShare 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_activeNumberAcademy certifications the tenant’s staff hold today0 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 columnMeaningSent to HubSpot as
academy_staff_eligibleActive staff whose roles map to a trackDenominator of embaylms_academy_staff_trained_pct
academy_staff_enteredEligible staff who have entered the Academy at least onceNot sent
academy_staff_track_completedEntered staff who have completed at least one trackNumerator of embaylms_academy_staff_trained_pct
academy_owner_track_completed_atEarliest Administrator-track completion by an Owner or Adminembaylms_academy_admin_track_completed_at
academy_certifications_activeCertifications currently heldembaylms_academy_certifications_active
academy_certifications_expiring_30dHeld certifications that expire within 30 daysNot sent
academy_last_activity_atLatest Academy activity by the tenant’s staffNot 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

  1. Academy progress is recorded when a staff member enters the Academy and on every lesson they progress. An hourly reconcile at :40 repairs anything that was missed.
  2. The hourly metrics rollup at :15 recomputes 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.
  3. The hourly adoption write-back at :30 sends 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.

  1. Confirm the private app token in HUBSPOT_API_KEY has the crm.schemas.companies.write scope.

  2. Run the dry run and check that the three embaylms_academy_* properties are listed as missing:

    pnpm tsx infra/scripts/hubspot-bootstrap-properties.ts
  3. Create them:

    pnpm tsx infra/scripts/hubspot-bootstrap-properties.ts --execute
  4. Wait for the next :30 sweep, 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

SymptomCauseFix
Deal updates all fail with 400 / properties never populateCustom 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 allHUBSPOT_API_KEY unset, or no Redis on the workerSet the key; confirm REDIS_URL on the worker (jobs are skipped without Redis)
Academy properties empty while the other adoption properties updateThe three embaylms_academy_* company properties were never created in the portal; the worker logs hubspot.adoption.academy_props_missing and writes the restRun the bootstrap script with --execute (see Embay Academy adoption properties above); the next :30 sweep fills them
Adoption fields staleTenant has no deal id yetAdoption write-back only runs for tenants with a deal; trigger any console subscription write to create the deal first
Repeated reconcile failuresHubSpot 4xx/5xxJobs retry (5 attempts, exponential backoff); final failures surface to Sentry / logger.error (CloudWatch). Check scopes + property types

  • Platform Admin Guide — the console subscription actions
  • ADR-013 — Tenant Entitlements (ownership split, conflict rule, sequencing)
  • PRD §6.3.4 / §7.4