Call campaigns

A call campaign is a Campaign row with type = call. It owns a set of CampaignTarget rows, each one phone number with its own state machine.

The /campaigns list shows call campaigns only. Opening a voice campaign URL for a campaign whose type is email redirects to the email campaign detail page.

Configuration

Field Default Meaning

status

draft

draft, running, paused, stopped, completed

maxConcurrentCalls

10

Targets leased per broadcast tick, and the basis of the in-flight ceiling

maxAttempts

null

Retry ceiling per target. Fallbacks differ by code path — see the note below

waitHoursToRecall

24

Cooling-off hours before a recoverable target is dialable again

windowStart / windowEnd

null

Time-of-day window, evaluated in the target’s timezone

allowWeekends

false

When false, Saturday and Sunday are skipped in the target’s timezone

preferedPhone

null

Preferred outbound number

preferredAiAgentId

null

Preferred analysis agent for this campaign’s calls

schedulerIntervalSeconds

30

How often pg_cron calls the broadcast endpoint

subscriptionId

—

Owning workspace

maxAttempts has three different fallbacks depending on which code reads it:

  • the call-ended webhook handler uses campaign.maxAttempts ?? 10

  • RETRY_CONSTANTS.DEFAULT_MAX_ATTEMPTS is 4

  • the double-tap guard in callEndedAnalysis applies no ceiling when maxAttempts is null

Set maxAttempts explicitly on every campaign rather than relying on a default.

Campaign lifecycle

stateDiagram-v2
  [*] --> draft
  draft --> running
  running --> paused
  paused --> running
  running --> stopped
  running --> completed
  stopped --> [*]
  completed --> [*]

Only a running campaign broadcasts. processBroadcast throws CallInitiationError('CAMPAIGN_NOT_RUNNING') for anything else, so pausing a campaign stops new dials on the next tick without touching leased targets.

The broadcast loop

POST /api/campaigns/{id}/broadcast is an internal-only endpoint gated by verifyInternalSecret(). pg_cron calls it on the campaign’s interval. The route passes a lease of one day (60 × 60 × 24 seconds) because a queued call can wait hours before Retell dials it; the lease is cleared when the call ends.

flowchart TD
  Cron["pg_cron tick"] --> Route["POST /api/campaigns/{id}/broadcast\n(internal secret)"]
  Route --> Svc["PhoneNumberCallingService.processBroadcast(campaignId, 86400)"]
  Svc --> S1{"Campaign exists\nand status = running?"}
  S1 -->|No| Err["Throw CallInitiationError"]
  S1 -->|Yes| S2["Supabase RPC get_and_lease_due_targets\n(batch = maxConcurrentCalls, lease = 86400s)"]
  S2 --> S3{"Any targets?"}
  S3 -->|No| Idle["Return hasAvailableTargets: false"]
  S3 -->|Yes| S4{"Redis in-flight >= maxConcurrentCalls x 2?"}
  S4 -->|Yes| Throttle["Return throttled — retry next tick"]
  S4 -->|No| S5["toEnqueue = min(targets, maxInflight - currentRuns)"]
  S5 --> S6["Per target: check TargetPhoneNumbers registry"]
  S6 --> S7{"Eligible?"}
  S7 -->|No| Skip["state = skipped, clear lease, record lastError"]
  S7 -->|Yes| S8["inngest.send call-target/call.requested\nthen incrementCallTargetRuns"]

Target leasing

get_and_lease_due_targets is a Supabase RPC (a PostgreSQL function). It atomically selects up to p_batch_size due targets and stamps leaseExpiresAt = now() + p_lease_seconds, so two concurrent ticks cannot lease the same row. A crashed worker releases its claim by expiry rather than needing cleanup.

The lease skips a target whose TargetPhoneNumbers row has isBlocked = true. Resolution matches the broadcaster: the targetPhoneNumbersId foreign key when it is set, otherwise the phone number plus the campaign workspace, ignoring deleted registry rows. A target with no registry row stays eligible. Deleted and high-risk rows are still refused after the lease.

In-flight throttling

A call is in-flight from the moment the Inngest event is sent until the provider webhook lands, which the database cannot see cheaply. A Redis counter bridges that window:

  • Ceiling is maxConcurrentCalls × 2. The doubled headroom accommodates a target that has a double tap queued alongside its original run.

  • At or above the ceiling the tick returns throttled and enqueues nothing.

  • Below it, only maxInflight - currentRuns events are enqueued even if more targets were leased. Surplus leased targets simply wait for their lease to expire.

  • incrementCallTargetRuns runs after each successful inngest.send; callTarget decrements on every exit path.

