Skip to main content

HubSpot

HubSpot mirrors companies and contacts into SABLE's client/contact records. It is staged behind conservative defaults: the sync is safe to run in dry run or matching-scan mode at any time, but the real write path is off by default and requires two separate switches to enable.

How it works

hubspot-sync pulls HubSpot companies and contacts via the private-app token HUBSPOT_SERVICE_KEY (scopes: crm.objects.companies.read, .contacts.read, .deals.read — deals are counted for context but not ingested). Email ingestion is explicitly out of scope for this connector; that's handled separately by the Email/BCC connector.

Matching:

  • Companies → clients, matched first by a stable client_external_refs id if one already exists, otherwise by normalized company name.
  • Contacts → external_contacts, linked via the contact's associated company, deduped on client_id + email or client_id + name.

Two run modes:

  • Match-rate scan (dryRun: true, optionally fullScan: true) — pages through the whole portal to report the true match rate without writing anything. fullScanCap bounds how many records a full scan will page through (100–20,000, default 5,000).
  • Real run (confirm: true) — same matching logic, but actually writes clients and external_contacts. It also respects a wall-clock time budget, the same bounded-ingest pattern used by Fathom and Granola.

The double gate

A real run requires both:

  1. The caller passes confirm: true.
  2. The edge secret HUBSPOT_SYNC_WRITES_ENABLED is exactly "true".

The secret ships unset, so the real write path is — by the code comment's own description — "physically incapable of writing" until an operator deliberately sets it. This is a stronger gate than most connectors' single confirm flag, because HubSpot's matching problem (below) makes an accidental real run more consequential than most.

The matching problem

Exact-name matching alone isn't enough for every client in the portal. As last measured, only 15 of 32 clients matched by exact name — the rest are alias/parent-company gaps (for example "Indigo Trigger LLC" vs. "Indigo Trigger", or "Hearst Corporation" vs. "Hearst Television"). Before arming the real write path, an operator has to choose between:

  • Exact-match-only — conservative; creates new client rows for every alias rather than risking a wrong merge.
  • Fuzzy/domain-based matching — closes more of the gap, but needs review before being trusted to run unattended.

Whichever path is chosen, any real write path should stay double-gated by the operator switch above — this isn't a one-time decision that removes the gate.

Configuration

SettingValue
SecretHUBSPOT_SERVICE_KEY (private-app token; scopes: companies.read, contacts.read, deals.read)
Write gateHUBSPOT_SYNC_WRITES_ENABLED=true (edge secret; unset by default)
Full-scan cap100–20,000 records, default 5,000

Cron cadence

There is no Trigger.dev schedule for HubSpot — it's registered as a valid edge function for on-demand/manual invocation only. This differs from every other connector in this section, which all run on some schedule.

Table mapping

TableWhat's written (real run only)
clientsNew client rows for unmatched companies
client_external_refsUpsert on (org_id, provider, normalized_id), provider='hubspot_company'
external_contactsInsert/update, deduped on client_id+email or client_id+name

clients and client_external_refs are also read, to resolve existing links before deciding whether to create a new client row.

Modes

{ "dryRun": true }
{ "dryRun": true, "fullScan": true }

What can go wrong

  • A real run appears to do nothing. Check HUBSPOT_SYNC_WRITES_ENABLED first — it's unset by default, so confirm:true alone is not enough to write anything.
  • Duplicate client rows for the same real-world company. This is the known alias/parent-company gap — exact-name matching won't merge "Hearst Corporation" and "Hearst Television," for example. Decide on exact-match-only vs. fuzzy/domain matching before running a real sync at scale, and reconcile existing duplicates via client_external_refs rather than re-running with different settings and hoping it self-corrects.
  • A fullScan seems to stop before covering the whole portal. Check fullScanCap — it's a deliberate ceiling (default 5,000), not a bug, meant to bound how much a single scan call can page through.
  • Nothing runs on a schedule. That's expected — HubSpot has no cron task; every run (dry-run scan or real) has to be triggered explicitly.

Where the code lives

  • supabase/functions/hubspot-sync/index.ts — matching, dry-run scan, and gated real write
  • supabase/migrations/20260723131729_account_creation.sqlclient_external_refs table