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
Bidirectional syncHubSpot → LMSHourly sweep (:45) — superseded by ADR-016LMS-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

The worker writes to custom properties. Create them once in HubSpot → Settings → Properties before enabling the integration; un-created properties are silently dropped by HubSpot and the metrics will not appear.

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 — (Superseded) Bidirectional sync (LMS-380)

Superseded by ADR-016. Inbound billing state now comes from the Stripe webhook, not from HubSpot. Do not enable HubSpot → LMS billing write-back. The historical configuration is retained below for reference only.

Inbound sync (HubSpot → LMS) was a defined fast-follow and shipped off:

  1. Set the worker variable BILLING_SYNC_INBOUND_ENABLED=true.
  2. Redeploy the worker. The :45 inbound sweep scheduler registers only when the flag is on.

When enabled, the inbound sweep:

  • Flows a HubSpot cancellation back to the LMS status — but only when it is newer than the LMS value (the stale-echo guard).
  • Flags tier drift (HubSpot tier ≠ LMS tier) as a warning/Sentry signal but does not auto-correct it — tier is LMS-authoritative.
  • Writes with sync-worker provenance and bypasses the console path, so there is no outbound echo (loop prevention).

Troubleshooting

SymptomCauseFix
Properties never populateCustom properties not created in HubSpotComplete Step 1 — HubSpot drops unknown properties silently
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)
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
billing_sync.drift warningsTier edited in HubSpot, diverges from LMSExpected signal, not auto-fixed. Reconcile by making the change in the platform console (LMS-authoritative)

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