Call logging and dispositions
Every call — AI or human — has to end up in two places: a Call row in the
operational database and a Call engagement on the HubSpot contact timeline. Two
separate paths do this, because the two dialers are completely different systems.
| Path | Trigger | Source |
|---|---|---|
AI dialer |
|
The web application’s Retell-based dialer |
Human dialer |
|
Five9 CSV exports dropped on Files.com |
Implementation: lambdas/morpheus_hubspot/services/dialer_call_sync.py (688 lines,
the single densest piece of business logic in the platform) and
events/scheduled/five9_call_sync.py.
AI dialer sync — Crm.DialerCallEnded
The event is deliberately thin — call_id, target_id, campaign_id,
subscription_id, plus a few descriptive fields. Everything the sync needs is read
from the Call and CampaignTarget rows, so the message cannot go stale.
flowchart TD
A["load Call + CampaignTarget"] --> B["effective disposition"]
B --> C{"machine / screening?"}
C -->|yes| C1["hubspotSyncStatus = skipped\nreturn"]
C -->|no| D["pick owner\nround-robin, else fallback owner"]
D --> E["build contact properties"]
E --> F["resolve HubSpot contact\ntarget id, else phone-first search"]
F -->|none| F1["hubspotSyncStatus = skipped\nreturn"]
F --> G["backfill local Contact row"]
G --> H{"disposition = Amazon_Seller?"}
H -->|yes| H1["promote lifecycle stage to MQL\nstamp call list, priority 4"]
H --> I["create HubSpot Call engagement\nrich HTML summary + outcome GUID"]
I -->|failure| I1["hubspotSyncStatus = failed\nreturn"]
I --> J["mark Call + Target synced"]
J --> K{"follow-up trigger?"}
K -->|no| K1["done"]
K -->|yes| L["create HubSpot Task\n+ Activity row\n+ contact heatStatus = hot"]
L --> M["optional Nooks sequence enrolment"]
Effective disposition
The stored disposition is overridden by two stronger machine signals:
if call.callScreeningDetected: return Disposition.AI_SCREENING
if call.inVoicemail: return Disposition.VOICEMAIL_DETECTED
return call.disposition or Disposition.UNDETERMINED
Skip rules
A call that never reached a human is not worth a CRM engagement. Any of these sets
hubspotSyncStatus = skipped and returns:
| Skip reason | Condition |
|---|---|
|
Disposition contains "voicemail", or is |
|
|
|
|
|
Next action is |
|
No |
|
The |
|
Neither the target’s stored id nor the phone-first search resolved a contact |
Thresholds are module constants: HUMAN_DURATION_THRESHOLD_SECONDS = 30,
SHORT_HANGUP_THRESHOLD_SECONDS = 10.
Owner selection
-
Round-robin with
require_nooks=Truewhen a Nooks client is configured. -
Failing that, round-robin with
require_nooks=False. -
Failing that — or when the chosen rep has no
hubspotOwnerId— use the fallback owner86817215, loggingdecision=use_fallback_hubspot_owner.
The fallback owner is the "AI owner". Downstream, every re-engagement segment
treats a contact owned by 86817215 as effectively unowned. See
Ownership and round-robin.
Contact resolution
-
target.hubspotContactId, when set — a direct PATCH with no search. -
Otherwise
resolve_hubspot_contact_id_phone_first(), searching by phone first, then email and mobile phone. Multiple matches are logged atWARNINGand the first is used;match_stepis logged so the resolution path is auditable. -
No resolution → skip.
ensure_local_contact_row() then guarantees a local contacts row exists,
short-circuiting at the first success:
-
The row is already loaded — return it.
-
Look up by
hubspotContactIdlocally, repairingtarget.contactId/target.hubspotContactIdif they point elsewhere. -
Fetch the contact from HubSpot and
upsert_for_seller()it locally, withsource = "hubspot". -
Repair the target’s foreign keys.
A CRM or database failure logs a warning and returns None; the caller continues
without a local row.
Contact properties written
Always:
| Property | Value |
|---|---|
|
The effective disposition |
|
The selected owner |
Additionally, when the disposition is Amazon_Seller — this call is the
re-engagement signal:
| Property | Value |
|---|---|
|
Today (US Eastern) — the contact goes on today’s list |
|
|
|
The selected owner |
Lifecycle promotion
Also for Amazon_Seller only: maybe_promote_lifecycle_stage() reads the CRM
lifecyclestage, and writes marketingqualifiedlead when the current stage ranks
lower. On success the local contacts.lifecycleStage is updated to match. The whole
step is best-effort — a failure logs and the sync continues. Ranks are in
Outbound funnel.
The Call engagement
build_dialer_rich_summary_html() renders an HTML body for the engagement,
mirroring the legacy JavaScript SDK’s buildRichSummary. It always includes the
call summary, then a "RETELL AI ANALYSIS" block: monthly revenue, disposition,
next action, termination reason, detected language, AI-suspicion flag, and
decision-maker flag. Corrected contact info, a reference name, a requested callback
time, and the AI agent name are appended when present.
Engagement properties:
| Property | Value |
|---|---|
|
|
|
The effective disposition |
|
Always the fallback owner |
|
The outcome GUID for |
|
The engagement is always owned by the AI agent, even when the contact and the follow-up task are assigned to a human rep. This keeps the CRM honest about who made the call, and it is what lets the re-engagement segments distinguish AI calls from human calls. |
Duration is max(0, durationSec) * 1000 milliseconds. Transcript, recording URL,
and start timestamp are passed through.
Outcome GUIDs come from pyb_hubspot/disposition_outcomes.py, which holds separate
production and preview tables selected by HUBSPOT_ENVIRONMENT. Note that
Qualified_Lead, Amazon_Seller, and Sales_Handoff all map to the same GUID
(Warm Lead / AE Follow Up). A disposition with no mapping simply omits
hs_call_disposition.
Conflict handling
HubSpot answering with "already has that value" is treated as success, not
failure: the engagement already exists with the same content, so the call is marked
synced and the handler returns. Any other exception marks failed.
On success both the Call row and the CampaignTarget row receive
hubspotEngagementId and hubspotSyncStatus = synced.
Follow-up task creation
Created when any of these is true:
-
call.callbackRequested -
nextAction == Sales_Handoff -
disposition
== Qualified_Lead
Otherwise the handler logs follow_up_skipped … reason=no_trigger and stops.
Task shape — due 24 hours out, type CALL, status NOT_STARTED, assigned to the
selected owner:
| Case | Subject | Priority |
|---|---|---|
Hot lead ( |
|
|
Callback requested |
|
|
After a successful create:
-
An
Activityrow of typetaskis inserted withexternalId = hubspotTaskId, the phone, the email, the campaign, and the target — best-effort. -
contacts.heatStatusis set tohot— best-effort.
Nooks enrolment
Optional, and gated on all of: the fallback owner was not used, a Nooks client is
configured, a rep was selected, that rep has a nooksUserId, and
NOOKS_DEFAULT_SEQUENCE_ID is set. Otherwise the step is skipped with an INFO
line naming the reason. Client: lambdas/morpheus_hubspot/utils/nooks_client.py.
Five9 sync — Hubspot.Five9CallSync
The human dialer has no webhook, so its call logs arrive as CSV files.
sequenceDiagram
participant EB as EventBridge
participant H as Five9CallSyncHandler
participant CFG as configs
participant FC as Files.com
participant HS as HubSpot
participant DB as PostgreSQL
EB->>H: Hubspot.Five9CallSync
H->>CFG: read five9_call_sync_watermark
H->>FC: list /five9/reports modified after watermark
loop each file
H->>FC: download CSV
loop each row
H->>H: parse duration, timestamp, customer phone
H->>DB: resolve contact (HubSpot id → local → target_phone_numbers)
H->>HS: create Call engagement (resolved contacts only)
H->>DB: upsert Call row (all calls)
end
end
H->>CFG: advance watermark to newest file mtime
Files.com is accessed through its REST API at
https://app.files.com/api/rest/v1 with FILES_COM_API_KEY, reading the folder
/five9/reports. The watermark (five9_call_sync_watermark, default
2026-01-01T00:00:00+00:00) is the last processed file modification time. Batch
size is capped by FIVE9_CALL_SYNC_BATCH_SIZE (default 500).
Row handling
-
Duration is parsed from either a
HH:MM:SSstring or a flexible numeric field. -
The customer phone is picked out of the CSV’s several phone columns and normalised to E.164 by
normalize_phone(). -
Contact resolution goes HubSpot id → local
contacts→target_phone_numbers, creating a callee row where one is missing. -
A HubSpot Call engagement is created only for resolved contacts.
Callrows are upserted for every row, so unmatched calls are still recorded locally.
Disposition mapping
Unlike AI dispositions, Five9’s strings have near-exact HubSpot counterparts, so
FIVE9_DISPOSITION_TO_HUBSPOT_GUID maps case-folded CSV values straight to
production outcome GUIDs. Seventeen entries, including several many-to-one
collapses:
| Five9 disposition | HubSpot outcome |
|---|---|
|
Connected |
|
Left voicemail |
|
No answer |
|
Busy |
|
Hung up |
|
Wrong number |
|
Not interested |
|
No longer selling / shut down |
|
Follow up scheduled |
|
Warm Lead (AE Follow Up) |
|
Bridge Issue |
FIVE9_NOANSWER_DISPOSITIONS — no answer, busy, operator intercept,
dial error, answering machine, left voicemail — is used to derive HubSpot’s
hs_call_status.
Call rows are upserted on providerCallId (FIVE9_CALL_CONFLICT_COLUMNS),
updating phone, direction, status, disposition, durations, start timestamp,
engagement id and sync status, contact name, notes, callee row, and rep.
|
Rewinding |
Disposition vocabularies
Three vocabularies coexist. Confusing them is the most common source of bugs here.
| Vocabulary | Values | Where |
|---|---|---|
AI dispositions |
|
|
Next actions |
|
Same file — |
Five9 dispositions |
Free-text CSV strings, case-folded |
|
HubSpot outcomes |
GUIDs, differing per portal environment |
Both files |
The re-engagement views work in HubSpot GUIDs, because that is what the
engagements export contains — which is why the blocked-disposition lists in
bigquery/*.sql are GUID literals rather than readable names. See
Re-engagement segments.