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

Crm.DialerCallEnded (SQS, event-driven)

The web application’s Retell-based dialer

Human dialer

Hubspot.Five9CallSync (scheduled)

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

skipped_machine_voicemail

Disposition contains "voicemail", or is Voicemail_Detected

skipped_machine_short_hangup

Hangup_Early and duration < 10 seconds

skipped_screening

AI_Screening and duration ⇐ 30 seconds

skipped_machine_retarget

Next action is Retarget_Later and the disposition is not Hangup_Early

call_not_found

No Call row for the id triple

missing_target_relation

The Call has no CampaignTarget

no_hubspot_contact

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

  1. Round-robin with require_nooks=True when a Nooks client is configured.

  2. Failing that, round-robin with require_nooks=False.

  3. Failing that — or when the chosen rep has no hubspotOwnerId — use the fallback owner 86817215, logging decision=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

  1. target.hubspotContactId, when set — a direct PATCH with no search.

  2. Otherwise resolve_hubspot_contact_id_phone_first(), searching by phone first, then email and mobile phone. Multiple matches are logged at WARNING and the first is used; match_step is logged so the resolution path is auditable.

  3. No resolution → skip.

ensure_local_contact_row() then guarantees a local contacts row exists, short-circuiting at the first success:

  1. The row is already loaded — return it.

  2. Look up by hubspotContactId locally, repairing target.contactId / target.hubspotContactId if they point elsewhere.

  3. Fetch the contact from HubSpot and upsert_for_seller() it locally, with source = "hubspot".

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

ai_call_outcome

The effective disposition

hubspot_owner_id

The selected owner

Additionally, when the disposition is Amazon_Seller — this call is the re-engagement signal:

Property Value

call_list_date

Today (US Eastern) — the contact goes on today’s list

call_priority

"4" — highest urgency

call_assigned_to

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

hs_call_title

AI Call with <contact display name>

ai_call_outcome_2

The effective disposition

hubspot_owner_id

Always the fallback owner 86817215

hs_call_disposition

The outcome GUID for (disposition, environment), when one exists

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 (Sales_Handoff or Qualified_Lead)

<Rep>: HOT LEAD - Follow Up ASAP

HIGH

Callback requested

<Rep>: Callback Requested: <time>

MEDIUM

After a successful create:

  • An Activity row of type task is inserted with externalId = hubspotTaskId, the phone, the email, the campaign, and the target — best-effort.

  • contacts.heatStatus is set to hot — 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:SS string 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. Call rows 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, correct call

Connected

left voicemail

Left voicemail

no answer, dial error, operator intercept, answering machine

No answer

busy

Busy

abandon, hangup, hung up

Hung up

incorrect call

Wrong number

not interested

Not interested

no longer selling

No longer selling / shut down

follow up scheduled

Follow up scheduled

warm lead ae follow up

Warm Lead (AE Follow Up)

bridge issue

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 five9_call_sync_watermark re-processes files. Call upserts are idempotent on providerCallId, but HubSpot Call engagements would be created again. Reset only with that in mind.

Disposition vocabularies

Three vocabularies coexist. Confusing them is the most common source of bugs here.

Vocabulary Values Where

AI dispositions

Qualified_Lead, Amazon_Seller, Sales_Handoff, Callback_Requested, Not_Qualified, Not_Interested, Wrong_Number, Do_Not_Call, Not_a_Seller, Hangup_Early, AI_Issue, Busy, AI_Screening, Gatekeeper, Voicemail_Detected, Undetermined

pyb_hubspot/disposition_outcomes.py — Disposition

Next actions

None, Retarget_Later, Sales_Handoff

Same file — NextAction

Five9 dispositions

Free-text CSV strings, case-folded

morpheus_hubspot/utils/constants.py

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.