Registry gate at lease time

Before enqueuing, the broadcaster resolves the target’s TargetPhoneNumbers row (by targetPhoneNumbersId, else by number within the campaign’s workspace) and refuses to dial when:

Condition lastError recorded

Registry row belongs to another workspace

Target phone registry subscription mismatch

isBlocked = true

Target phone number is blocked

isDeleted = true

Target phone number registry row is deleted

riskLevel is not low or null

Target phone risk level (<level>) must be below medium

In all four cases the target moves to skipped and its lease is cleared. A target with no registry row at all is allowed through — the registry is an enrichment layer, not a prerequisite.

The dial pipeline

The callTarget Inngest function (retries: 0, concurrency: { limit: 5 }, rateLimit: 2 per targetPhoneNumber per 24h) performs three steps. retries: 0 is deliberate: a dial attempt is a discrete business event, and retrying it automatically would double-dial a prospect.

  1. check-call-window — checkCallWindow(campaignId, targetId). Outside the window or on a disallowed weekend, exit with skipReason: outside_call_window. The lease is not cleared, so the target is simply re-picked after expiry.

  2. pre-validate — preValidateTargetOnly(…​), see below.

  3. initiate-call — initiateCall(…​): select a source number, pick a caller agent, create the Retell call, then upsert the Call row and set the target to dialing in one transaction.

Every exit path calls a finish() helper that fire-and-forgets decrementCallTargetRuns(campaignId).

Pre-validation guards

Evaluated in order; the first match wins.

Guard Check Result

Target exists

findCampaignTargetById

target_not_found

DNC

Any target with this phone number is on the DNC list

state = skipped, reason dnc

Prior terminal disposition

A previous call to this number had a disposition in SKIP_CALL_DISPOSITIONS (Do_Not_Call, Not_a_Seller, Amazon_Seller)

state = skipped, attempts = 9999, reason prior_disposition

Contact lifecycle

The linked contact’s stage is in SKIP_CALL_LIFECYCLE_STAGES (customer, marketingqualifiedlead)

state = skipped, attempts = 9999, reason lifecycle_stage

E.164 format

/^\+[1-9]\d{1,14}$/

reason invalid_e164

Setting attempts = 9999 is a belt-and-braces measure: even if the state were later reset, the attempt ceiling would keep the target out of the dial pool.

Amazon_Seller appears in SKIP_CALL_DISPOSITIONS because a confirmed Amazon seller has already been qualified and is handed to sales — re-dialing them is a negative experience, not an opportunity.

Call initiation

  1. Select a source number via PhoneNumberSelector (see Phone numbers). If none is eligible, a purchase is requested asynchronously and the attempt exits with no_phone_number_available — the next broadcast retries.

  2. Pick a caller agent: a random active caller-type AiAgent for the workspace, falling back to the static RETELL_AGENT_LIST in scripts/retelai/agents/registry.ts.

  3. Build metadata. targetPayload values are stringified and null-coalesced to "", because a Retell agent will read a missing variable’s name aloud.

  4. Create the Retell call with campaignId, campaignName, campaignTargetId, agentId and agentVersionId in both metadata and retell_llm_dynamic_variables. The webhook handler relies on metadata.campaignId and metadata.campaignTargetId to correlate the call.

  5. On a Retell error: set the target to failed, increment attempts, record lastError, and return skipped with retell_error rather than throwing — a provider outage should not fill Sentry with unhandled exceptions.

  6. On success: upsertCallAndSetTargetDialing writes the Call row (status dialing, providerCallId, aiAgentId, aiAgentName, agentVersionId, phoneNumberId) and moves the target to dialing in a single transaction.

The call_started webhook handler exists (lib/events/handlers/call-started.ts) but is not registered — the registration line is commented out in lib/events/event-handler.ts. The dialing state and the Call row are therefore created by initiateCall, not by a webhook, and the attempt counter is incremented by the call_analyzed handler when the call ends.

Coverage

GET /api/campaigns/{id}/coverage reports how well the workspace’s outbound numbers cover the campaign’s target geography — which states have targets but no local source number. POST /api/campaigns/{id}/coverage/recommendations asks an LLM (lib/ai/services/coverage-recommendation-service.ts) which area codes to buy next. See Coverage endpoints.