Target lifecycle

A CampaignTarget is one phone number in one call campaign. Its state, attempts, nextEarliestAttemptAt and leaseExpiresAt together decide whether it is dialable right now.

States

State Meaning

pending

Dialable once nextEarliestAttemptAt has passed

in_progress

Set by the call_analyzed handler when a retry has been scheduled

dialing

A call has been created with the provider

completed

Terminal — the call achieved its purpose

failed

Terminal — invalid destination, negative disposition, or attempts exhausted

canceled

Terminal — manually cancelled

skipped

Terminal — pre-validation refused the target (DNC, prior disposition, lifecycle stage, registry block)

DNC

Terminal — Do Not Call or wrong number

WN

Wrong number (declared in the enum; the handler currently routes wrong numbers to DNC)

MAX

Max attempts reached (declared in the enum; the handler currently routes this to failed)

DT_PENDING

Double-tap pending (declared in the enum; the current double tap runs in Inngest and does not set this state)

VOICEMAIL

Voicemail detected (declared in the enum; the handler currently routes voicemail to a retry)

TargetState carries more values than the current handler produces. WN, MAX, DT_PENDING and VOICEMAIL are legacy values from an earlier state machine and still appear on historical rows. Read them, but do not write them from new code without also updating the handler.

Transitions

stateDiagram-v2
  [*] --> pending
  pending --> dialing : initiateCall
  pending --> skipped : pre-validation refused
  dialing --> DNC : DNC or wrong number
  dialing --> failed : invalid destination / negative disposition / attempts exhausted
  dialing --> in_progress : recoverable outcome, attempts remain
  in_progress --> dialing : cooling-off elapsed, re-leased
  dialing --> completed : anything else
  dialing --> failed : Retell rejected the call
  completed --> [*]
  failed --> [*]
  DNC --> [*]
  skipped --> [*]

The decision is made in the call_analyzed handler (lib/events/handlers/call-ended.ts) in strict priority order:

Priority Rule

1 — stop calling

stopCalling is true (DNC via nextAction = Do_Not_Call / disposition Do_Not_Call / Remove_From_List, or wrong number via disposition Wrong_Number / Not_a_Seller). → state = DNC, attempts += 999999 so no path can revive it.

2 — invalid destination

Disposition is invalid_destination. → state = failed, attempts += 999999, lastError = "Invalid destination".

3 — recoverable

Any of: inVoicemail, callScreeningDetected, technical failure (dial_no_answer, dial_busy, dial_failed, inactivity, machine_detected), busy without a callback request, Hangup_Early, duration under 10 s, or a disposition in NEGATIVE_DISPOSITIONS_TO_RECALL (Gatekeeper, IVR, dial_no_answer, dial_busy, AI_Screening).

If attempts < (maxAttempts ?? 10): state = in_progress, nextEarliestAttemptAt = now + waitHoursToRecall. Otherwise the state falls through to completed.

4 — default

state = completed.

Every branch also increments attempts, stamps lastAttemptAt, clears leaseExpiresAt, and caches the call’s business flags onto the target (disposition, isAmazonSeller, meetsMonthlyThreshold, stopCalling, nextAction, lastCallRecordingUrl, validConversation = durationSec >= 10).

Retry strategy

Two mechanisms operate at different timescales.

Cooling off (database-driven)

The webhook handler sets nextEarliestAttemptAt = now + waitHoursToRecall (24 h by default, stored in UTC) for every recoverable outcome. The broadcast loop re-leases the target once that time has passed.

This deliberately uses the full cooling-off window rather than a short one, so the broadcast loop cannot race the Inngest double tap and dispatch a second parallel attempt. If Inngest fails entirely, the cooling-off window is the fallback that eventually retries the target.

Double tap (Inngest-driven)

The callEndedAnalysis function reacts to call-target/call.ended and performs an immediate same-day retry when the technical disconnection reason is in DOUBLE_TAP_REASONS:

DOUBLE_TAP_REASONS = [
  "voicemail_reached",
  "machine_detected",
  "dial_no_answer",
  "user_declined",
]

