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 |
|---|---|
|
Dialable once |
|
Set by the |
|
A call has been created with the provider |
|
Terminal — the call achieved its purpose |
|
Terminal — invalid destination, negative disposition, or attempts exhausted |
|
Terminal — manually cancelled |
|
Terminal — pre-validation refused the target (DNC, prior disposition, lifecycle stage, registry block) |
|
Terminal — Do Not Call or wrong number |
|
Wrong number (declared in the enum; the handler currently routes wrong numbers to |
|
Max attempts reached (declared in the enum; the handler currently routes this to |
|
Double-tap pending (declared in the enum; the current double tap runs in Inngest and does not set this state) |
|
Voicemail detected (declared in the enum; the handler currently routes voicemail to a retry) |
|
|
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 |
|
2 — invalid destination |
Disposition is |
3 — recoverable |
Any of: If |
4 — default |
|
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:
-
First call today.
countCallsTodayForTargetPhone(targetPhone, campaignId)must be1. More than one call today to that number in that campaign cancels the double tap. -
Attempts remain.
target.attempts < campaign.maxAttempts. WhenmaxAttemptsis 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.
Call windows
PhoneNumberCallingService.checkCallWindow(campaignId, targetId) is the first step of every dial:
-
Read
campaign.windowStart,campaign.windowEnd,campaign.allowWeekends. -
Read
CampaignTarget.tz, derived from the number’s area code and defaulting toAmerica/New_York. -
Convert the current UTC instant into that timezone using
Intl. -
Return "not in window" when the local time is outside
[windowStart, windowEnd), or it is a weekend andallowWeekendsis 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 |
|---|---|
|
Set by |
|
Earliest instant the target may be leased again. |
|
Incremented on every completed call and on a Retell failure. Sentinel values |
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 ( |
|
Disposition |
|
Any other disposition |
|
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:
-
durationSecis set and>= 10 -
inVoicemailis false -
callScreeningDetectedis false -
dispositionis inHUBSPOT_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.