Skip to content

Oktivo Platform Campaign & Call Flow Architecture ​

Production architecture for high-throughput AI voice calling: thousands of calls/minute, distributed orchestration, real-time credit billing, and campaign lifecycle management.


Platform Overview ​

Oktivo is a white-label AI voice calling platform with a 3-tier hierarchy:

SuperAdmin (platform owner)
  └── Organization (white-label tenant, owns credit pool + concurrent call limits)
        └── Customer (end user who runs campaigns, has own credit balance)

A customer uploads a CSV of contacts, creates a campaign, and starts it. The system calls each contact via SIP, connects them to an AI agent, records the conversation, analyzes it, deducts credits, and stores everything on the Contact document.


High-Level Production Architecture ​


How It Handles Thousands of Calls/Minute ​

Distributed Orchestration via Redis + BullMQ ​

No single backend instance owns a campaign. All state lives in Redis, and BullMQ distributes work across instances.

Key design decisions:

  • Each campaign gets a repeatable BullMQ job that fires every 2 seconds
  • fillCallSlots() acquires a distributed lock (5s TTL) so only one instance fills slots per campaign at a time
  • Worker concurrency = 5 jobs in parallel, rate limited to 50 jobs/second
  • Active calls tracked in Redis hashes (not in-memory Maps) survives instance restarts

Concurrent Call Rate Limiting (3 levels) ​

Resolution priority for maxConcurrent:

  1. campaign.maxConcurrentCalls (set per campaign, 1-500)
  2. org.plan.maxConcurrentCalls (set by SuperAdmin when creating org)
  3. Platform default: 10

Example: Org has maxConcurrentCalls=50, customer runs 3 campaigns each with maxConcurrent=20. The org-level cap ensures total across all 3 campaigns never exceeds 50.


Complete Campaign Lifecycle ​

Step 1: CSV Upload → Campaign + Contacts Created ​

CSV limits:

  • Max file size: 30MB (enforced on both frontend and backend via multer)
  • Deduplication by phone number within the CSV
  • Invalid phone numbers are skipped during parsing

Step 2: Campaign Start (with Duplicate Protection) ​

Restart protection: When a completed campaign is started again, the backend returns a 409 requiring explicit confirmation. On confirm, all contacts are reset to pending with call data cleared. This prevents accidental duplicate billing.

