HubSpot Integration

Bi-directional CRM sync between Renewa One and HubSpot. The most complex integration in the system — a full sync engine with mapping layer, polymorphic association infrastructure, owner sync, async webhook processing via BullMQ, and 10 database tables. HubSpot is R1’s primary CRM; the legacy Dataverse paths are retained only for narrow document-obtaining/PDF use cases.

Architecture

LayerLocation
OAuth / token lifecyclebackend/src/services/hubspot/auth.ts (HubSpotAuthManager)
API clientbackend/src/services/hubspot/client.ts
Webhook receiverbackend/src/routes/hubspot/webhook.ts
Monitoring routebackend/src/routes/hubspot/monitoring.ts
Sync enginebackend/src/services/hubspot/sync-engine/
Mapping systembackend/src/services/hubspot/mapping/
Event handlersbackend/src/services/hubspot/event-handlers/
Association infrastructurebackend/src/services/hubspot/association-config.service.ts + renewa-role-bindings.ts
Owner syncbackend/src/services/hubspot/owner-sync.ts
Handover configbackend/src/services/hubspot/handover-config.service.ts
Full syncbackend/src/services/hubspot/full-sync.ts
Main servicebackend/src/services/hubspot/hubspot-sync.service.ts
Admin servicebackend/src/services/hubspot/hubspot-sync-admin.service.ts
Queuesbackend/src/lib/jobs/hubspot-sync-queue.ts, hubspot-webhook-event-queue.ts, hubspot-association-recon-queue.ts
Processorsbackend/src/lib/jobs/processors/hubspotSyncProcessor.ts, hubspotWebhookEventProcessor.ts, hubspotAssociationReconProcessor.ts, hubspot-webhook-log-cleanup.ts
Frontend adminfrontend/src/pages/admin/HubSpot.tsx

Entity Map (canonical)

Canonical source: backend/src/services/hubspot/sync-engine/entity-configs.ts (one EntitySyncConfig per object; objectTypeIds in entity-registry.ts).

HubSpot objectobjectTypeIdR1 tableSync config
Contact0-1contactscontactConfig
Company0-2companiescompanyConfig
Deal0-3projectsdealConfig
Quote0-14quotesquoteConfig
Line Item0-8quote_line_itemslineItemConfig
Listing0-420buildingslistingConfig
Lead0-136hubspot_leadsleadConfig
Product0-7productsproductConfig
Associationn/a (polymorphic)hubspot_associationsevent-handlers/association-handler.ts