Two guards must both pass:

  1. First call today. countCallsTodayForTargetPhone(targetPhone, campaignId) must be 1. More than one call today to that number in that campaign cancels the double tap.

  2. Attempts remain. target.attempts < campaign.maxAttempts. When maxAttempts is null this guard does not apply.

If both pass: step.sleep('double-tap-wait', '1 minute') → incrementCallTargetRuns(campaignId) → re-send call-target/call.requested → return { doubleTapScheduled: true }. AI analysis does not run on this path — the outcome will be analysed after the retry.

If either guard fails, execution falls through to the analysis steps rather than returning early.

Hard ceiling

callTarget declares an Inngest rateLimit of limit: 2 per event.data.targetPhoneNumber per 24h. Regardless of campaign configuration, broadcaster behaviour or double taps, a single phone number cannot be dialled more than twice in any 24-hour window. Excess events are dropped, not queued.

Summary

Mechanism Delay Bounded by

Double tap

1 minute

first-call-today guard, maxAttempts, Inngest rate limit

Cooling off

waitHoursToRecall (24 h)

maxAttempts

Rate limit

—

2 dials per number per 24 h, absolute

Call windows

PhoneNumberCallingService.checkCallWindow(campaignId, targetId) is the first step of every dial:

  1. Read campaign.windowStart, campaign.windowEnd, campaign.allowWeekends.

  2. Read CampaignTarget.tz, derived from the number’s area code and defaulting to America/New_York.

  3. Convert the current UTC instant into that timezone using Intl.

  4. Return "not in window" when the local time is outside [windowStart, windowEnd), or it is a weekend and allowWeekends is false.

On failure the function exits without dialing and without clearing the lease, so the target is re-picked naturally once the lease expires.

Area-code to timezone and state mappings live in lib/utils/area-code-timezones.ts and lib/utils/state-area-codes.ts.

Leasing

Field Role

leaseExpiresAt

Set by get_and_lease_due_targets to now + leaseSeconds (86 400 s from the broadcast route). Cleared when the call ends or the target is skipped.

nextEarliestAttemptAt

Earliest instant the target may be leased again. null means "immediately eligible" for a pending target and "never" for a terminal one.

attempts

Incremented on every completed call and on a Retell failure. Sentinel values 9999 and +999999 are used to permanently disqualify a target.

Post-call contact updates

Step 2 of callEndedAnalysis propagates the outcome onto the contact graph, resolving the contact through the phone registry (TargetPhoneNumbers.contactId):

Outcome Contact changes

DNC (newState = "DNC")

leadStatus = connected, lifecycleStage = other, stopCalling = true, nextAction = "Do_Not_Call"; the TargetPhoneNumbers row is set isBlocked = true

Disposition Amazon_Seller

leadStatus = connected, heatStatus = hot, lifecycleStage = marketingqualifiedlead

Any other disposition

leadStatus = attempted_to_contact, heatStatus = idle, lifecycleStage = opportunity

Blocking the registry row on DNC is what makes a Do Not Call request stick across all future campaigns, not just the current one.

HubSpot eligibility

A call is eligible for CRM hand-off when all of the following hold:

  • durationSec is set and >= 10

  • inVoicemail is false

  • callScreeningDetected is false

  • disposition is in HUBSPOT_WHITE_LISTED_DISPOSITIONS: Qualified_Lead, Amazon_Seller, Callback_Requested, Wrong_Number, Do_Not_Call, Not_a_Seller, AI_Issue

  • the target is not a test target (CampaignTarget.isTestTarget)

HUBSPOT_BLOCKED_DISPOSITIONS records the inverse set for reference: AI_Screening, Voicemail_Detected, dial_busy, dial_no_answer, invalid_destination, Gatekeeper, IVR.

Note that negative outcomes such as Do_Not_Call and Wrong_Number are whitelisted — the CRM needs to know about them in order to suppress future outreach.

When eligible, the handler publishes Crm.DialerCallEnded to the CRM SQS queue with the workspace id as the message group, rather than calling the HubSpot API inline. The direct HubSpot sync call remains in the source, commented out. See CRM queue and HubSpot events.