Phone number resolution: The Campaign model does NOT store customerUID. Instead, campaign.userId (the customer's userUID) is passed to telephonyService.resolveCallConfig() as the customerUID parameter. This resolves the phone number assigned to that customer within the org. The same userId is used as customerUID fallback when dispatching individual calls in fillCallSlots().

Step 3: Call Dispatch Loop (every 2 seconds) ​

Step 4: Single Call From Dispatch to Completion ​

Step 5: Credit Deduction Flow ​

Example: 3-minute call, customerRate=3 cr/min, platformRate=1 cr/min

  • Customer charged: 9 credits
  • Org charged: 3 credits (from org pool)
  • Org profit retained: 6 credits

Step 6: Campaign Completion + Retry Mechanism ​

Contact Status Lifecycle ​

Campaign Stop Flow ​

Campaign Restart Flow (with Stopped Contacts) ​

When restarting a stopped campaign, the backend returns a 409 with callStatusBreakdown that includes a "stopped" entry for contacts that were stopped by the user. The frontend shows these as a selectable option in the restart dialog.

Restart filter handling:

  • "stopped" is a contact status (not a callStatus), so it's filtered by status: "stopped"
  • Other options (ended, rejected, etc.) are filtered by callStatus: { $in: [...] }
  • When both are selected, an $or query combines them

Real-Time Frontend Updates ​

The SSE endpoint streams both campaign status (isRunning, counts) and contact updates every 3 seconds. The campaigns list page additionally polls the campaigns API every 5 seconds while any campaign is running, so the user sees real-time progress without manual refresh.


Contact Single Source of Truth ​

The Contact document holds ALL call data. No need to query separate collections.

GroupFieldsSet By
Identityname, phone, contactUID, campaignUID, AgentID, userId, customerUID, orgUIDCSV upload (customerUID = userId)
FlagsisTestCallTest call creation (true) / CSV upload (false, default)
Statusstatus, callStatus, attempts, lastErrorWebhook + calling.service
TimingparticipantJoinedAt, participantLeftAt, callAnsweredAtWebhook + /start-recording
LiveKitroomId, sipCallId, sipTrunkId, disconnectReason, livekitDataWebhook
Recordings3RecordingKey, egressId, recordingUrl, billingDuration, callDuration, AnsDurationWebhook + Egress
Transcriptiontranscription (full conversation)Agent → POST /transcription-sync
Analysisanalysis (summary, sentiment, interests)Analysis → POST /analysis-sync

Call Status Mapping ​

SIP Status → callStatus ​

sip.callStatusdisconnectReason→ callStatus
activeROOM_DELETEDended_by_agent
activeMEDIA_FAILUREmedia_failure
activeotherended
hangupCLIENT_INITIATEDended_by_user
ringingUSER_REJECTED / USER_UNAVAILABLErejected
ringingCONNECTION_TIMEOUTnot picked

callStatus → Contact.status ​

callStatus→ Contact.status
ended, ended_by_agent, ended_by_user, active, hangup, completeddone
failed, rejected, not_answered, not picked, media_failure, unknown, dialing, ringingfailed
force_stoppedstopped (set by force-stop or drain timeout, not by webhook)
null (no call made)stopped (set when campaign is stopped before contact was dispatched)

Contact Status Values ​

StatusMeaningSet By
pendingWaiting to be calledCSV upload, campaign restart
in_progressCurrently being calledfillCallSlots → startSingleCall
doneCall completed successfullyWebhook (handleCallStatusUpdate)
failedCall failed (rejected, no answer, media failure, etc.)Webhook or stale call timeout
skippedInvalid phone numberfillCallSlots validation
stoppedCampaign was stopped before/during callstopCallingProcess / finalizeCampaignStop / force-stop

Recording Pipeline ​

Recording starts only after the phone user picks up (not during ringing):

Timeline:
  T+0.0s  SIP participant joins room → participant_joined fires
  T+0.0s  Phone starts ringing (sip.callStatus = "dialing")
  T+7.4s  User picks up → create_sip_participant() returns
  T+7.4s  Agent calls POST /start-recording → egress starts 🎙️
  T+7.4s  callAnsweredAt stored on Contact
  T+30.0s User hangs up → participant_left fires → egress stopped ⏹️
  T+30.0s billingDuration = ceil(30.0 - 7.4) = 23s

S3 Folder Structure ​

s3://oktivo-recordings/
  └── {orgUID}/
        └── {customerUID}/
              └── {campaignUID}/
                    └── {contactUID}/
                          └── {phone}_{YYYYMMDD_HHmmss}.mp4

Duration Computation (computeBillingDuration()) ​

1. Call must have been answered (sipCallStatus = "active" or "hangup")
2. startTime = callAnsweredAt ?? participantJoinedAt
3. durationSec = ceil((participantLeftAt - startTime) / 1000)
4. Validate: > 0 and ≤ 14400 (4 hours)
5. Write billingDuration = callDuration = AnsDuration atomically

Safety Nets & Failure Recovery ​

Stale Call Cleanup ​

Every 2-second management loop checks for calls active longer than 5 minutes. These are removed from Redis and the contact is marked failed (retryable).

Campaign Drain Timeout ​

When a campaign is stopped, active calls are allowed to finish naturally (draining). If draining exceeds 2 minutes, the system force-finalizes: remaining in-progress contacts are marked as stopped with callStatus: "force_stopped", Redis state is cleaned up, and the campaign is fully stopped.

Force-Stop (User-Initiated) ​

If a user clicks Stop a second time while the campaign is draining, a force-stop is triggered:

  • All in-progress contacts are immediately marked as stopped (callStatus: "force_stopped")
  • Redis active call tracking is cleaned up
  • Campaign is finalized immediately without waiting for webhooks

Stale stopRequested Detection ​

When a campaign is restarted, there's a race condition where finalizeCampaignStop from the old run might set stopRequested: true after the restart clears it. fillCallSlots detects this by checking if the Redis state has no stopRequestedAt (freshly initialized) if so, it clears the stale stopRequested and continues dispatching.

room_finished Safety Net ​

When a LiveKit room closes, the handler checks for:

  • Stuck in_progress contacts → marks as failed or ended
  • Missing recordingUrl → attempts S3 presigned URL generation
  • Missing participantLeftAt → sets it to now, computes billing duration

completeCampaign() Safety Net ​

Before marking a campaign as done, any remaining in_progress contacts are force-marked as failed. This prevents contacts from being permanently stuck.

finalizeCampaignStop() Completion Check ​

Before marking a campaign as stopped, checks if all contacts are actually processed (done/failed/skipped with no stopped contacts). If so, treats it as a natural completion instead of a stop. This prevents campaigns from being incorrectly marked as "stopped" when all calls actually completed during the drain period.

Billing Duration Race Condition ​

On short calls, participant_left can fire before POST /start-recording commits callAnsweredAt. The handler re-fetches the Contact right before computing duration to get the latest data.

Credit Deduction Idempotency ​

Each credit deduction uses serviceRef = call_{contactUID}_{roomName} as an idempotency key. If a webhook fires twice (retry), the second deduction is skipped. The consumeCredits() function checks for existing transactions with the same serviceRef before processing.

Call History Archive Deduplication ​

When restarting a campaign, call data is archived to callHistory before resetting contacts. The archive function checks if the last callHistory entry already matches the current data (same callStatus, s3RecordingKey, callStartedAt) to prevent duplicate entries from repeated restarts.

Server Restart Recovery ​

On startup, recoverCampaignStates() scans for campaigns with isRunning=true in MongoDB:

  • If Redis state exists and is running → skip (another instance is handling it)
  • If no pending/in_progress contacts → mark campaign as complete
  • Otherwise → reset stale in_progress contacts to pending and restart the campaign

Campaign Restart (Duplicate Protection) ​

This prevents accidental duplicate billing when a user clicks Start on a campaign that already ran. The confirmation dialog shows how many contacts were done/failed so the user understands the impact.


Test Calls ​

Test calls use the same webhook pipeline as campaign calls. When a customer triggers a test call:

  1. A real Contact document is created with isTestCall: true, status: "in_progress", and roomId set
  2. campaignUID is set to test_{agentId}_{timestamp} (won't collide with real campaigns)
  3. customerUID is set to req.userId (same as campaign flow)
  4. Phone number is resolved via telephonyService.resolveCallConfig(orgUID, userId) identical to campaign start
  5. Call is dispatched to LiveKit via createDispatch() with the same metadata shape

Because the Contact document exists with roomId, the entire webhook pipeline works identically:

  • participant_joined → stores sipCallId, participantJoinedAt
  • POST /start-recording → starts egress, stores callAnsweredAt
  • participant_left → computes billingDuration, deducts credits, updates status
  • egress_ended → stores recordingUrl
  • POST /transcription-sync → stores transcription
  • POST /analysis-sync → stores analysis

Credits ARE deducted for test calls (same as campaign calls). A legacy TestCall record is also created for backward compatibility.

Update (April 2026): Test calls are now free the webhook controller skips credit deduction when contact.isTestCall === true. Test calls still go through the full pipeline (recording, transcription, analysis) but no credits are consumed.


Webhook Endpoints ​

EndpointCalled ByPurpose
POST /livekit-webhookLiveKit Serverparticipant_joined, participant_left, egress_ended, room_finished
POST /start-recordingPython AgentStart egress after call answered, store callAnsweredAt
POST /transcription-syncPython AgentStore conversation transcription on Contact
POST /analysis-syncAnalysis ServiceStore sentiment/interest analysis on Contact
POST /update-call-statusLegacyBackward-compatible call status update

Customer-Facing API Endpoints ​

EndpointAuthPurpose
GET /contactsJWTList contacts with full filter support: date range, campaign, agent, search, direction, duration range, bookmarked, and dynamic analysis.* filters. See "Contact List Filters" section below.
GET /recording/:contactUIDQuery tokenStream/redirect recording audio for <audio> elements. Uses presigned S3 URL.
GET /campaignsJWTList campaigns with stats (includes stopped count). Returns scheduledStartTime, scheduledEndTime, scheduled status.
POST /upload-campaignJWTUpload CSV + create campaign. Accepts SelectedAgentId, scheduledStartTime.
POST /start-campaign/:uidJWTStart campaign. Returns 409 with requiresConfirmation + callStatusBreakdown (includes stopped count) for completed/stopped campaigns.
POST /stop-campaign/:uidJWTStop campaign. Accepts { forceStop: true } to immediately terminate all active calls.
GET /dashboard-statsJWTDashboard analytics. Supports range=custom&from=&to= for date filtering.

Key Files ​

Backend (client_backend/src/) ​

FilePurpose
services/calling.service.tsCampaign orchestration BullMQ worker, fillCallSlots, startSingleCall, retry logic, Redis state management
services/credit.service.ts3-tier credit system consumeCredits (atomic), checkBalance, distributeToCustomer
controllers/webhook.controller.tsLiveKit webhooks, recording start, transcription/analysis sync, credit deduction on call end
controllers/campaign.controller.tsCampaign CRUD, start/stop with duplicate protection, SSE real-time updates, CSV upload with 30MB limit
controllers/agent.controller.tsAgent CRUD, test call dispatch with Contact creation (isTestCall: true)
services/livekit.service.tsLiveKit SDK createDispatch, DispatchMetadata interface, egress, webhook verification
services/telephony.service.tsResolves SIP trunk + phone number for outbound calls (via PhoneNumberAssignment)
services/s3.service.tsS3 recording key builder, presigned URLs, existence checks
utils/dispatch.utils.tsDRY dispatch helpers buildAgentConfig, mergeDynamicValues, fetchDynamicTools, fetchMcpServers, extractIvrActions, extractVoiceBehaviorFields
utils/duration.utils.tscomputeBillingDuration() pure function, provider-agnostic
utils/csv.utils.tsparseCSVBuffer() CSV parsing, deduplication, sets customerUID = userId on contacts
repositories/campaign.repository.tsCampaign DB queries, getCampaignStats (explicit status counts)
config/redis.tsRedis connection (ElastiCache in production)

Frontend (oktivo_client/src/ and oktivo_app/src/) ​

FilePurpose
oktivo_app/src/pages/customer/CampaignsPage.tsxCampaign list with auto-polling (5s while running), restart confirmation dialog, agent change warnings
oktivo_app/src/pages/customer/UploadPage.tsxCSV upload with 50K row limit, agent selection, credit estimation, CSV preview, schedule campaign
oktivo_app/src/pages/customer/CampaignDetailPage.tsxCampaign detail with auto-polling (8s while running)
oktivo_app/src/pages/customer/HistoryPage.tsxCall history with date range, campaign/agent filters, direction, duration, bookmarked, analysis filters, per-page selector, CSV export, auto-polling for in-progress contacts
oktivo_app/src/pages/customer/AnalyticsPage.tsxAnalytics with date range, campaign/agent filters, per-campaign daily chart
oktivo_app/src/services/campaign.service.tsAPI client start(uid, agentId, confirmRestart?), upload(formData), listContacts(params)

Contact List Filters (GET /contacts) ​

The History page uses the following query parameters to filter contacts:

ParameterTypeDescription
fromISO date stringStart of date range (inclusive)
toISO date stringEnd of date range (inclusive, end of day)
statusstring"stopped" filters by contact status; all others filter by callStatus
campaignUIDstringFilter by campaign
agentIdstringFilter by agent
searchstringRegex search across name, phone, originalPhone
direction"inbound" | "outbound" | "all"Call direction (outbound includes legacy null values)
minDurationnumber (seconds)Minimum billing duration
maxDurationnumber (seconds)Maximum billing duration
bookmarked"true" | "false"Filter by bookmark status
analysis.*stringDynamic analysis field filters (e.g., analysis.sentiment=positive). Boolean strings are coerced. Multiple analysis filters use AND logic.
pagenumberPage number (default: 1)
limitnumberResults per page (default: 50, max: 500)

Analysis Filters are dynamic they depend on the campaign's agent analysis schema. The frontend fetches the schema via GET /contacts/analysis-schema/:campaignUID and renders appropriate filter controls (dropdowns for enums, toggles for booleans).

AnalysisFilters component (app/src/components/shared/AnalysisFilters.tsx): Renders dynamic filter UI based on the agent's analysis schema. Only visible when a campaign is selected.

Agent (Agent/) ​

FilePurpose
agent.pyLiveKit worker entry point
src/call_handler.pySIP call + recording trigger via aiohttp
src/services/post_call.pyTranscription/analysis sync

Environment Variables ​

client_backend ​

VariableExamplePurpose
PORT3000Express server port
MONGO_URImongodb+srv://...MongoDB Atlas (M30, 50 conn pool)
REDIS_URLredis://elasticache:6379Redis (ElastiCache Multi-AZ)
LIVEKIT_URLwss://voice.oktivo.com:7880LiveKit WebSocket
LIVEKIT_API_KEYLiveKitProdKeyLiveKit API key
LIVEKIT_API_SECRET...LiveKit API secret
AWS_S3_BUCKEToktivo-recordingsS3 bucket for recordings
AWS_S3_REGIONap-south-1S3 region

Production Scaling Targets ​

ComponentCountPurpose
Backend instances2-5Behind ALB, BullMQ distributes work
LiveKit servers2-4Behind NLB, handle WebRTC rooms
Egress workers3-5Auto-scaling, 1.5 CPU per recording
Agent workers3-5Python LiveKit workers, one agent per call
RedisElastiCache Multi-AZCampaign state, locks, active call tracking
MongoDBAtlas M3050 connection pool, compound indexes on campaignUID+status

Throughput: 100-200 concurrent calls at steady state, with BullMQ processing up to 50 campaign management jobs/second across all instances.

Last updated: