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

sellers row

Company

A seller is a business. hubspotCompanyId is stored on the seller row.

contacts row

Contact

A person. hubspotContactId is stored on the contact row.

Activity row

Engagement (Email, Call, Note, Task, Meeting)

Correlated by Activity.externalId ↔ engagement_id.

follow_up_tasks row

Task

hubspotTaskId on the task row.

Call row

Call engagement

hubspotEngagementId on the call row.

sales_reps row

Owner

hubspotOwnerId — the CRM owner id, not the settings user id.

—

Contact list

Per-rep dynamic call list; id stored in sales_reps.hubspotCallSegmentId.

hubspot_owner_id must be the id from GET /crm/v3/owners. The number in the HubSpot settings URL (…​/users/user/12345/…​) is a user id and is not guaranteed to match. A wrong id often still creates the Task, but assigns it to the wrong person — usually the integration user. This is called out in pyb_hubspot/tasks.py.

Authentication

Two modes, in priority order:

  1. Private App token — HUBSPOT_PRIVATE_APP_TOKEN (pat-…). Static, no refresh. Preferred.

  2. 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:

  1. hubspot_company_id from the event — skips searching entirely.

  2. The custom amazon_seller_id property — catches companies created by an earlier sync whose id was never written back.

  3. 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:

  1. hubspot_contact_id from the event.

  2. 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.

Isolation

A single contact failure is logged and skipped. It does not block the remaining contacts, and it does not block the Company write-back.

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_id does not match the function’s configured tenant.

  • properties is empty (also rejected by a model_validator on the event).

  • hubspot_contact_id is 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 properties against EmailActivityCreateProperties (which defaults hs_timestamp to 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. Only activity_kind = "email" is supported — anything else raises.

Two follow-on behaviours:

  • When hs_email_direction is INCOMING_EMAIL and hubspot_owner_id is present, a follow-up Task is created and assigned to that owner.

  • An AgentEmail custom behavioural event is sent afterwards, carrying the optional campaign value. The campaign property 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 by hubspot_object_id. Only object_type = "emails" is supported; anything else raises.

Crm.CustomEvent — behavioural events

Only whitelisted kinds are accepted. Today that is agent_email, sent to POST /events/v3/send as pe9298797_agentemail (overridable per message with hubspot_event_name_override). Properties: status (one of opened, clicked, bounced, complained, unsubscribed, delivered, replied, sent), plus optional campaign and link_url. The whitelist means an unknown custom_event_kind fails 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) or hubspot_contact_id, enforced by a model_validator.

  • hubspot_owner_id is mandatory — used both to assign the Task and to look up the matching sales_reps row for the local mirror.

  • task_type maps call/email/todo → HubSpot CALL/EMAIL/TODO.

  • subject is required (hs_task_subject); body is optional (hs_task_body); due_at becomes hs_timestamp; priority defaults to MEDIUM.

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_id present — update exactly that deal.

  • hubspot_contact_id present — find every open deal for the contact via get_open_deal_ids_for_contact() and update each. No open deals logs skip_reason=no_open_deals and 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:

  1. contact_email from the event, when set.

  2. 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 with resolve_hubspot_contact_id_phone_first(), and writes back. batch_limit on the event overrides CONTACT_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, and mobilePhone, then every related target_phone_numbers row (against both the HubSpot phone and mobilephone properties), then every related target_emails row. On the first match it writes hubspotContactId, backfills firstName and lastName from HubSpot where the local values are null, and stamps hubspotLastSyncedAt. On no match it stamps hubspotLastSyncedAt anyway, 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.