HubSpot Deal = R1 Project. There is no deals table; deals sync directly into projects. (Made discoverable in PR#1685.)

No vendor lock-in: every mirror table has a hubspotId varchar for round-tripping, but internal cross-table references join on UUID PKs — never on hubspotId. Webhooks resolve hubspotId → internal UUID once at the boundary; only the sync engine and webhook router consume hubspotId columns. New *HubspotId columns on non-mirror tables are forbidden.

Buildings mirror columns

PR#1983 added HubSpot Listing mirror values to buildings (sync overwrites on every run): residentialUnits, livingArea, hubspotPipeline, hubspotPipelineStage, recordOwnerContactId (resolved via hubspot_owners; sync layer is the sole writer), and a hubspotProperties jsonb column. EB-maintained fields like heatedArea deliberately have no HubSpot source.

Sync Engine

Located at backend/src/services/hubspot/sync-engine/:

ModulePurpose
entity-registryObject type IDs and registration of syncable entity types
entity-configsDeclarative per-entity config (mapEntity, validate, preSyncHook, postSyncHook, fetchFromHubSpot)
sync-operationsCore sync logic (syncEntity, archiveEntity, restoreEntity, privacyDeleteEntity, mergeEntity)
batch-syncBulk sync for initial import/export, watermarks, orphan detection
apply-mappingsApplies property mappings; sanitization choke point for API-fetched data
owner-resolutionResolves hubspot_owner_id properties to contacts.id

Since PR#1928, HubSpot data fetched via the API (full sync / batch sync) is sanitized once at the apply-mappings choke point with the same sanitizeValue (from @shared/lib/sanitize) the webhook route applies to inbound payloads — no raw CRM strings reach the DB on either path.

Mapping System

Located at backend/src/services/hubspot/mapping/:

ModulePurpose
property-mapping.serviceMaps Renewa fields to HubSpot properties
property-sync.servicePushes property definitions/values
enum-mapping.service + enum-value-resolverMaps enum values between systems
type-inference.service + type-coercionsInfers and coerces field types
value-transformerTransforms values during sync (dates, currencies, etc.)
mapping-suggestion.service + column-matcher.serviceAuto-suggests mappings based on field names
mapping-cacheCaches resolved mappings for performance

Database Tables

TablePurpose
hubspot_configOAuth tokens (encrypted jsonb), sync settings, requires_reauth flag
hubspot_property_mappingsField-to-property mapping definitions
hubspot_enum_value_mappingsEnum value translations
hubspot_association_labelsHubSpot label catalogue + R1 internal_name / renewa_name / is_enabled
hubspot_associationsRecord-level associations (polymorphic)
hubspot_sync_logSync operation audit trail
hubspot_webhook_logInbound webhook event log (90-day retention, redacted payloads)
hubspot_leadsLead object mirror
hubspot_handover_configAdmin-configurable deal pipeline stages qualifying for handover (Issue #1431)
hubspot_ownersHubSpot Owner ID → contacts.id mapping (owner sync)

Webhook Event Handling

Since the async rework, the receiver only validates and enqueues — processing happens in a BullMQ worker:

HubSpot POST → v3 HMAC-SHA256 signature validation on the raw body
  (full https URL; HUBSPOT_WEBHOOK_URL override local-dev only;
   repeated signature failures throttled per source → 429)
  → durable enqueue to BullMQ queue hubspot-webhook-events
  → 200 within HubSpot's 5s deadline (503 if enqueue fails → HubSpot redelivers)

hubspotWebhookEventProcessor:
  → parseHubSpotEventType (legacy + new format)
  → idempotency check (hubspot_webhook_log.eventId)
  → parseEntityAction → ENTITY_CONFIGS lookup
  → handler dispatch:
      creation         → handleCreation (fetch + syncEntity)
      property_change  → handlePropertyChange (updateEntityProperty)
      deletion         → handleDeletion (archiveEntity)
      restore          → handleRestore (restoreEntity)
      privacy_deletion → handlePrivacyDeletion (privacyDeleteEntity)
      merge            → handleMerge (mergeEntity per loser)
      association_change → association-handler
  → webhook log status update (success / failed)

Payload redaction at ingest (PR#1982, I#1970): propertyValue is the only value-carrying field HubSpot sends; it is stored as [REDACTED] in hubspot_webhook_log. The full payload is written back only when an event fails (for debugging) and is re-redacted on a successful re-claim; everything is bounded by the retention sweep.

Retention: hubspot-webhook-log-cleanup processor deletes webhook log rows past 90 days, daily at 03:30 (PR#1927, I#1902).

Side-effect triggers after certain property changes:

TriggerCondition
Handover readinessDeal dealstage property changes (vs hubspot_handover_config stages)
Scope change detectionQuote hs_quote_status changes to APPROVAL_NOT_NEEDED
Association scope changeDEAL_TO_QUOTE primary association change

Event handlers are at backend/src/services/hubspot/event-handlers/:

ModulePurpose
generic-handlerParameterized handlers for all standard entity events (creation, deletion, merge, etc.)
association-handlerAssociation sync/remove with dynamic entity resolution (contact, company, building, project, lead)

Owner Sync

Issue #1724, spec docs/superpowers/specs/2026-05-13-hubspot-owner-as-contact-design.md. HubSpot Users (humans with CRM access) are mirrored into contacts (contacts.hubspot_user_id), and hubspot_owners maps HubSpot Owner IDs (the per-user handle embedded in hubspot_owner_id properties) to contacts.id. Three identifiers stay in separate domains: Contact ID (contacts.hubspot_id, written by contact sync), User ID (written by owner sync), Owner ID (PK of hubspot_owners). Record-owner mirror columns (e.g. buildings.recordOwnerContactId) are written only by the sync layer — quick-lint.sh enforces this isolation.

Security

  • OAuth tokens: encrypted at rest with AES-256-GCM via ENCRYPTION_KEY (the env var name the code reads — backend/src/lib/encryption.ts); stored as EncryptedData jsonb in hubspot_config
  • Token refresh: automatic, 5 minutes before expiry
  • Self-healing auth (I#1896): HubSpotAuthManager handles 401s/revocations without a process restart; a rejected refresh token sets hubspot_config.requires_reauth (HubSpotReauthRequiredError), cleared when an admin re-runs the OAuth flow
  • Webhook validation: HMAC-SHA256 v3 signatures with 5-minute timestamp window; per-source throttling after repeated signature failures
  • Secrets: HUBSPOT_CLIENT_ID (public), HUBSPOT_CLIENT_SECRET (OAuth + webhook signature validation — single secret for both); managed in Infisical
  • Webhook URL override: HUBSPOT_WEBHOOK_URL available in local dev only (ignored on cloud envs); used for ngrok tunnel URLs
  • Inbound sanitization: webhook route sanitizes every string in the payload; API-fetched data sanitized at the apply-mappings choke point (PR#1928)

Hardening Batch (2026-06-11)

PRWhat
PR#1926hubspot_associations data integrity fixes (#1900); association reconciliation queue (hubspot-association-recon-queue + hubspotAssociationReconProcessor)
PR#1927Client hardening: 30s per-request timeout (timeouts retried like network errors), Retry-After honored on 429, health endpoint caching, log hygiene, webhook log retention (I#1902)
PR#1928Sanitize API-fetched entity data at the mapping choke point (#1901)
PR#1964Unified HubSpot log structure (#1961) — all HubSpot services log through moduleLogger({ module: 'hubspot' })
PR#1982Redact propertyValue in webhook log at ingest (I#1970)
PR#1983HubSpot mirror columns on buildings (#1962)

Additionally, PR#1892 (merged 2026-06-12) froze the handover + association config for local/pr-preview mocks, so mock data no longer drifts from the admin-managed configuration.

Background Processing

Sync operations are queued via Background Jobs (BullMQ): hubspot-sync-queue (entity sync), hubspot-webhook-events (inbound webhook processing), and hubspot-association-recon-queue (association reconciliation). The hubspot-webhook-log-cleanup scheduler runs daily at 03:30.

How R1 Reads HubSpot-Tracked Relationships (Association Infrastructure)

For relationships HubSpot also tracks (customer↔deal, contractor↔deal, owner↔deal, invoice_recipient↔company, landlord, property_management, resident, architect, main_contact, care_of, attention, primary_company, facility_management), R1 does not add FK columns on its mirror tables. Business logic reads the relationship through the association infrastructure instead.

Why: HubSpot models these relationships polymorphically. A contractor row may target a contact (a 1-person Einzelunternehmer) or a company (a Handwerks GmbH); the customer and invoice_recipient roles work the same way. Reproducing this as R1 FK columns means either (a) two nullable FKs plus a CHECK constraint per role (verbose, easy to mismodel) or (b) losing the polymorphism by FK-ing only to companies and breaking the 1-person case (data loss). The HubSpot association table already encodes the polymorphism cleanly via (typeId, fromObjectType, toObjectType, fromEntityId, toEntityId).

Stack

  • hubspot_associations — mirror of HubSpot’s association rows.
  • hubspot_association_labels — mirror of HubSpot’s label catalogue, plus three R1-side additions:
    • internal_name — V3-sourced, immutable per pair-direction.
    • renewa_name — a 13-value renewa_association_role PG enum.
    • is_enabled — admin toggle (active = renewa_name IS NOT NULL AND is_enabled = TRUE).
  • RENEWA_ROLE_BINDINGS (backend/src/services/hubspot/renewa-role-bindings.ts) — in-code map keyed on HubSpot’s stable internal_name → R1 role. Reapplied on every sync, so admin renames in HubSpot do not silently un-bind R1 reads.
  • Helper: getAssociationsByRenewaName(role, fromEntityId, fromEntityType, toEntityType?) — exported from backend/src/services/hubspot/association-config.service.ts — returns the matching association rows. Live consumers include project participants, portal access, and billing services.

R1-canonical role enum (renewa_association_role)

RoleWhat it means
customerAuftraggeber:in (project’s commissioning party)
ownerEigentümer:in (property owner)
main_contactAnsprechpartner:in (primary point of contact)
care_ofc/o (delivery via third party)
attentionz.Hd. (delivery to specific named recipient)
property_managementVerwalter:in / Verwaltung
facility_managementHausmeister:in (label dormant in HubSpot at present)
residentBewohner:in
landlordVermieter:in
architectArchitekt:in / Architekt
contractorZugewiesener KOP — despite the legacy “KOP” label in HubSpot, the R1-canonical term is contractor (covers Kooperationspartner + external craftspeople + 1-person Einzelunternehmer)
invoice_recipientRechnungsempfänger
primary_companyHubSpot-defined “Primary” deal-to-company association

Pattern

import { getAssociationsByRenewaName } from '@/services/hubspot/association-config.service';
 
// Returns rows; each row's toEntityType field tells you whether the target is a contact or a company.
const contractors = await getAssociationsByRenewaName('contractor', projectId, 'project', 'company');
const customer    = await getAssociationsByRenewaName('customer', projectId, 'project'); // contact OR company

Forbidden patterns

  • New <role>Id FK columns on R1 mirror tables for HubSpot-tracked relationships — hard “no” since P1 landed.
  • Hardcoding HubSpot typeId numbers in business code — vendor-locked. Use renewa_name enum values.
  • Reading hubspot_associations.internal_name from business logic. The internal_name is a sync-engine implementation detail; go through getAssociationsByRenewaName().

Spec / phase status

  • P0 (merged): entity map + no-vendor-lock rule (hubspotId lives only on mirror tables; cross-table FKs join on UUID PKs). PR#1685.
  • P1 (landed): the association infrastructure described above is in production code. Spec: docs/superpowers/specs/2026-05-08-hubspot-association-infrastructure-design.md.
  • P2–P4 (done): the legacy tech-debt FK columns (kop_package_metadata.contractor_id, workflow_packages.contactId) are no longer in schema.ts; contractor/customer reads go through the helper.

Test Coverage

Tests are co-located with production code per the project convention (e.g. auth.integration.test.ts, webhook-handler.integration.test.ts, sync-operations.integration.test.ts, batch-sync.*.integration.test.ts, association-handler.integration.test.ts) plus older suites under backend/src/test/integration/hubspot/. All use a real test DB (no mock.module); the foundational suites landed in PR#1600.