HubSpot Connector
The HubSpot connector is Olyron CRM's live two-way bridge to HubSpot CRM. It runs a real OAuth 2.0 handshake, then pushes and pulls contacts (leads), deals, notes, and tasks against the HubSpot v3 CRM API — and records vendor-enrollment changes onto the member's HubSpot timeline. This page documents the connector as it ships today.
#Current status
getSupportedProviders() it is flagged implemented: true, so on the Integrations screen the HubSpot card shows an "Available" badge and a working "Connect HubSpot" button. Zoho CRM and Salesforce are also live and follow the same connect → callback pattern.The HubSpotConnector class (in src/lib/crm-connectors/providers/hubspot.ts) implements every capability it declares. connect and refreshToken perform real OAuth token exchanges against HubSpot, and pushLead, updateLead, fetchLeads, pushNote, createTask, pushDeal, updateDeal, fetchDeals, and syncVendorEnrollment all make live calls to the HubSpot v3 CRM API. Requests flow through the shared BaseConnector.request helper, which retries with exponential backoff and tolerates empty / 204 responses.
| Piece | State | Where |
|---|---|---|
| Provider registration | Live | factory.ts (getConnector) |
| OAuth authorize URL + scopes | Live | factory.ts (getAuthUrl) |
| OAuth callback route | Live | api/crm-connectors/[provider]/callback |
Token exchange (connect / refreshToken) | Live | providers/hubspot.ts |
| Lead / deal / note / task sync | Live | providers/hubspot.ts |
| Vendor enrollment sync | Live | providers/hubspot.ts |
| Encrypted token storage | Live (AES-256-GCM) | sdk.ts (encryptToken / decryptToken) |
Field-mapping screen (map-fields) | Not yet built | referenced by callback redirect |
#Why it matters
Advisors who already run their book of business in HubSpot want Olyron's insurance-native tooling — vendor enrollment feeds, plan changes, member rosters — to flow back into the pipeline they report on. The connector is a two-way sync (bidirectional: true) so leads and deals stay consistent in both systems rather than being copied once and drifting.
A distinguishing capability is syncVendorEnrollment: Olyron normalizes carrier/vendor enrollment attributes (plan, status, change type, effective and termination dates) and records them as a Note on the member's HubSpot contact, so the change lands directly on the contact's activity timeline — something a generic CRM sync does not do.
#How the connect flow works
The OAuth handshake follows the standard authorization-code grant:
- 1Start from the Integrations screenGo to Dashboard → Settings → Integrations (
/dashboard/settings/integrations). Because HubSpot isimplemented: true, its card shows an "Available" badge and a Connect HubSpot button that routes to/dashboard/settings/integrations/hubspot/connect. - 2Pick what to syncOn the connect page you can toggle Vendor enrollment sync (on by default) and choose which vendor fields to include. The options are Plan Name, Status, Effective Date, Termination Date, and Premium; the first three are selected by default. These preferences are stashed in
sessionStorageundercrm_vendor_sync_preferencesas{ enabled, fields }. - 3Authorize in HubSpotOlyron builds the authorize URL with
getAuthUrl("hubspot", redirectUri, state)and redirects you tohttps://app.hubspot.com/oauth/authorize. Thestatevalue is"<tenantId>:<timestamp>"and is also saved tosessionStorage(crm_oauth_state) for CSRF protection. The redirect URI is${window.location.origin}/api/crm-connectors/hubspot/callback. - 4HubSpot calls backAfter you grant access, HubSpot redirects to
/api/crm-connectors/hubspot/callback?code=...&state=.... The route parses thetenantIdfromstate, confirms a signed-in Supabase user, and exchanges the code for tokens by callingconnector.connect({ auth_code: code }). - 5Connection is savedOn success the callback inserts a row into
crm_connectionswith AES-256-GCM-encrypted access and refresh tokens,expires_at, andstatus: "active", then redirects to the field-mapping step with?connection_id=<id>&success=true.
https://app.hubspot.com/oauth/authorize
?client_id=<HUBSPOT_CLIENT_ID>
&redirect_uri=<origin>/api/crm-connectors/hubspot/callback
&scope=crm.objects.contacts.read crm.objects.contacts.write
crm.objects.deals.read crm.objects.deals.write
crm.objects.notes.read crm.objects.notes.write
crm.objects.tasks.read crm.objects.tasks.write
&state=<tenantId>:<timestamp>${window.location.origin}/api/crm-connectors/hubspot/callback. Register that exact URL as a redirect URI in your HubSpot app, and set HUBSPOT_REDIRECT_URI to the same value — connect() sends it in the token exchange and HubSpot rejects a mismatch.#Configuration
To stand up the connector you need a HubSpot public app and the following environment variables. HUBSPOT_CLIENT_ID is read by getAuthUrl and connect(); the client secret and redirect URI are used by the token exchange in connect(); refreshToken() needs the client ID and secret.
HUBSPOT_CLIENT_IDstring- Public app client ID. Injected into the authorize URL and sent in both the authorization-code and refresh-token grants.
HUBSPOT_CLIENT_SECRETstring- App client secret used when exchanging the authorization code and refreshing tokens at POST https://api.hubapi.com/oauth/v1/token.
HUBSPOT_REDIRECT_URIstring- The registered callback URL. Must equal <origin>/api/crm-connectors/hubspot/callback and match the redirect URI used to obtain the code.
CRM_ENCRYPTION_KEYstring- Secret used to derive the AES-256-GCM key that encrypts stored CRM tokens. Applies to all connectors, not just HubSpot. Required in production — the app refuses to store tokens without it.
Token storage is encrypted at rest
OAuth access and refresh tokens are stored in crm_connections.access_token_enc / refresh_token_enc. The encryptToken / decryptToken helpers in sdk.ts use AES-256-GCM (authenticated encryption) with a 32-byte key derived by SHA-256 from CRM_ENCRYPTION_KEY. Ciphertext is stored in this format:
v1:gcm:<iv_b64>:<tag_b64>:<ciphertext_b64>NODE_ENV === "production" and CRM_ENCRYPTION_KEY is missing, token storage throws rather than persisting anything. In non-production it falls back to an insecure built-in dev key and logs a warning, so local dev works without extra setup — never rely on that key outside development.decryptToken still reads them for backward compatibility, and each is transparently upgraded to AES-256-GCM the next time the token is persisted (isLegacyToken reports whether a stored value is still legacy). Note this is a symmetric app-key scheme keyed off one env var; a separate KMS envelope module (src/lib/secrets/envelope.ts) exists for other at-rest secrets and is not what protects these CRM tokens.#What syncs and how fields map
The connector declares these capabilities, each backed by a live HubSpot v3 CRM call. All requests send an Authorization: Bearer <access_token> header.
| Capability | Value | HubSpot v3 call |
|---|---|---|
| Push leads | true | POST /crm/v3/objects/contacts |
| Update leads | true | PATCH /crm/v3/objects/contacts/{id} |
| Pull leads | true | GET /crm/v3/objects/contacts · POST /objects/contacts/search |
| Push notes | true | POST /crm/v3/objects/notes |
| Push tasks | true | POST /crm/v3/objects/tasks |
| Push deals | true | POST /crm/v3/objects/deals |
| Update deals | true | PATCH /crm/v3/objects/deals/{id} |
| Pull deals | true | GET /crm/v3/objects/deals |
| Bidirectional | true | — |
| Vendor sync | true | POST /crm/v3/objects/notes (on member contact) |
The webhooks capability is declared true, but the connector does not currently register HubSpot subscriptions itself — sync is driven by the push/pull operations above.
Contact (lead) field mapping
pushLead and updateLead send these standard HubSpot contact properties, then merge in any tenant field mappings and custom_fields. Non-standard values such as status and source are not pushed directly — route them through field mappings to real HubSpot properties to avoid 400s. fetchLeads reads the properties below and maps hs_lead_status back to status.
| Olyron field | HubSpot property | Direction |
|---|---|---|
| first_name | firstname | push + pull |
| last_name | lastname | push + pull |
| push + pull | ||
| phone | phone | push + pull |
| status | hs_lead_status | pull only (push via field mapping) |
| source | via field mapping | push via field mapping |
| custom_fields | as-provided property keys | push |
fetchLeads returns up to query.limit (default 100) contacts. With query.search it POSTs to /objects/contacts/search matching the term against email EQ or lastname EQ; otherwise it GETs /objects/contacts and uses query.offset as the HubSpot after cursor.
Deal field mapping
| Olyron field | HubSpot property | Direction |
|---|---|---|
| title | dealname | push + pull |
| amount | amount | push + pull |
| stage | dealstage | push + pull |
| expected_close_date | closedate (ISO) | push + pull |
| contact_id | association (deal → contact) | push |
fetchDeals requests the dealname,amount,dealstage,closedate,pipeline properties (default limit 100, offset → after) and maps amount back to a number.
#Notes, tasks, and associations
In HubSpot, engagement objects (notes, tasks) must be associated to their contact or deal. The connector creates the object and attaches it using HubSpot-defined (v4) association type IDs in the same request.
| Association | associationTypeId | Used by |
|---|---|---|
| Note → Contact | 202 | pushNote, syncVendorEnrollment |
| Task → Contact | 204 | createTask (default) |
| Task → Deal | 216 | createTask when related_to_type = "deal" |
| Deal → Contact | 3 | pushDeal when contact_id is set |
pushNote writes hs_note_body (prefixed with the note title when present) and hs_timestamp, associating the note to the given contact ID via type 202. An additional Note → Deal type (214) is defined in the connector but pushNote currently only associates notes to contacts.
Task enums
createTask sends hs_task_subject, hs_task_body, hs_task_status, hs_task_priority, and hs_timestamp (the task's due date, or now). Olyron statuses and priorities are normalized to HubSpot's enums:
| HubSpot status | Mapped from |
|---|---|
| NOT_STARTED | default / anything unmatched |
| IN_PROGRESS | "in_progress" / "in progress" |
| WAITING | "waiting" |
| COMPLETED | "completed" / "done" |
| DEFERRED | "deferred" |
| HubSpot priority | Mapped from |
|---|---|
| HIGH | "high" |
| MEDIUM | default |
| LOW | "low" |
#Vendor enrollment sync
syncVendorEnrollment(payload) records a vendor/carrier enrollment change on the HubSpot activity timeline. It builds a plain-text summary from the payload (member name, plan, status, change type, effective and termination dates, and a free-text description), then:
- If
payload.member_external_idis set, it callspushNote(member_external_id, …)so the enrollment appears as a Note on that member's contact, titledVendor enrollment: <vendor_name>(association type 202). - If there is no linked contact, it POSTs an unassociated note to
/crm/v3/objects/noteswith the vendor name and summary inline, so the record still exists in HubSpot.
member_external_id maps to a real HubSpot contact — that puts the change directly on the member's timeline. Without it the note is still created, just not attached to anyone.#Limits and troubleshooting
- HubSpot access tokens are short-lived (the connector stores
expires_atfrom theexpires_inHubSpot returns); refresh tokens do not expire.refreshToken()performs therefresh_tokengrant usingHUBSPOT_CLIENT_IDandHUBSPOT_CLIENT_SECRET. - HubSpot's rate limit is roughly 100 requests per 10 seconds per account. Every connector call goes through
BaseConnector.request, which retries with exponential backoff (up to 3 attempts) and skips retries on 4xx except 429. - The success redirect and the Active Connections "Configure" button both target
/dashboard/settings/integrations/<provider>/map-fields, but that field-mapping route is not built yet — only theconnectroute exists under[provider]. - State validation in the callback currently only checks that
stateis present; full CSRF verification against the storedcrm_oauth_stateis still marked "validate this properly" in the code.
Callback error codes
If the OAuth flow fails, the callback redirects back to /dashboard/settings/integrations with an ?error= query parameter:
| error value | Meaning |
|---|---|
| no_code | HubSpot returned no authorization code. |
| invalid_state | The state parameter was missing. |
| connection_failed | connect() returned success: false — e.g. missing auth_code, HubSpot OAuth env vars not configured, or HubSpot rejected the token exchange. The specific message is passed through in the error value. |
| db_error | The connection could not be written to crm_connections. |
| callback_error | An unexpected exception occurred in the callback handler. |