morpheus-hubspot
Physical name: morpheus-sam-hubspot-sync. Source: lambdas/morpheus_hubspot/.
The largest and busiest function in the platform. It is the only component that writes to HubSpot, which concentrates OAuth, property mapping, rate-limit exposure, and re-engagement segmentation in one place.
| Aspect | Value |
|---|---|
Handler |
|
Timeout |
300 s ( |
Memory |
256 MB |
Layers |
common, data-layer, pyb-hubspot, bigquery |
SQS concurrency |
|
SQS events — event_registry
Routing key comes from the event_type field inside the message body, not from a
message attribute. A body may also be a JSON array of event objects, each dispatched
individually.
event_type |
Handler | Purpose |
|---|---|---|
|
|
Upsert a Company + its Contacts, log Notes on updates, write ids back |
|
|
Generic Contact PATCH by id; optional |
|
|
Communication Preferences (email subscription types) |
|
|
Backfill |
|
|
Create a logged Email object, plus an incoming-email follow-up Task and an AgentEmail event |
|
|
PATCH an existing Email object |
|
|
Send a whitelisted behavioural event |
|
|
Create a HubSpot Task and mirror it in |
|
|
Set a Deal stage, by deal id or across a contact’s open deals |
|
|
AI dialer call sync — the densest handler in the repository |
|
|
Dispatch inner HubSpot webhook events to processors |
|
|
Sync a task’s status to HubSpot, or accept HubSpot’s completed state |
Payload schemas: CrmSyncQueue events. Business rules: CRM sync.
An unknown event_type is logged at ERROR and the message discarded (the dispatcher
returns True), since no retry could route it.
Scheduled events — scheduled_registry
detail-type |
Handler | Purpose |
|---|---|---|
|
|
Poll HubSpot for completed tasks |
|
|
Resolve missing |
|
|
Mirror HubSpot engagements into |
|
|
Email-click re-engagement segment |
|
|
Supplier account-created segment |
|
|
Meeting no-show segment |
|
|
Lost/stalled deal segment |
|
|
Rebuild each rep’s dynamic HubSpot call list |
|
|
Import Five9 CSV call logs from Files.com |
All nine rules are enabled. Cadences are in EventBridge schedules; several are set to development values rather than what their descriptions claim.
Services
Handlers stay thin; orchestration lives in services/.
| Module | Lines | Responsibility |
|---|---|---|
|
688 |
The whole |
|
121 |
|
|
101 |
|
|
65 |
Resolve deal ids from a |
Business detail: Call logging and dispositions.
Webhook processors
Crm.HubSpotWebhook carries a batch of inner HubSpot notifications. The handler opens
one session and one CRM client for the whole batch and dispatches each inner event
through WebhookProcessorRegistry. Processors are ordered: the first whose
applies_to() returns True handles that event exclusively.
| Processor | Matches |
|---|---|
|
|
Processor contract:
-
applies_to(inner) → bool— match tightly onsubscription_typeandproperty_name. -
process(*, session, client, subscription_id, inner)— read from HubSpot withclient, persist withsessionand the data-layer repositories. -
Do not commit inside a processor — the handler’s
get_session()block commits once per message. -
Never log full payloads or raw PII.
-
An unsupported inner shape must log and return, not raise, so SQS does not retry the whole batch for an unknown notification type.
Registering: append an instance to the default tuple in
WebhookProcessorRegistry.init, putting more specific matchers before generic ones.
See HubSpot webhooks.
HubSpot client
utils/hubspot_client.py builds a HubSpotSyncClient from HubspotSecrets, preferring
the Private App token and falling back to the OAuth triple. HubspotSecrets validates
at load time that at least one complete auth mode exists and raises otherwise, so a
misconfigured secret fails the cold start rather than the first API call.
Every HubSpot API call goes through pyb-hubspot. Handlers
never import from hubspot.* directly.
Configuration
| Setting | Default | Purpose |
|---|---|---|
|
(template) |
Shared secret |
|
(template) |
Events with a different |
|
|
Rows per |
|
|
Tasks per poll run |
|
|
Engagement rows per sync |
|
|
Five9 rows per sync |
|
|
HubspotSecrets reads DATABASE_URL, HUBSPOT_PRIVATE_APP_TOKEN, the OAuth triple,
HUBSPOT_ENVIRONMENT, the three Nooks keys, BIGQUERY_CREDENTIALS, and
FILES_COM_API_KEY.
Non-environment constants live in utils/constants.py: the newsletter subscription
type id (44467216), the AI fallback owner id (86817215), all four BigQuery
re-engagement queries, watermark keys and defaults, the Five9 disposition→GUID map, the
outreach_list_reason strings, and the call-list name template.
HubSpot portal setup
The function authenticates as a HubSpot Private App (or OAuth app). Permissions are granted in the portal, not by IAM, so a missing scope surfaces as a 403 at runtime rather than at deploy.
Minimum scopes:
| Area | Scopes |
|---|---|
CRM objects |
Companies and Contacts read/write — the core sync |
Emails |
|
Behavioral events |
Send custom event occurrences, for |
Communication Preferences |
|
Each CustomEventKind also needs a matching custom behavioral event defined in the portal
before POST /events/v3/send will accept it.
Email subscription type ids
Contact.Unsubscribe sends a hubspot_subscription_type_id per update. These ids are
portal-specific. Snapshot for portal 9298797, default brand
(businessUnitId: 0):
| Id | Name | Notes |
|---|---|---|
|
Marketing Information |
Marketing offers and updates; the default subscription type |
|
Newsletter |
Weekly email newsletter |
|
One to One |
HubSpot internal default ( |
44467216 is the only one hardcoded, as HUBSPOT_NEWSLETTER_SUBSCRIPTION_TYPE_ID in
utils/constants.py. The webhook processor matches hs_email_optout_44467216 against it to
detect a newsletter opt-out. It is a code constant rather than an environment variable, so
changing portals means changing code.
Re-fetch the current list rather than trusting this table:
curl -H "Authorization: Bearer $HUBSPOT_PRIVATE_APP_TOKEN" \
https://api.hubapi.com/communication-preferences/2026-03/definitions
results[].id values are what to send. Deleting and recreating a subscription type in HubSpot
issues a new id, which silently breaks any producer still sending the old one — the API
accepts the call and updates nothing. Confirm under Settings → Marketing → Email →
Subscription types before wiring a new producer.
|
The Communication Preferences API is email-oriented. Non-email channels are not fully
represented, which is why |
IAM
-
SQSPollerPolicyonCrmSyncQueue -
secretsmanager:GetSecretValueonAgentcoreSecret
No Bedrock access — this function runs no agents.
Tests
cd lambdas && uv run pytest morpheus_hubspot/tests -v
Covers the four re-engagement segments, dialer filters, Five9 sync, lifecycle
promotion, ensure_local_contact_row, the newsletter opt-out processor, the sales-rep
call-list refresh, and BigQuery engagement sync.
Operating notes
-
Everything routes through one function, so a HubSpot outage backs up
CrmSyncQueueand then fillsCrmSyncDlq(maxReceiveCount: 1). -
Empty
BIGQUERY_CREDENTIALSdisables four scheduled handlers at once — the three re-engagement segments and the engagement sync — each logging a warning. -
Missing
FILES_COM_API_KEYdisables the Five9 sync. -
Watermarks for the engagement and Five9 syncs live in
configs. Rewinding the Five9 watermark re-creates HubSpot call engagements — see watermarks. -
The dispatcher’s "Handler failed"
ERRORline includes the full message body, so this log group can contain PII after a failure.