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 |
| Bidirectional sync | HubSpot → LMS | Hourly sweep (:45) — superseded by ADR-016 | 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
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) | 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 — (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:
- Set the worker variable
BILLING_SYNC_INBOUND_ENABLED=true. - Redeploy the worker. The
:45inbound 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-workerprovenance and bypasses the console path, so there is no outbound echo (loop prevention).
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Properties never populate | Custom properties not created in HubSpot | Complete Step 1 — HubSpot drops unknown properties silently |
| 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) |
| 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 |
billing_sync.drift warnings | Tier edited in HubSpot, diverges from LMS | Expected signal, not auto-fixed. Reconcile by making the change in the platform console (LMS-authoritative) |
Related
- Platform Admin Guide — the console subscription actions
- ADR-013 — Tenant Entitlements (ownership split, conflict rule, sequencing)
- PRD §6.3.4 / §7.4