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
| Layer | Location |
|---|---|
| OAuth / token lifecycle | backend/src/services/hubspot/auth.ts (HubSpotAuthManager) |
| API client | backend/src/services/hubspot/client.ts |
| Webhook receiver | backend/src/routes/hubspot/webhook.ts |
| Monitoring route | backend/src/routes/hubspot/monitoring.ts |
| Sync engine | backend/src/services/hubspot/sync-engine/ |
| Mapping system | backend/src/services/hubspot/mapping/ |
| Event handlers | backend/src/services/hubspot/event-handlers/ |
| Association infrastructure | backend/src/services/hubspot/association-config.service.ts + renewa-role-bindings.ts |
| Owner sync | backend/src/services/hubspot/owner-sync.ts |
| Handover config | backend/src/services/hubspot/handover-config.service.ts |
| Full sync | backend/src/services/hubspot/full-sync.ts |
| Main service | backend/src/services/hubspot/hubspot-sync.service.ts |
| Admin service | backend/src/services/hubspot/hubspot-sync-admin.service.ts |
| Queues | backend/src/lib/jobs/hubspot-sync-queue.ts, hubspot-webhook-event-queue.ts, hubspot-association-recon-queue.ts |
| Processors | backend/src/lib/jobs/processors/hubspotSyncProcessor.ts, hubspotWebhookEventProcessor.ts, hubspotAssociationReconProcessor.ts, hubspot-webhook-log-cleanup.ts |
| Frontend admin | frontend/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 object | objectTypeId | R1 table | Sync config |
|---|---|---|---|
| Contact | 0-1 | contacts | contactConfig |
| Company | 0-2 | companies | companyConfig |
| Deal | 0-3 | projects | dealConfig |
| Quote | 0-14 | quotes | quoteConfig |
| Line Item | 0-8 | quote_line_items | lineItemConfig |
| Listing | 0-420 | buildings | listingConfig |
| Lead | 0-136 | hubspot_leads | leadConfig |
| Product | 0-7 | products | productConfig |
| Association | n/a (polymorphic) | hubspot_associations | event-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/:
| Module | Purpose |
|---|---|
| entity-registry | Object type IDs and registration of syncable entity types |
| entity-configs | Declarative per-entity config (mapEntity, validate, preSyncHook, postSyncHook, fetchFromHubSpot) |
| sync-operations | Core sync logic (syncEntity, archiveEntity, restoreEntity, privacyDeleteEntity, mergeEntity) |
| batch-sync | Bulk sync for initial import/export, watermarks, orphan detection |
| apply-mappings | Applies property mappings; sanitization choke point for API-fetched data |
| owner-resolution | Resolves 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/:
| Module | Purpose |
|---|---|
| property-mapping.service | Maps Renewa fields to HubSpot properties |
| property-sync.service | Pushes property definitions/values |
| enum-mapping.service + enum-value-resolver | Maps enum values between systems |
| type-inference.service + type-coercions | Infers and coerces field types |
| value-transformer | Transforms values during sync (dates, currencies, etc.) |
| mapping-suggestion.service + column-matcher.service | Auto-suggests mappings based on field names |
| mapping-cache | Caches resolved mappings for performance |
Database Tables
| Table | Purpose |
|---|---|
hubspot_config | OAuth tokens (encrypted jsonb), sync settings, requires_reauth flag |
hubspot_property_mappings | Field-to-property mapping definitions |
hubspot_enum_value_mappings | Enum value translations |
hubspot_association_labels | HubSpot label catalogue + R1 internal_name / renewa_name / is_enabled |
hubspot_associations | Record-level associations (polymorphic) |
hubspot_sync_log | Sync operation audit trail |
hubspot_webhook_log | Inbound webhook event log (90-day retention, redacted payloads) |
hubspot_leads | Lead object mirror |
hubspot_handover_config | Admin-configurable deal pipeline stages qualifying for handover (Issue #1431) |
hubspot_owners | HubSpot 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:
| Trigger | Condition |
|---|---|
| Handover readiness | Deal dealstage property changes (vs hubspot_handover_config stages) |
| Scope change detection | Quote hs_quote_status changes to APPROVAL_NOT_NEEDED |
| Association scope change | DEAL_TO_QUOTE primary association change |
Event handlers are at backend/src/services/hubspot/event-handlers/:
| Module | Purpose |
|---|---|
| generic-handler | Parameterized handlers for all standard entity events (creation, deletion, merge, etc.) |
| association-handler | Association 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 asEncryptedDatajsonb inhubspot_config - Token refresh: automatic, 5 minutes before expiry
- Self-healing auth (I#1896):
HubSpotAuthManagerhandles 401s/revocations without a process restart; a rejected refresh token setshubspot_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_URLavailable 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)
| PR | What |
|---|---|
| PR#1926 | hubspot_associations data integrity fixes (#1900); association reconciliation queue (hubspot-association-recon-queue + hubspotAssociationReconProcessor) |
| PR#1927 | Client 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#1928 | Sanitize API-fetched entity data at the mapping choke point (#1901) |
| PR#1964 | Unified HubSpot log structure (#1961) — all HubSpot services log through moduleLogger({ module: 'hubspot' }) |
| PR#1982 | Redact propertyValue in webhook log at ingest (I#1970) |
| PR#1983 | HubSpot 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-valuerenewa_association_rolePG 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 stableinternal_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 frombackend/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)
| Role | What it means |
|---|---|
customer | Auftraggeber:in (project’s commissioning party) |
owner | Eigentümer:in (property owner) |
main_contact | Ansprechpartner:in (primary point of contact) |
care_of | c/o (delivery via third party) |
attention | z.Hd. (delivery to specific named recipient) |
property_management | Verwalter:in / Verwaltung |
facility_management | Hausmeister:in (label dormant in HubSpot at present) |
resident | Bewohner:in |
landlord | Vermieter:in |
architect | Architekt:in / Architekt |
contractor | Zugewiesener KOP — despite the legacy “KOP” label in HubSpot, the R1-canonical term is contractor (covers Kooperationspartner + external craftspeople + 1-person Einzelunternehmer) |
invoice_recipient | Rechnungsempfänger |
primary_company | HubSpot-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 companyForbidden patterns
- New
<role>IdFK columns on R1 mirror tables for HubSpot-tracked relationships — hard “no” since P1 landed. - Hardcoding HubSpot
typeIdnumbers in business code — vendor-locked. Userenewa_nameenum values. - Reading
hubspot_associations.internal_namefrom business logic. The internal_name is a sync-engine implementation detail; go throughgetAssociationsByRenewaName().
Spec / phase status
- P0 (merged): entity map + no-vendor-lock rule (
hubspotIdlives 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 inschema.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.
Related
- Background Jobs — async sync queue processing
- Validation Pattern — input validation for synced data
- Admin Dashboard — HubSpot configuration UI
- External Integrations — other third-party integrations
- Database Architecture — encrypted storage design