pyb-hubspot
Import prefix pyb_hubspot. Layer PybHubspotLayer. Used by morpheus-hubspot and
enrichment-scorer.
Source: libs/pyb-hubspot/pyb_hubspot/.
Every HubSpot API call lives here. Handlers never import hubspot.* directly, which keeps
auth modes, property mapping, association type ids, and disposition GUIDs in one place.
Clients
Two, sharing the same bearer token.
HubSpotSyncClient-
Wraps the official HubSpot Python SDK. Exposes
companies,contacts,deals,associations, andlistsfromcrm.*, plusaccess_tokenfor callers that need the raw token. HubSpotHttpClient-
Direct
api.hubapi.comrequests for endpoints the SDK does not cover — currently Communication Preferences. Obtain one from a sync client withas_http_client(), or build it directly.
Authentication
# Private App token — preferred: static, no refresh
client = HubSpotSyncClient.from_private_app_token("pat-na1-…")
# OAuth credentials — fallback
client = HubSpotSyncClient.from_oauth(
client_id=secrets.hubspot_client_id,
client_secret=secrets.hubspot_client_secret,
refresh_token=secrets.hubspot_refresh_token,
)
http = client.as_http_client()
Both factories return the same object, so library code taking a client does not need to know
which mode produced it. Use the factory class methods rather than init.
auth.refresh_hubspot_access_token() performs the OAuth exchange. It lives here — not in
morpheus-llm — because token refresh is a HubSpot credential concern needed by both the LLM
layer’s MCP tool and the CRM sync Lambda; morpheus_llm.utils.hubspot re-exports it.
Module map
| Module | Contents |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Shared constants and payload builders |
Sync — company and contact upsert
Both return enough information for the caller to decide whether to log an update Note.
upsert_company(client, event) → UpsertResult-
Search priority, stopping at the first hit:
-
hubspot_company_idfrom the event — skips searching entirely. -
The custom
amazon_seller_idproperty — catches companies created by an earlier sync whose id was never written back. -
name— catches companies created outside Morpheus.No match creates a new Company.
-
upsert_contact(client, payload, company_id) → str | None-
Search priority:
-
hubspot_contact_idfrom the payload. -
email— HubSpot’s own primary deduplication key for Contacts.No match creates a new Contact, then associates it with the Company through the v4 Associations API. A payload with no email and no existing id returns
None— there is nothing to deduplicate on.
-
Property mapping lives in models.py so the sync functions stay free of field-name strings
and the mapping can be unit-tested on its own.
Contact search
resolve_hubspot_contact_id_phone_first(client, *, email, phone, mobile_phone) returns a
PhoneFirstResolveResult carrying the resolved id, the match_step that found it, and
had_multiple_matches. Callers log match_step, which makes a resolution auditable after the
fact, and log a warning on multiple matches while using the first.
search_contact_ids_by_property_eq(client, prop, value, limit) is the primitive underneath —
an equality filter on any standard property. The scheduled Hubspot.ContactResolve handler
uses it directly to try phone and mobilephone against every related
target_phone_numbers row.
Typed properties
Arbitrary HubSpot property names are allowed via extra="allow", while the documented custom
fields are typed:
| Model | Use |
|---|---|
|
Contact PATCH — any internal property name plus documented custom fields |
|
Email object POST; |
|
Email object PATCH; partial fields, no auto-filled timestamp |
Validating before the call means a malformed property dict fails in the handler with a Pydantic error rather than as an opaque HubSpot 400.
Call engagements and dispositions
create_call_engagement(client, hubspot_contact_id, CallEngagementInput(…), extra_properties=…)
creates a Call object and associates it with a Contact (v3 create plus v4 associations).
CallEngagementInput carries duration, summary HTML, transcript, recording URL, and start
timestamp.
disposition_outcomes.py holds the disposition vocabulary and the GUID tables:
class Disposition(StrEnum):
QUALIFIED_LEAD = "Qualified_Lead"
AMAZON_SELLER = "Amazon_Seller"
SALES_HANDOFF = "Sales_Handoff"
CALLBACK_REQUESTED = "Callback_Requested"
NOT_QUALIFIED = "Not_Qualified"
NOT_INTERESTED = "Not_Interested"
WRONG_NUMBER = "Wrong_Number"
DO_NOT_CALL = "Do_Not_Call"
NOT_A_SELLER = "Not_a_Seller"
HANGUP_EARLY = "Hangup_Early"
AI_ISSUE = "AI_Issue"
BUSY = "Busy"
AI_SCREENING = "AI_Screening"
GATEKEEPER = "Gatekeeper"
VOICEMAIL_DETECTED = "Voicemail_Detected"
UNDETERMINED = "Undetermined"
class NextAction(StrEnum):
NONE = "None"
RETARGET_LATER = "Retarget_Later"
SALES_HANDOFF = "Sales_Handoff"
def get_hubspot_outcome_id(disposition: str, environment: Environment) -> str | None
PRODUCTION_OUTCOME_IDS and PREVIEW_OUTCOME_IDS are separate tables selected by
HUBSPOT_ENVIRONMENT. Two things to know: Qualified_Lead, Amazon_Seller, and
Sales_Handoff all map to the same GUID (Warm Lead / AE Follow Up), and
Voicemail_Detected and Undetermined have no mapping — the caller omits
hs_call_disposition when the lookup returns None.
Tasks
| Function | Purpose |
|---|---|
|
Create a Task with associations; returns the HubSpot task id |
|
Read task properties; raises |
|
PATCH a task |
|
The specialised task |
|
|
Lists
create_contact_list(), delete_list(), and build_call_list_filter_branch() back the daily
per-rep call list. The filterBranch payload is a generic oneOf on the SDK’s
ListCreateRequest, so it is hand-built to match HubSpot’s list-filters schema and passed
through as a dict.
See list materialisation.
Notes
log_note(client, …) creates a CRM Note associated with a Company and/or Contact in one API
call. Morpheus logs a Note whenever a sync updated an existing record, so the timeline shows
that automation touched it.
Association type ids
HubSpot-defined ids used for v3 inline associations:
| Association | Type id |
|---|---|
Email → Contact |
198 |
Email → Company |
186 |
Note → Contact |
202 |
Note → Company |
190 |
Verify against the Associations API if an association starts failing — these are portal-level constants that HubSpot documents but can extend.
Communication preferences
apply_subscription_preference_updates() delegates to
HubSpotHttpClient.post_communication_preference_subscription_statuses(), hitting
POST /communication-preferences/2026-03/statuses/{email}. HubSpot may require one POST per
subscription type, so the list is applied item by item.
Reference: HubSpot Communication Preferences.