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 stableclient_external_refsid if one already exists, otherwise by normalized company name. - Contacts →
external_contacts, linked via the contact's associated company, deduped onclient_id + emailorclient_id + name.
Two run modes:
- Match-rate scan (
dryRun: true, optionallyfullScan: true) — pages through the whole portal to report the true match rate without writing anything.fullScanCapbounds how many records a full scan will page through (100–20,000, default 5,000). - Real run (
confirm: true) — same matching logic, but actually writesclientsandexternal_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:
- The caller passes
confirm: true. - The edge secret
HUBSPOT_SYNC_WRITES_ENABLEDis 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
| Setting | Value |
|---|---|
| Secret | HUBSPOT_SERVICE_KEY (private-app token; scopes: companies.read, contacts.read, deals.read) |
| Write gate | HUBSPOT_SYNC_WRITES_ENABLED=true (edge secret; unset by default) |
| Full-scan cap | 100–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
| Table | What's written (real run only) |
|---|---|
clients | New client rows for unmatched companies |
client_external_refs | Upsert on (org_id, provider, normalized_id), provider='hubspot_company' |
external_contacts | Insert/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_ENABLEDfirst — it's unset by default, soconfirm:truealone 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_refsrather than re-running with different settings and hoping it self-corrects. - A
fullScanseems to stop before covering the whole portal. CheckfullScanCap— 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 writesupabase/migrations/20260723131729_account_creation.sql—client_external_refstable