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.sql — client_external_refs table