CRM sync
HubSpot is the system of record for the sales team. Morpheus treats it as an
integration target with exactly one writer:
morpheus-hubspot. Everything else publishes
an event to CrmSyncQueue.
Implementation: lambdas/morpheus_hubspot/events/, with every HubSpot API call
isolated in the pyb-hubspot library.
Object mapping
| Morpheus | HubSpot | Notes |
|---|---|---|
|
Company |
A seller is a business. |
|
Contact |
A person. |
|
Engagement (Email, Call, Note, Task, Meeting) |
Correlated by |
|
Task |
|
|
Call engagement |
|
|
Owner |
|
— |
Contact list |
Per-rep dynamic call list; id stored in |
|
|
Authentication
Two modes, in priority order:
-
Private App token —
HUBSPOT_PRIVATE_APP_TOKEN(pat-…). Static, no refresh. Preferred. -
OAuth triple —
HUBSPOT_CLIENT_ID,HUBSPOT_CLIENT_SECRET,HUBSPOT_REFRESH_TOKEN. Exchanged for a short-lived access token.
HubspotSecrets validates at load time that at least one complete mode exists and
raises otherwise, so a misconfigured secret fails the cold start loudly rather
than at the first API call.
Both modes produce the same HubSpotSyncClient. Endpoints not covered by the
official SDK (Communication Preferences, custom events) go through
HubSpotHttpClient, which carries the same bearer token.
Seller sync — Seller.CrmSync
Consumes the fat SellerSyncEvent published by
enrichment-scorer. No database read is
needed to perform the sync; the database is only written at the end.
sequenceDiagram
participant Q as CrmSyncQueue
participant H as SellerCrmSyncHandler
participant HS as HubSpot
participant DB as PostgreSQL
Q->>H: Seller.CrmSync (company fields + contacts[])
H->>HS: upsert_company()
alt company already existed
H->>HS: log_note() on the Company
end
loop each contact
H->>HS: upsert_contact() + associate with Company
alt contact already existed
H->>HS: log_note() on the Contact
end
end
H->>DB: sellers.hubspotCompanyId
H->>DB: contacts.hubspotContactId (per contact)
Company match priority
pyb_hubspot/sync/company.py searches in this order and stops 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. The function returns
UpsertResult(id, created) so the caller knows whether to log an update Note.
Contact match priority
pyb_hubspot/sync/contact.py:
-
hubspot_contact_idfrom the event. -
email— HubSpot’s own primary deduplication key for Contacts.
No match creates a new Contact, which is then associated with the Company using
the v4 Associations API. A payload with no email and no existing id returns
None — there is nothing to deduplicate on.
Contact property updates — Contact.Update
The generic Contact PATCH path. hubspot_contact_id is required; there is no CRM
search and no local contacts lookup, which makes it cheap and predictable.
Guards, each logging at ERROR and returning without raising:
-
subscription_iddoes not match the function’s configured tenant. -
propertiesis empty (also rejected by amodel_validatoron the event). -
hubspot_contact_idis blank after trimming.
Optional write-back: when target_phone_number_id is set, the corresponding
target_phone_numbers row is stamped after a successful PATCH. This is how
phone line intelligence closes its loop.
This is the event every re-engagement producer in phone-agent uses to set
call_list_date, call_priority, and call_assigned_to.
Activities
Crm.NewActivity— create a logged Email object-
Validates
propertiesagainstEmailActivityCreateProperties(which defaultshs_timestampto now when omitted, since HubSpot requires it on create), then creates the Email and associates it with the Contact and/or Company. At least one association target is required; without one the handler logs a warning and returns. Onlyactivity_kind = "email"is supported — anything else raises.Two follow-on behaviours:
-
When
hs_email_directionisINCOMING_EMAILandhubspot_owner_idis present, a follow-up Task is created and assigned to that owner. -
An
AgentEmailcustom behavioural event is sent afterwards, carrying the optionalcampaignvalue. Thecampaignproperty lives on the custom event, not on the Email object.
-
Crm.UpdateActivity— PATCH an existing object-
Validates against
EmailActivityUpdateProperties(partial fields, no auto timestamp) and PATCHes byhubspot_object_id. Onlyobject_type = "emails"is supported; anything else raises. Crm.CustomEvent— behavioural events-
Only whitelisted kinds are accepted. Today that is
agent_email, sent toPOST /events/v3/sendaspe9298797_agentemail(overridable per message withhubspot_event_name_override). Properties:status(one ofopened,clicked,bounced,complained,unsubscribed,delivered,replied,sent), plus optionalcampaignandlink_url. The whitelist means an unknowncustom_event_kindfails validation before any API call.
Association type ids used inline (HubSpot-defined): Email→Contact 198,
Email→Company 186, Note→Contact 202, Note→Company 190.
Notes
log_note() (pyb_hubspot/engagements.py) creates a CRM Note associated with a
Company and/or Contact in a single API call. Morpheus logs a Note whenever a sync
updated an existing record, so the CRM timeline shows that automation touched
it — a create needs no note because the record’s existence is the evidence.
Tasks — Crm.TaskCreate
Creates a HubSpot Task first, then mirrors it locally. Order matters: the HubSpot
id is needed to populate follow_up_tasks.hubspotTaskId.
Requirements:
-
At least one of
contact_id(internal UUID) orhubspot_contact_id, enforced by amodel_validator. -
hubspot_owner_idis mandatory — used both to assign the Task and to look up the matchingsales_repsrow for the local mirror. -
task_typemapscall/email/todo→ HubSpotCALL/EMAIL/TODO. -
subjectis required (hs_task_subject);bodyis optional (hs_task_body);due_atbecomeshs_timestamp;prioritydefaults toMEDIUM.
See Follow-up tasks and escalation for what happens next.
Deals — Crm.DealUpdate
Sets a HubSpot Deal’s stage, in one of two modes:
-
hubspot_deal_idpresent — update exactly that deal. -
hubspot_contact_idpresent — find every open deal for the contact viaget_open_deal_ids_for_contact()and update each. No open deals logsskip_reason=no_open_dealsand returns.
A model_validator requires at least one identifier. When note is set it is
logged on each deal before the stage change, so the timeline reads in causal
order. A per-deal update failure is logged and the loop continues.
Communication preferences — Contact.Unsubscribe
Updates HubSpot email subscription types through
POST /communication-preferences/…/statuses/{email} — a REST endpoint not covered
by the SDK, so it goes through HubSpotHttpClient.
Subscriber email resolution:
-
contact_emailfrom the event, when set. -
Otherwise
get_by_id(hubspot_contact_id)to read the email from the CRM.
Neither present is a validation error on the event model. Each
SubscriptionPreferenceUpdate carries a numeric hubspot_subscription_type_id
(validated as digits) and a status of SUBSCRIBED, UNSUBSCRIBED, or
NOT_SPECIFIED. legal_basis and legal_basis_explanation are passed through
when the portal’s data-privacy settings require them. HubSpot may require one POST
per subscription type, so the list is applied item by item.
HubSpot id backfill
Many contacts arrive without a hubspotContactId — imported, created by the web
application, or synced from BigQuery. Two paths resolve them.
Contact.ResolveHubSpotIds(SQS, on demand)-
Loads contacts missing the id via
ContactRepository.list_missing_hubspot_contact_id(), resolves each withresolve_hubspot_contact_id_phone_first(), and writes back.batch_limiton the event overridesCONTACT_HUBSPOT_RESOLVE_BATCH_SIZE. The payload carries no PII — the handler loads candidates itself. Hubspot.ContactResolve(scheduled, every 30 minutes)-
The same idea with a wider search. It tries the contact’s own
email,phone, andmobilePhone, then every relatedtarget_phone_numbersrow (against both the HubSpotphoneandmobilephoneproperties), then every relatedtarget_emailsrow. On the first match it writeshubspotContactId, backfillsfirstNameandlastNamefrom HubSpot where the local values are null, and stampshubspotLastSyncedAt. On no match it stampshubspotLastSyncedAtanyway, which deprioritises that contact in subsequent runs so the batch keeps moving through new candidates.
Inbound: HubSpot → Morpheus
Changes made by humans in HubSpot arrive as Crm.HubSpotWebhook events on
CrmSyncQueue (relayed by an external forwarder). CrmHubSpotWebhookHandler opens
one session and one CRM client for the whole batch and dispatches each inner event
through an ordered processor registry: the first processor whose applies_to()
returns True handles it exclusively.
Today one processor is registered: NewsletterOptOutProcessor, matching
contact.propertyChange on hs_email_optout_<subscriptionTypeId> where the id is
44467216. See opt-outs and
HubSpot webhooks.
Rate limits
The HubSpot MCP transport used by AI agents retries HTTP 429 with exponential
backoff — up to 4 attempts, base 1 s doubling, capped at 30 s per sleep, honouring
Retry-After when present (morpheus_llm/tools/builtin/hubspot.py).
Direct SDK calls from morpheus-hubspot do not add retry logic; the function
relies on MaximumConcurrency: 10 on the SQS trigger plus BatchSize: 1 to keep
request rates modest, and on the DLQ to capture anything that still fails.