Daily call lists

A contact appears on a rep’s list for a day when three things are true at once:

  1. It is assigned to them — hubspot_owner_id on the contact matches the rep.

  2. Today’s date is stamped on it — call_list_date equals today. This is what makes a list "today’s" and what makes it drop off tomorrow.

  3. It has a priority — call_priority 1–4, where 4 is most urgent. Reps work top-down.

Nobody adds contacts by hand. Automation watches for signals that a contact is worth calling and stamps those three properties.

This page describes the mechanism. For the rules deciding which signals qualify, see Re-engagement segments.

The three ways a contact lands on a list

Written for the sales team; the same rules in engineering detail are in Re-engagement segments.

1. They engaged on a phone call (Amazon seller follow-up)

When the AI dialer reaches an Amazon seller, that contact is queued for a human follow-up. Urgency depends on how recently the AI spoke to them:

  • AI talked to them in the last 3 days → priority 4

  • AI talked to them longer ago, or we are not sure → priority 3

  • The last contact was not from the AI → priority 1

These are round-robined across the team, so they go to whichever rep is next in line.

A contact who clicks a marketing email link and does not already have an open deal is added back to their existing owner’s list. Freshness drives priority:

  • Clicked today or yesterday → priority 4

  • Clicked in the last 2–6 days → priority 3

  • Clicked a week or more ago → priority 2

Because they already have an owner, these stay with that person.

3. They created a Payability account but stalled

A supplier who signed up — a deal in the "appointment scheduled" stage created recently — but has not moved forward gets re-queued. Priority is a score blending several things:

  • Never been called? → top priority

  • How the last call went — connected, or left a live message, scores higher than a dead end like a wrong number

  • How long ago the last call was — the longer it has been, the higher it nudges

  • How many times we have already called — more prior attempts pushes it down, so we do not over-dial the same person

The score is rounded to a 1–4 priority. These also stay with their existing owner.

Two further segments feed the same lists: contacts who booked a meeting and did not attend and have since gone quiet, and contacts on lost or stalled deals that were not hard-disqualified.

A few things worth knowing

  • Lists clear daily. A contact you do not reach today may reappear tomorrow if it still qualifies — or drop off if the situation changed, for example because they booked a deal.

  • We avoid double-dialing. A person called recently is held back for a couple of days before being re-added.

  • Open deals are excluded. Once a contact has an active deal, they stop appearing on re-engagement lists.

  • Priority is your running order. Start at 4 and work down. A 4 means something happened recently that makes a call timely.

Questions about why a specific contact showed up, or did not? The outreach_list_reason property on the contact records which automation enrolled them, and the segment rules are in Re-engagement segments.

Suppression rules

Suppression always beats enrolment. A contact is held back when any of these apply:

Rule Where enforced Applies to

Has an open or existing deal

BigQuery view WHERE clause

Email click (property_deal_id IS NULL); supplier and no-show restrict by deal stage instead

Ever had a call with a blocked disposition — wrong number, do not call, not qualified, not a seller

BigQuery blocked_emails / bad_disposition_calls CTE

Email click, no-show, lost deal, supplier

Closed-lost for a hard-disqualifying reason

BigQuery view WHERE clause

Lost deal

Unsubscribed

unsubscribedat IS NULL

Email click

Already called today (US Eastern, any owner)

Python, in the handler

Supplier, no-show, lost deal

Called by a human owner in the last 2 days

Python, in the handler

Email click

Any contact activity in the last 30 days

BigQuery view WHERE clause

No-show — this segment specifically wants cold contacts

No resolvable HubSpot contact id

Python (counted as skipped_no_hs_id) or an INNER JOIN in SQL

All segments

An AI-owned call never suppresses a human follow-up. That distinction — comparing the engagement owner against the AI fallback owner id 86817215 — is what lets a contact the AI just spoke to become a priority 4 human call rather than being treated as "recently contacted".

List materialisation

Stamping properties is only half the job; the rep needs a list to open. That is Hubspot.SalesRepCallListRefresh (lambdas/morpheus_hubspot/events/scheduled/sales_rep_call_list_refresh.py).

For each active sales rep with a HubSpot owner id:

  1. Delete the rep’s existing call-segment list, if sales_reps.hubspotCallSegmentId is set.

  2. Create a fresh dynamic HubSpot Contacts list filtered to hubspot_owner_id = <rep> AND call_list_date = <today>.

  3. Write back the new list id to sales_reps.hubspotCallSegmentId.

The list is named from CALL_LIST_NAME_TEMPLATE: Outreach-SmartList-{rep_name}-{MM-DD-YYYY}. The HubSpot object type id for Contacts is 0-1.

Each rep is processed best-effort — one rep’s failure is logged and does not stop the batch. Rep fields are snapshotted out of the session before the HubSpot calls, so a database connection is not held open across CRM latency.

Deleting and recreating the list means the list id changes daily. Anything holding a saved list id — a Five9 or Nooks import, a saved view, a bookmark — must read sales_reps.hubspotCallSegmentId rather than caching the id.

The rule is ScheduleV2 with ScheduleExpressionTimezone: America/New_York, intended for 6 AM ET daily. The schedule expression is currently rate(6 hours); the cron(0 6 * * ? *) line is commented out. See EventBridge schedules.

"Today" in US Eastern

Handlers derive today’s date by adding a fixed -4 hour offset to UTC and taking the date, rather than using a timezone database:

_US_EASTERN_UTC_OFFSET = timedelta(hours=-4)
today_et = (datetime.now(timezone.utc) + _US_EASTERN_UTC_OFFSET).strftime("%Y-%m-%d")

This is exact during EDT. During EST (UTC−5) it is off by one hour, so a handler running between 04:00 and 05:00 UTC can compute tomorrow’s date. The list refresh runs at 6 AM ET, an hour at which both offsets agree on the calendar date, so the materialised list is unaffected; the re-engagement handlers run throughout the day and are the ones exposed to the edge.

The BigQuery views do this properly, using CURRENT_DATE('America/New_York') and DATE(ts, 'America/New_York').

Ordering within a priority

call_priority is the only ordering signal the rep sees. Within a priority band the segments order their own candidate rows before the cap (ORDER BY priority_score DESC, last_call_date ASC, and LIMIT 5000 or 2000), so ordering only matters for deciding which rows get enrolled at all when a segment exceeds its cap.

Diagnosing a missing contact

  1. Read outreach_list_reason and call_list_date on the HubSpot contact. Stale date means no segment enrolled them today.

  2. Check whether they now have a deal — that excludes most segments.

  3. Check for a blocked disposition on any of their calls.

  4. Check whether they were called today, or in the last 2 days by a human.

  5. Confirm the contact has a hubspotContactId in the local database — the email click and campaign segments resolve identity through it and drop rows without it.

  6. Confirm their owner is an active sales_reps row; a contact owned by an inactive rep is reassigned, so it may be on someone else’s list.

  7. Confirm the contact has a phone value. The list filter requires phone IS_KNOWN, and no segment checks it before enrolling — a contact with only mobilephone gets call_list_date written and still appears on no list.

  8. Check the handler’s completion line in CloudWatch: … completed updated=N assigned=N skipped_no_hs_id=N total=N.

  9. If that completion line is absent, the run was killed by the Lambda timeout part-way through. Look for Task timed out in the function’s log group. A truncated run always drops the same rows — whichever ones the segment’s ORDER BY puts last — so a contact can be missing every day while the job reports no errors.