Overview
Once a candidate is added to a job, Tenzo decides when and how to reach out based on a stack of gates: stage, consent, business hours, cooldown, retry budget, and channel-specific guards (phone line type, ATS sync state). Every outbound attempt has to pass every gate.
This page describes the rules. For the consent rules in isolation, see Candidate Consent. For the broader stage flow, see Candidate Stage Lifecycle.
When Outreach Starts
A candidate becomes eligible for outreach when their tenzo_stage transitions to OUTREACH. Two paths get them there:
- Resume passes —
AWAITING_RESUME_REVIEW → OUTREACH after the resume scorer accepts the candidate against the job’s requirements.
- Recruiter override —
RESUME_REJECTED → OUTREACH when a recruiter manually clicks Progress to Interview. Any scheduled rejection email is cancelled at the same time.
On entry to OUTREACH, a PENDING call row is created (idempotently — if one already exists, nothing happens). The scheduled_datetime on that row is what the call dispatcher polls for.
A candidate added to a job who is already being contacted on another job at the same org never goes to OUTREACH directly — they go to COOLING_DOWN first. See Cooldown below.
Scheduling the First Attempt: Outreach Hours
The first scheduled_datetime is computed from the org’s Outreach Hours settings and the candidate’s timezone.
- Timezone:
candidate.timezone on the candidate row. The scheduling code itself does no inference at outreach time — it reads the field as-is and falls back to US/Eastern if empty. The field is populated at candidate intake in this order: (1) explicit value from the ATS/import, (2) zip-code geocoding, (3) the location point (offline lat/lon lookup, e.g. a city/state centroid when there is no zip), (4) phone area-code lookup. If all four fail at intake, the candidate stays untimezoned and is treated as Eastern at outreach time. A timezone the candidate has confirmed directly (call, SMS, or action token) outranks all derived sources for 90 days; after that the confirmation expires, so location evidence can update the value again and call/SMS flows will re-confirm the timezone with the candidate.
- Weekday window: Configurable per org (default
08:00–21:00 candidate-local).
- Weekend window: Configurable per org.
call_on_weekends is on by default and weekend hours default to the same window as weekdays. If call_on_weekends is off, weekend slots roll forward to Monday.
If the current time falls inside the window, the call is scheduled for now. If not, it rolls forward to the next valid slot.
Initial Outreach Method
Each job has an initial_outreach_method setting that biases the first contact:
| Setting | Behavior |
|---|
OUTREACH_HOURS (default) | First outreach only (is_first_outreach, gated by sms_status == NONE): inside outreach hours → call; outside → SMS + companion email. Lets late-evening adds get a same-day touch. Retries always wait for the next outreach window; SMS is never re-fired outside hours. |
CALL | Always place a call (subject to consent and line-type rules). |
SMS | Send an SMS as the first touch; a companion email is sent alongside. |
Jobs can also set override_outreach_hours_for_initial_outreach=True, which fires the first contact immediately without rolling forward to business hours. This is used by SMS-first and Outreach-Hours flows so an evening sync still gets a same-day touch.
Web-call jobs
For jobs whose Campaign.call_types.applicant_call_type (or sourced_call_type) is CallType.WEB, every PENDING Call row is created with web_call=True. The dispatcher short-circuits the normal phone path at the top of make_initiate_call_request: it calls invite_to_web_call instead, which creates a WebCallLink row and POSTs the internal /send_web_invite_email endpoint. The email template comes from the customer’s Cosmos script_info.webCallEmailBody / webCallEmailSubject and includes a candidate-specific link to the scheduling/start page.
Reminder emails respect outreach hours and the configured VideoInterviewReminderSettings cadence. Link expiration is governed by VideoInterviewLinkExpirationSettings (default: no expiration). When the candidate clicks the link and schedules, the stage transitions to AI_INTERVIEW_SCHEDULED.
Web-call invite outreach does not use the phone voicemail retry budget or Max Attempts Tenzo stages. Those candidates stay in Outreach while invites, reminders, or a scheduled interview slot are in flight. Once the configured invite/reminder budget is exhausted with no response, they move to the All Invites Sent soft-wait stage: no further invites are sent, but the emailed link stays live, so the candidate can still schedule, complete, decline, or opt out. When outreach goes stale in either state, the inactivity disposition sweep moves them to Expired (not Max Attempts). Phone-style retry exhaustion (MAX_ATTEMPTS_NO_INTERVIEW / MAX_ATTEMPTS_NOT_COMPLETED) applies only to outbound phone attempts.
Channel Selection at Send Time
When the dispatcher picks up a PENDING call, it re-evaluates channel right before sending:
PENDING call due
│
├─ Stage blocks outreach? ────────────────────────── CANCEL (see "What Stops Outreach")
├─ Master AI-communications consent off? ─────────── try email-only or cancel
├─ Cooldown on another job? ──────────────────────── move to COOLING_DOWN
│
├─ initial_outreach_method = SMS?
│ └─ phone line is sms-blocked (landline/VoIP/etc.)?
│ ├─ yes → call + email fallback
│ └─ no → send SMS (+ companion email)
│
├─ initial_outreach_method = OUTREACH_HOURS?
│ └─ inside business hours? ─── yes → call
│ outside business hours? ── send SMS (+ email) (line-type gate same as above)
│
└─ initial_outreach_method = CALL
└─ place call (or fall back per consent — see Candidate Consent)
Line-type gate. SMS is blocked when the candidate’s stored phone line type is LANDLINE, PAGER, VOICEMAIL, PREMIUM, SHARED_COST, or FIXED_VOIP. Non-fixed VoIP numbers (e.g. Google Voice) can receive SMS and are allowed, matching call behavior. Tenzo looks the number up against Twilio and caches the result for 30 days. If the lookup is missing or fails, the SMS is allowed through.
Consent gate. Before any channel is used, consent is checked. See Candidate Consent for the full matrix, but the short version:
all_ai_communications_consent=False blocks calls only. SMS is independent of this flag and gated solely by sms_consent. Email is not gated by this flag either — email proceeds based on email_consent alone.
sms_consent=False blocks SMS; the system falls back to email when possible.
call_consent=False blocks calls.
email_consent=False blocks email (no further fallback).
SMS opt-in confirmation (per-script override). When a script has Require Opt-In Confirmation turned on AND the candidate’s sms_consent is False, Tenzo replaces the candidate’s first failed-call SMS with a configurable opt-in prompt (optInApplicantSms or optInSourcedSms) and transitions the SMS conversation to AWAITING_OPT_IN. An LLM classifies the reply as opt-in / opt-out / unclear:
- Opt-in → grants
sms_consent=True, sends postOptInSms, advances to INITIAL_OUTREACH. Normal outreach resumes.
- Opt-out → routes through the same consent-revocation path as a literal
STOP reply: revokes text + AI communications consent (keeps email) and moves tenzo_stage to CONSENT_REVOKED.
- Unclear (first reply) → sends a clarification SMS, advances to
AWAITING_OPT_IN_CLARIFY. A second unclear reply defaults to opt-out.
The opt-in prompt, clarification, and postOptInSms are sent with skip_consent_check=True, which bypasses the sms_consent gate and disables the email fallback — the consent ask must arrive on the SMS channel it’s asking about, not via email. Outside this narrow flow the gates above apply unchanged.
Retries and Cooldowns
Retry Budget
When a call ends without a conversation (voicemail, no-answer, busy), a new PENDING call is scheduled. The behavior comes from the job’s Call Retry Settings:
| Setting | Default | Meaning |
|---|
reschedule_on_voicemail | True | Master switch for voicemail retries and consent-gated retries. Turning it off disables both. |
retry_count | 5 | Maximum retry attempts after the first call. |
retry_hours | 26.0 | Spacing between retries (hours). |
Each rescheduled call is re-bounded to outreach hours. When retry_count is exhausted, the candidate’s stage transitions out of OUTREACH and no further calls are scheduled.
Consent-Gated Retry
If a call is cancelled at dispatch time because consent is missing (and the candidate can’t be reached via email fallback either), Tenzo schedules a consent-gated retry at retry_hours later — up to retry_count + 1 total attempts. This exists so that a consent flip during the retry window (e.g. the ATS sync adds opt-in, or the candidate texts back START) gets picked up.
Failure-Reschedule
A separate, short rescheduler kicks in when the call initiation itself fails (Retell rejects the request, phone-provider error, etc.). The call is bumped 10 minutes forward and re-bounded to outreach hours.
Cooldown Across Jobs
When a candidate is added to a job, Tenzo checks if they’re already in motion on another job at the same org. If so, the new job’s CandidateCampaignInfo enters COOLING_DOWN instead of OUTREACH. They auto-resume once the other outreach concludes, or after 14 days they expire to COOLDOWN_EXPIRED with no contact.
If outreach is manually paused on a candidate or because the whole job is paused, Tenzo moves that candidate to OUTREACH_PAUSED and parks pending outreach calls as PAUSED. On resume, Tenzo re-runs cooldown checks before returning to OUTREACH (or leaving the candidate in COOLING_DOWN if another job is still active).
Cooldown days are configurable per org by candidate source and call type:
| Source | Channel | Default |
|---|
| Sourced | Phone | 1 day |
| Sourced | Web | 1 day |
| Applicant | Phone | 1 day |
| Applicant | Web | 0 (disabled) |
The day count controls only the “called within window” trigger. Active outreach and queued attempts on another job always trigger cooldown, regardless of the day setting. See Cooldown across jobs for the full stage description.
What Stops Outreach
Outreach is halted — pending calls cancelled, no new ones created — when any of these become true for the candidate:
Stage-based stops
If the candidate’s stage moves into any of the following, all non-terminal calls are marked NOT_INTERESTED and no new outreach is scheduled:
NOT_INTERESTED — candidate declined this job; can be re-engaged for other jobs.
CONSENT_REVOKED — candidate revoked consent on the channel(s) they were reachable on (STOP / call / email / ATS sync), leaving no usable channel; blocks outreach on every job that relied on the revoked channel. Recoverable — re-consent, a recruiter override, or the candidate calling in and completing a graded interview re-engages outreach. See Consent Revocation below.
DO_NOT_CONTACT — legacy stage, retained for history; gets no new entrants. Opt-outs now route to CONSENT_REVOKED.
FRAUD_REJECTED
DISPOSITIONED_IN_ATS (the ATS marked them rejected/withdrawn)
USER_CANCELED (recruiter explicitly stopped outreach)
FAILED_KNOCKOUT
HUMAN_REVIEW_REQUESTED
AI_INTERVIEW_SCHEDULED (the candidate has scheduled a web call — typically by following an SMS invite link; phone outreach pauses until the web call lands)
SKIP_OUTREACH (outreach is blocked before contact, either pre-dispatch or at dispatch time — e.g. an ATS-eligibility soft-block or a job-level AI Resume Review Only policy)
COOLING_DOWN
INVALID_CONTACT is functionally a stop too, but it doesn’t sit in the blocking-stages list — it cancels only the call that surfaced the problem and is documented under Invalid contact and other dispatch gates below.
Consent-based stops
all_ai_communications_consent=False → no calls. SMS is unaffected (gated solely by sms_consent), and email is independent too — it still goes through if email_consent=True and an email integration is connected.
has_any_consent(candidate) is the master outreach gate: True when any of sms_consent, email_consent, or all_ai_communications_consent is set. With all three false the candidate has no reachable channel and outreach is cancelled entirely. The stage depends on whether consent was ever present: the first time an application is ingested with no consent on file (it was never revoked) the candidate transitions to NO_CONSENT; a subsequent re-check of an existing application whose channel(s) are now gone (e.g. via outreach_blocking_service) transitions to CONSENT_REVOKED. (call_consent is intentionally excluded from this predicate — unused/unmapped today.)
Retry exhaustion
When retry_count is reached without a successful phone conversation, the candidate exits OUTREACH into a max-attempts stage and no more outbound calls are scheduled. This does not apply to web-call invite jobs: those candidates stay in OUTREACH while invites and reminders are in flight, move to ALL_INVITES_SENT once the invite/reminder budget is exhausted with no response, and from either state are picked up by the inactivity timeout (EXPIRED) sweep — unless they first complete the interview or reach another terminal stage. The emailed link stays live throughout, so a candidate in ALL_INVITES_SENT can still complete, decline, or self-schedule.
Beyond stage and consent, the dispatcher applies several operational gates at the moment a PENDING call comes due. These don’t move the candidate to a “stopped” stage in advance — they only fire when dispatch tries to send.
- Paused job. Every dispatcher query filters out calls whose campaign status is
PAUSED. While a job is paused, no candidate on that job is contacted, regardless of their scheduled time. Candidates in active outreach are moved to OUTREACH_PAUSED while the job is paused; when resumed, they re-enter OUTREACH only if cooldown allows it.
- Missing or invalid phone. Phoneless candidates with email + email-consent get a single outreach email + a consent-gated retry, cancelled with
NO_PHONE_EMAIL_FALLBACK (analytics-distinct from the AI-comms-opt-out branch’s NO_AI_COMMUNICATIONS_CONSENT, but both share the same retry-budget filter). The shared body lives in _try_email_only_outreach; the two outer branches differ on their precondition and their failure-mode resolution. If email isn’t viable for a phoneless candidate (no email, no consent, or get_no_answer_email_task no-op’d), the email-only branch is skipped/falls through — the downstream no-phone gate in make_initiate_call_request then transitions the candidate to INVALID_CONTACT. Web-call jobs (Call.web_call=True) are excluded from this branch — invite_to_web_call handles them with a webCallEmail invite and doesn’t need a phone.
- SMS invalid-number callback. When an SMS provider (Twilio or Telnyx) returns an invalid-number error code on send, the SMS failure policy transitions the candidate to
INVALID_CONTACT. This is a single-strike rule — one invalid-number response from the carrier halts SMS for that candidate.
- SMS error threshold. Independent of invalid-number errors, repeated SMS failures against the same candidate-job pair (currently 10) disable further SMS attempts on that pair. This is a per-pair circuit breaker for transient provider issues.
- From-number resolution. A candidate’s call must be placed from a phone number registered to the org. If no usable from-number is available at dispatch time (no registered numbers, all numbers exhausted), the call fails and is rescheduled via the failure-reschedule path described above.
- ATS org-policy eligibility. For ATS-synced orgs, the dispatcher re-runs the ATS outreach eligibility check just before sending. If the ATS now classifies the candidate as ineligible, the call is cancelled and the candidate transitions directly into a blocking stage. The
OutreachBlockCategory.DO_NOT_CONTACT category now means “no consent on any channel” (set when not has_any_consent) and maps to NO_CONSENT on first ingestion of the application or CONSENT_REVOKED on a later re-check (the dispatcher’s just-before-send check is always a re-check, so it reaches CONSENT_REVOKED); the ORG_OUTREACH_POLICY category (org-level reject/withdraw status, custom blocklists) maps to SKIP_OUTREACH. This is how org policy at the ATS reaches the dispatcher.
- Concurrency cap. The dispatcher processes one or two outbound calls at a time per phone number — two by default, one during peak hours (5–10 AM Pacific). On orgs with a large backlog, a
PENDING call that comes due during a busy minute may slip a few seconds past scheduled_datetime. The limit can be raised for a specific customer when needed, but there is no self-serve setting — it is adjusted by engineering, not in the product.
- Stale-call cleanup. Long-pending calls on ATS-synced candidates are periodically checked against the ATS for fresh status. If the candidate has since been rejected or withdrawn in the ATS, the pending call is cancelled and the candidate moves to
DISPOSITIONED_IN_ATS.
Other Outreach Touches
The rest of this page is about the prospecting outbound path (call/SMS/companion email at the top of the funnel). A handful of other automated touches share the same gates but have their own schedulers, and a reader asking “why didn’t this email go out?” needs to know they exist:
- Rejection email. Scheduled 24 hours after the candidate enters
RESUME_REJECTED, then rounded forward to the next outreach-hours slot in the candidate’s timezone. enable_rejection_emails defaults to off at the org level — most orgs don’t send them unless they’ve opted in. Subject to email consent; if the candidate transitions back to OUTREACH before the email fires, the scheduled send is cancelled. Sent by process_scheduled_rejection_emails.
- Resume feedback email. Parallel flow to the rejection email but enabled by default. Same consent gate, same scheduler pattern (
process_scheduled_resume_feedback_emails).
- Scheduling reminders. Once a candidate has agreed to schedule a call but hasn’t picked a time, the system re-pings them on a configurable cadence (
process_scheduling_reminders). These pings honor outreach hours and per-channel consent the same way prospecting touches do.
- Meeting reminders. Pre-interview SMS reminders (24h and 1h ahead of a scheduled call), sent by
process_scheduled_meeting_reminders. Consent-gated; not subject to outreach hours because the meeting time itself is what’s being honored.
- Video interview reminder emails. Email reminders before a self-scheduled web call, governed by
VideoInterviewReminderSettings (defaults: reminder_email_count=7, reminder_email_hours=12.0). Distinct from the SMS meeting reminders above — these fire for video interviews only and use the email channel.
The cadence settings for the prospecting path (retry count/hours, cooldown days, outreach hours, initial outreach method) are defined at the org level and can be overridden per job. Job-level settings, when present, replace the org default for that job only — there’s no merge.
Inbound Call Routing
Outbound is only half the picture: candidates can dial the phone numbers Tenzo uses to reach them. The Twilio /inbound_call webhook routes incoming calls in this order:
- Invalid/anonymous caller ID → reject.
- Permanent org phone number (DB-mapped to a campaign) → candidate-intake flow (collect name/email at the top of the call).
- Legacy hardcoded mapping (Dollar Tree).
- Resumable incomplete call matched by from/to pair → resume the existing session via the State Snapshot Pattern.
- Known caller with prior call → duplicate the prior call, start a new interview.
- No prior-call phone match → verification flow. Creates a
VerificationCall row and routes to a verification agent. Stage 1 identifies the caller, then it confirms their full name; on both validations the inbound call is linked to the candidate row and transitions into the interview. The stage-1 method is chosen by resolve_verification_method(org_id): when org_has_email_integration(org_id) is true the caller reads back an 8-character code (VerificationMethod.CODE); when it returns false (SMS-only orgs with no email integration) the caller is asked which number they applied with / were texted on, which is matched against the candidate on file (VerificationMethod.PHONE). The method is resolved from the dialed number’s org at routing time — LiveKit carries it in the room metadata, Retell via the dev override / websocket resolution — because the candidate (and campaign) isn’t known yet.
The verification code is computed from candidate_campaign_info.candidate_campaign_id[:8].upper() and surfaced to candidates via the auto-appended footer block in every outreach email rendered by send_virtual_interview_email (the email.footer.verification i18n string). It only applies to the CODE method: SMS-only orgs never send an email and have no code, so they use the PHONE method instead. Either way, candidates with no usable phone-on-file can still convert by dialing in and verifying.
On every successful verification, the verified caller’s number is written back to the candidate row (best-effort, only when the field is empty) so subsequent dispatch attempts pick up the candidate as phone-eligible. This happens on the shared handle_transition_confirmation path, so it applies to both Retell and LiveKit flows.
Consent Revocation
When a candidate opts out, Tenzo revokes consent only on the channel(s) they were reachable on rather than flipping a single global flag. The candidate’s outreach then stops because there is no usable channel left, not because a separate suppression flag is set.
State involved:
- Per-channel consent columns on the candidate row —
sms_consent, email_consent, all_ai_communications_consent. These are the sole outreach-suppression mechanism: has_any_consent(candidate) is True if at least one is set. When all relevant channels are revoked, has_any_consent is False and no job can reach out. (Per-channel consent persistence is now unconditional — the old PER_CHANNEL_CONSENT_DB_SYNC feature flag is removed.)
tenzo_stage = CONSENT_REVOKED on each affected CandidateCampaignInfo — the per-job reflection of the opt-out. Active + recoverable: re-consent on a channel, a recruiter override, or the candidate dialing in and completing a graded interview re-engages outreach (see Inbound re-engagement below).
- Legacy: the
Candidate.do_not_contact / dnc_reason columns and the DO_NOT_CONTACT stage are retained (dark, unread) for history but get no new entrants; the column drop is deferred to a follow-up. do_not_contact survives only as a derived API field. The one writer that still clears it is re-engagement: reviving an opted-out candidate sets do_not_contact = False. Consent itself is no longer re-granted by the re-engagement path — it is owned by the per-channel inbound hard rules (inbound SMS → sms_consent, inbound call → all_ai_communications_consent), which fire on the same inbound event that drives the revive. A DO_NOT_CONTACT revive is therefore never left half-applied: the DNC clear and the channel’s consent grant both land on that inbound.
All opt-out entry points route through revoke_consent_and_transition (in consent/consent_revocation_service.py), which persists the revoked consent columns, writes the revocation back to the ATS (best-effort, per channel), and transitions the candidate-campaign to CONSENT_REVOKED. Two revocation channels:
RevocationChannel.TEXT_AND_AI — revokes sms_consent + all_ai_communications_consent (keeps email). Any SMS opt-out uses this; the old “SMS-only, keep AI” path is collapsed.
RevocationChannel.EMAIL — revokes email_consent only (keeps SMS + AI).
NOT_INTERESTED vs CONSENT_REVOKED:
| NOT_INTERESTED | CONSENT_REVOKED |
|---|
| Scope | This job only | Every job that relied on the revoked channel(s) |
| Source | Candidate declined this role | Candidate opted out of a channel (STOP / call / email / ATS sync) |
| Channels revoked | None | Only the channel(s) the candidate objected to |
| Re-engageable | Yes — recruiter can override, another job can re-contact, or the candidate calls in and completes a graded interview | Yes — re-consent on a channel, a recruiter override, or the candidate calling in and completing a graded interview re-engages outreach |
How candidates end up in CONSENT_REVOKED
-
Inbound SMS, candidate asks to be removed. During an SMS conversation, the LLM classifies the candidate’s intent. An emphatic, global opt-out (“don’t ever text me again”, “remove me from your system”, or clear anger at being contacted) is classified as
REQUESTED_NO_CONTACT. Rather than revoking straight away, transition_sms_state_action first sends a confirmation message asking whether they’d still like to hear about future opportunities, and moves the SMS conversation state to the intermediate AWAITING_DNC_CONFIRMATION state. The final state depends on their reply:
- A “no” to future contact calls
execute_confirm_global_dnc with RevocationChannel.TEXT_AND_AI (source SMS_CLASSIFIED_OPT_OUT), revoking text + AI consent and transitioning to CONSENT_REVOKED. It also moves the SMS conversation state to DO_NOT_CONTACT so the texter stops and a later inbound reaches the re-engagement handler.
- A “yes” calls
execute_confirm_campaign_opt_out, which resolves to NOT_INTERESTED (this job only, consent intact).
- An ambiguous or off-topic reply defaults to the opt-out, erring on the side of respecting their privacy.
If the confirmation message itself fails to send, the state falls back to NOT_INTERESTED, so the candidate is not left stranded waiting on a question they never received. A message that reads as a this-job-only decline is classified NOT_INTERESTED from the start and never enters this confirmation flow.
-
SMS
STOP keyword. A keyword-level STOP from the carrier (Twilio’s built-in unsubscribe handling, or the explicit STOP-detection path) revokes TEXT_AND_AI consent with source SMS_STOP. This is independent of LLM classification.
-
Email reply. An email opt-out revokes
RevocationChannel.EMAIL only (source EMAIL_REPLY), leaving SMS + AI intact.
-
Call-side emphatic opt-out. When a candidate asks not to be contacted during an AI phone screen, Tenzo revokes
TEXT_AND_AI consent with source CALL_CLASSIFIED_OPT_OUT and transitions to CONSENT_REVOKED. Unlike the SMS emphatic path, there is no AWAITING_DNC_CONFIRMATION round-trip — the call path revokes consent immediately with no confirmation step.
-
ATS sync / excluded status. Excluded-status candidates do not revoke consent — they park in
SKIP_OUTREACH per-application. Recovery to OUTREACH requires the blocking condition to clear and a passing full org outreach eligibility re-check (owner, placement, consent, and other policy rules), not just the ATS status change:
- JobDiva — candidates with status “Unavailable Indefinitely” transition their active applications to
SKIP_OUTREACH nightly, and recover via the SKIP_OUTREACH → OUTREACH transition when they become available again.
- Bullhorn — excluded-status candidates likewise park in
SKIP_OUTREACH (generalized to any Bullhorn org). When the excluded status clears on sync, Tenzo re-runs candidate_outreach_eligibility() and only recovers applications that pass; org policy blocks (for example owner or placement rules) leave the application in SKIP_OUTREACH. The Bullhorn-sync reconcile hook separately moves active apps to CONSENT_REVOKED when consent is revoked upstream.
- Outreach eligibility mapping —
OutreachBlockCategory.DO_NOT_CONTACT now means “no consent on any channel” (set when not has_any_consent) and maps to NO_CONSENT on first ingestion of the application or CONSENT_REVOKED on a later re-check (the is_first_ingestion flag threaded from the enrollment/ingestion call sites selects which); OutreachBlockCategory.ORG_OUTREACH_POLICY maps to SKIP_OUTREACH.
Inbound re-engagement from an opt-out stage
A candidate in a terminal opt-out stage (NOT_INTERESTED, CONSENT_REVOKED, or DO_NOT_CONTACT) who calls in and completes a graded interview is automatically re-engaged — they don’t need a recruiter override. This mirrors the SMS opt-back-in flow:
- A call the candidate places themselves (an inbound call, including the inbound verification flow that rolls straight into an interview) is evaluated on its own merits rather than inheriting the candidate’s existing opt-out. A candidate who calls in and declines again is kept opted out.
- When the inbound call produces a real interview outcome (a completed interview, or one that fails a required knockout question), the candidate is re-engaged: the do-not-contact flag is cleared and the per-job stage moves back to
OUTREACH. Consent is re-granted per-channel by the inbound hard rules — an inbound call grants all_ai_communications_consent (via maybe_grant_ai_consent_on_inbound_call); it does not touch sms_consent, so the candidate stays text-suppressed until they text in. Eligibility uses the same stage-transition rule as SMS re-engagement, which already permits all three opt-out stages.
- Re-engagement happens before the interview’s own stage move (to
CALL_COMPLETED or FAILED_KNOCKOUT), so that move starts from the fresh OUTREACH stage. Any follow-up call that re-engaging would otherwise queue is cleaned up by that same stage move, so no stray redial is left behind. Candidates who never call in remain fully suppressed.
Revoking consent does not retroactively rewrite every existing CandidateCampaignInfo to CONSENT_REVOKED. Other jobs reflect the revoked channel when their next dispatch re-runs eligibility (or, for ATS-driven flips, when add-to-campaign next evaluates the candidate). Outreach is suppressed everywhere immediately because every send gate reads the per-channel consent columns directly.