Skip to main content

Campaign Management

This guide covers the Ominis Cluster Manager outbound dialer campaign system, including the dedicated campaign pod architecture, lead list management, dialing strategies, and campaign lifecycle operations.

What is a Campaign?

An outbound campaign is an automated system for placing multiple outbound calls to a list of contacts. Campaigns are essential for:

  • Outbound sales - Proactive customer acquisition
  • Customer notifications - Appointment reminders, service updates
  • Survey operations - Market research and feedback collection
  • Debt collection - Payment reminders and collections
  • Political campaigns - Voter outreach and polling

Key Concepts

  • Campaign: A container for outbound calling operations with configuration and contact lists
  • Lead List: Collection of phone numbers or SIP URIs to be called
  • Dialing Strategy: Algorithm controlling how and when calls are placed (progressive, predictive, preview)
  • Campaign Pod: Dedicated FreeSWITCH instance for outbound campaign operations
  • Concurrency Control: Maximum number of simultaneous outbound calls
  • Retry Logic: Automated retry of failed or unanswered calls

Example Scenario: A sales team creates a "Q1 Sales Outreach" campaign → Uploads 1,000 customer contacts → Sets max concurrency to 20 calls → Campaign automatically dials through the list → Failed calls retry after 5 minutes → Campaign completes when all contacts processed.

Campaign Architecture

The Ominis Cluster Manager uses a dedicated campaign pod architecture, separating outbound operations from inbound queue operations.

System Components

Why Dedicated Campaign Pod?

The campaign pod is architecturally separate from queue and registrar pods for several critical reasons (see ADR: Dedicated Campaign Pod Architecture).

Campaign Pod Architecture

Key Features:

  • Minimal FreeSWITCH build - Only required modules for outbound calling
  • XML-RPC control - API sends originate commands via HTTP
  • SIP trunk integration - Connects to external providers for call delivery
  • Independent scaling - Scale campaign capacity without affecting queues
  • Dedicated resources - CPU/memory reserved for outbound operations
  • Isolated failure domain - Campaign issues don't impact inbound queues

Campaign Lifecycle

A campaign progresses through several states from creation to completion.

Campaign State Machine

State Descriptions

StateDescriptionCan Add Contacts?Active Calls?
CreatedInitial state after creation✅ Yes❌ No
ActiveProcessing contacts and dialing✅ Yes✅ Yes
PausedTemporarily suspended✅ Yes✅ Existing only
StoppedPermanently terminated❌ No❌ All hung up
CompletedAll contacts processed❌ No❌ No

Campaign Call Flow

Understanding how a single campaign call flows through the system is essential for troubleshooting and optimization.

Call Flow Steps

  1. Background Task Initialization

    • API starts process_campaign() background task
    • Task connects to campaign pod XML-RPC
  2. Contact Processing Loop

    • Check if concurrency limit allows new call
    • Retrieve next contact from campaign list
    • Send XML-RPC originate command to campaign pod
  3. Call Origination (Campaign Pod)

    • FreeSWITCH initiates SIP INVITE to trunk gateway
    • A-leg: Call to agent/endpoint specified in caller_id
    • B-leg: When answered, bridge to contact number
  4. Call Outcome Handling

    • Success: Increment calls_originated, track active call
    • Failure: Increment calls_failed, schedule retry if attempts remaining
    • Completion: Decrement active_calls, process next contact
  5. Campaign Termination

    • Contact list exhausted → Campaign state = completed
    • Manual stop → Hang up all active calls, state = stopped

Lead List Management

Campaigns require contact lists (leads) to dial. The Ominis Cluster Manager provides flexible lead management.

Lead Processing Flow

Lead Lifecycle

  1. Upload Contacts: POST to /campaigns/{id}/contacts with array of phone numbers
  2. Queue Management: Contacts stored in campaign's contact_list (FIFO queue)
  3. Contact Selection: get_next_contact() pops first contact from list
  4. Retry Handling: Failed calls requeued with retry counter incremented
  5. Completion: Contact removed when successful or max retries exceeded

Contact Formats

The Ominis Cluster Manager supports multiple contact formats:

FormatExampleUse Case
E.164 Phone Number+15551234567Standard PSTN dialing
Local Number5551234567Provider-specific routing
SIP URIsip:user@domain.comDirect SIP endpoint
FreeSWITCH Dial Stringsofia/gateway/trunk/5551234567Advanced routing control

Dialing Strategies

The Ominis Cluster Manager supports multiple dialing strategies (currently progressive is fully implemented, with predictive and preview planned).

Progressive Dialing (Current Implementation)

Progressive dialing places calls sequentially with concurrency control. When a call completes, the next contact is dialed immediately.

Characteristics:

  • ✅ Simple and predictable
  • ✅ Respects max_concurrent limit
  • ✅ Automatic retry logic
  • ✅ No abandoned calls (agent always ready)
  • ⚠️ Lower throughput than predictive

Configuration:

{
"max_concurrent": 10,
"retry_attempts": 3,
"retry_delay": 300
}

Throughput Formula:

Calls per hour ≈ (max_concurrent × 3600) / avg_call_duration_seconds

For max_concurrent=10 and avg_call_duration=180s:

Calls per hour ≈ (10 × 3600) / 180 = 200 calls/hour

Predictive Dialing (Planned)

Predictive dialing uses statistical models to dial more contacts than available agents, predicting that some calls will fail or not answer.

Benefits:

  • ⚡ Higher throughput (150-300% of progressive)
  • 📊 Statistical modeling of answer rates
  • 🎯 Optimizes agent utilization

Challenges:

  • ⚠️ Risk of abandoned calls (no agent available when contact answers)
  • 📜 Regulatory compliance (TCPA, FCC rules)
  • 🧮 Complex algorithm tuning

Implementation Status: 🚧 Planned for future release

Preview Dialing (Planned)

Preview dialing shows contact information to agent before dialing. Agent manually approves each call.

Benefits:

  • 👤 Agent reviews contact before call
  • 🎯 Personalized approach
  • ✅ Zero abandoned calls

Use Cases:

  • High-value B2B sales
  • Complex customer accounts
  • Regulated industries

Implementation Status: 🚧 Planned for future release

API Reference

The Ominis Cluster Manager exposes comprehensive campaign management endpoints.

Campaign Lifecycle Endpoints

Create Campaign

Create a new outbound campaign.

Endpoint: POST /v1/telephony/campaigns

Request:

curl -X POST https://demo-client-api.app.ominis.ai/v1/telephony/campaigns \
-H "X-API-Key: your-api-key" \
-H "Content-Type: application/json" \
-d '{
"campaign_name": "Q1 Sales Outreach 2025",
"caller_id": "sofia/gateway/trunk/+15551234567",
"dialplan": "XML",
"context": "default",
"timeout": 30,
"max_concurrent": 20,
"retry_attempts": 3,
"retry_delay": 300
}'

Response:

{
"campaign_name": "Q1 Sales Outreach 2025",
"success": true,
"message": "Campaign created successfully",
"campaign_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"calls_originated": 0,
"calls_failed": 0
}

Fields:

  • campaign_name: Human-readable campaign name
  • caller_id: A-leg endpoint (agent or IVR to bridge to contact)
  • dialplan: FreeSWITCH dialplan type (default: XML)
  • context: FreeSWITCH dialplan context (default: default)
  • timeout: Call timeout in seconds (1-300)
  • max_concurrent: Maximum simultaneous calls (1-100)
  • retry_attempts: Number of retry attempts for failed calls (0-10)
  • retry_delay: Delay between retries in seconds (60-3600)

Error Responses:

Status CodeError CodeDescription
400VALIDATION_ERRORInvalid request parameters
401UNAUTHORIZEDMissing or invalid API key
500CAMPAIGN_CREATE_ERRORInternal server error creating campaign

Add Contacts to Campaign

Upload a list of contacts to an existing campaign.

Endpoint: POST /v1/telephony/campaigns/{campaign_id}/contacts

Request:

curl -X POST https://demo-client-api.app.ominis.ai/v1/telephony/campaigns/a1b2c3d4-e5f6-7890-abcd-ef1234567890/contacts \
-H "X-API-Key: your-api-key" \
-H "Content-Type: application/json" \
-d '[
"+15551234567",
"+15559876543",
"+15555555555",
"+15551112222"
]'

Response:

{
"campaign_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"contacts_added": 4,
"total_contacts": 4,
"message": "Added 4 contacts to campaign"
}

Notes:

  • Contacts can be added at any time (even to active campaigns)
  • Supports E.164 format, local numbers, SIP URIs, or dial strings
  • No limit on batch size (but consider memory constraints)
  • Duplicates are not automatically filtered

Get Campaign Status

Retrieve real-time campaign statistics.

Endpoint: GET /v1/telephony/campaigns/{campaign_id}/status

Request:

curl https://demo-client-api.app.ominis.ai/v1/telephony/campaigns/a1b2c3d4-e5f6-7890-abcd-ef1234567890/status \
-H "X-API-Key: your-api-key"

Response:

{
"campaign_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"campaign_name": "Q1 Sales Outreach 2025",
"status": "active",
"created_at": "2025-10-14T10:30:00Z",
"calls_originated": 142,
"calls_failed": 18,
"active_calls": 12,
"remaining_contacts": 846,
"max_concurrent": 20,
"retry_attempts": 3,
"retry_delay": 300
}

Status Values:

  • active: Campaign is dialing contacts
  • paused: Campaign temporarily suspended
  • stopped: Campaign permanently terminated
  • completed: All contacts processed

Pause Campaign

Temporarily suspend campaign operations.

Endpoint: POST /v1/telephony/campaigns/{campaign_id}/pause

Request:

curl -X POST https://demo-client-api.app.ominis.ai/v1/telephony/campaigns/a1b2c3d4-e5f6-7890-abcd-ef1234567890/pause \
-H "X-API-Key: your-api-key"

Response:

{
"campaign_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"status": "paused",
"message": "Campaign paused successfully"
}

Behavior:

  • No new calls originated
  • Active calls continue until completion
  • Contact list and retry queue preserved
  • Can resume with /resume endpoint

Resume Campaign

Resume a paused campaign.

Endpoint: POST /v1/telephony/campaigns/{campaign_id}/resume

Request:

curl -X POST https://demo-client-api.app.ominis.ai/v1/telephony/campaigns/a1b2c3d4-e5f6-7890-abcd-ef1234567890/resume \
-H "X-API-Key: your-api-key"

Response:

{
"campaign_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"status": "active",
"message": "Campaign resumed successfully"
}

Behavior:

  • Campaign immediately resumes dialing
  • Picks up from where it left off
  • Respects concurrency limits
  • Retry timers continue from pause time

Stop Campaign

Permanently stop a campaign and hang up all active calls.

Endpoint: DELETE /v1/telephony/campaigns/{campaign_id}

Request:

curl -X DELETE https://demo-client-api.app.ominis.ai/v1/telephony/campaigns/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
-H "X-API-Key: your-api-key"

Response:

{
"campaign_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"status": "stopped",
"message": "Campaign stopped successfully"
}

Behavior:

  • All active calls immediately terminated with NORMAL_CLEARING
  • Campaign removed from active campaigns dictionary
  • Cannot be resumed (permanent deletion)
  • Contact list and statistics are lost

List All Campaigns

Get a list of all active campaigns with summary statistics.

Endpoint: GET /v1/telephony/campaigns

Request:

curl https://demo-client-api.app.ominis.ai/v1/telephony/campaigns \
-H "X-API-Key: your-api-key"

Response:

{
"campaigns": [
{
"campaign_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"campaign_name": "Q1 Sales Outreach 2025",
"status": "active",
"created_at": "2025-10-14T10:30:00Z",
"calls_originated": 142,
"calls_failed": 18,
"active_calls": 12,
"remaining_contacts": 846
},
{
"campaign_id": "b2c3d4e5-f6g7-8901-bcde-fg2345678901",
"campaign_name": "Customer Satisfaction Survey",
"status": "paused",
"created_at": "2025-10-13T14:00:00Z",
"calls_originated": 520,
"calls_failed": 67,
"active_calls": 0,
"remaining_contacts": 230
}
]
}

Real-World Examples

Example 1: Basic Sales Campaign

Create a simple sales campaign with 50 contacts and 5 concurrent calls.

# Step 1: Create campaign
CAMPAIGN_RESPONSE=$(curl -s -X POST https://demo-client-api.app.ominis.ai/v1/telephony/campaigns \
-H "X-API-Key: demo-key" \
-H "Content-Type: application/json" \
-d '{
"campaign_name": "Afternoon Sales Blitz",
"caller_id": "sofia/gateway/trunk/+15551234567",
"max_concurrent": 5,
"retry_attempts": 2,
"retry_delay": 600
}')

CAMPAIGN_ID=$(echo $CAMPAIGN_RESPONSE | jq -r '.campaign_id')
echo "Campaign ID: $CAMPAIGN_ID"

# Step 2: Upload contact list
curl -X POST "https://demo-client-api.app.ominis.ai/v1/telephony/campaigns/$CAMPAIGN_ID/contacts" \
-H "X-API-Key: demo-key" \
-H "Content-Type: application/json" \
-d '[
"+15551111111",
"+15552222222",
"+15553333333",
"+15554444444",
"+15555555555"
]'

# Step 3: Monitor campaign progress
watch -n 5 "curl -s https://demo-client-api.app.ominis.ai/v1/telephony/campaigns/$CAMPAIGN_ID/status \
-H 'X-API-Key: demo-key' | jq '.'"

# Step 4: Pause campaign if needed
curl -X POST "https://demo-client-api.app.ominis.ai/v1/telephony/campaigns/$CAMPAIGN_ID/pause" \
-H "X-API-Key: demo-key"

# Step 5: Resume when ready
curl -X POST "https://demo-client-api.app.ominis.ai/v1/telephony/campaigns/$CAMPAIGN_ID/resume" \
-H "X-API-Key: demo-key"

# Step 6: Stop campaign when done
curl -X DELETE "https://demo-client-api.app.ominis.ai/v1/telephony/campaigns/$CAMPAIGN_ID" \
-H "X-API-Key: demo-key"

Example 2: High-Volume Appointment Reminder Campaign

Large-scale automated appointment reminders with high concurrency.

# Create high-capacity campaign
CAMPAIGN_RESPONSE=$(curl -s -X POST https://demo-client-api.app.ominis.ai/v1/telephony/campaigns \
-H "X-API-Key: demo-key" \
-H "Content-Type: application/json" \
-d '{
"campaign_name": "Medical Appointment Reminders - October",
"caller_id": "sofia/gateway/trunk/+18005551234",
"max_concurrent": 50,
"retry_attempts": 1,
"retry_delay": 3600,
"timeout": 20
}')

CAMPAIGN_ID=$(echo $CAMPAIGN_RESPONSE | jq -r '.campaign_id')

# Upload large contact list from file
curl -X POST "https://demo-client-api.app.ominis.ai/v1/telephony/campaigns/$CAMPAIGN_ID/contacts" \
-H "X-API-Key: demo-key" \
-H "Content-Type: application/json" \
--data @appointment_contacts.json

# appointment_contacts.json contains:
# ["+15551111111", "+15552222222", ... (5000 contacts)]

# Monitor with detailed stats
while true; do
STATUS=$(curl -s "https://demo-client-api.app.ominis.ai/v1/telephony/campaigns/$CAMPAIGN_ID/status" \
-H "X-API-Key: demo-key")

REMAINING=$(echo $STATUS | jq -r '.remaining_contacts')
ACTIVE=$(echo $STATUS | jq -r '.active_calls')
ORIGINATED=$(echo $STATUS | jq -r '.calls_originated')
FAILED=$(echo $STATUS | jq -r '.calls_failed')

echo "$(date) - Remaining: $REMAINING | Active: $ACTIVE | Originated: $ORIGINATED | Failed: $FAILED"

# Exit when campaign completes
if [ "$REMAINING" -eq 0 ] && [ "$ACTIVE" -eq 0 ]; then
echo "Campaign completed!"
break
fi

sleep 10
done

Example 3: Survey Campaign with Pause/Resume

Interactive survey campaign that pauses during business hours.

# Create survey campaign
CAMPAIGN_RESPONSE=$(curl -s -X POST https://demo-client-api.app.ominis.ai/v1/telephony/campaigns \
-H "X-API-Key: demo-key" \
-H "Content-Type: application/json" \
-d '{
"campaign_name": "Customer Satisfaction Survey Q4",
"caller_id": "sofia/gateway/trunk/+18005559999",
"max_concurrent": 15,
"retry_attempts": 3,
"retry_delay": 1800
}')

CAMPAIGN_ID=$(echo $CAMPAIGN_RESPONSE | jq -r '.campaign_id')

# Upload contacts
curl -X POST "https://demo-client-api.app.ominis.ai/v1/telephony/campaigns/$CAMPAIGN_ID/contacts" \
-H "X-API-Key: demo-key" \
-H "Content-Type: application/json" \
-d '["list", "of", "contacts"]'

# Intelligent pause/resume based on time
while true; do
HOUR=$(date +%H)

if [ $HOUR -ge 9 ] && [ $HOUR -lt 21 ]; then
# Business hours: Run campaign
STATUS=$(curl -s "https://demo-client-api.app.ominis.ai/v1/telephony/campaigns/$CAMPAIGN_ID/status" \
-H "X-API-Key: demo-key" | jq -r '.status')

if [ "$STATUS" == "paused" ]; then
echo "Resuming campaign for business hours..."
curl -X POST "https://demo-client-api.app.ominis.ai/v1/telephony/campaigns/$CAMPAIGN_ID/resume" \
-H "X-API-Key: demo-key"
fi
else
# Outside business hours: Pause campaign
STATUS=$(curl -s "https://demo-client-api.app.ominis.ai/v1/telephony/campaigns/$CAMPAIGN_ID/status" \
-H "X-API-Key: demo-key" | jq -r '.status')

if [ "$STATUS" == "active" ]; then
echo "Pausing campaign outside business hours..."
curl -X POST "https://demo-client-api.app.ominis.ai/v1/telephony/campaigns/$CAMPAIGN_ID/pause" \
-H "X-API-Key: demo-key"
fi
fi

sleep 300 # Check every 5 minutes
done

Example 4: Python Integration for Campaign Management

Programmatic campaign management using Python.

import requests
import time
from typing import List, Dict

class OminisCampaignClient:
"""Client for Ominis Cluster Manager campaign operations"""

def __init__(self, base_url: str, api_key: str):
self.base_url = base_url
self.headers = {
"X-API-Key": api_key,
"Content-Type": "application/json"
}

def create_campaign(self, name: str, caller_id: str, max_concurrent: int = 10) -> Dict:
"""Create a new campaign"""
response = requests.post(
f"{self.base_url}/v1/telephony/campaigns",
headers=self.headers,
json={
"campaign_name": name,
"caller_id": caller_id,
"max_concurrent": max_concurrent,
"retry_attempts": 3,
"retry_delay": 300
}
)
response.raise_for_status()
return response.json()

def add_contacts(self, campaign_id: str, contacts: List[str]) -> Dict:
"""Add contacts to campaign"""
response = requests.post(
f"{self.base_url}/v1/telephony/campaigns/{campaign_id}/contacts",
headers=self.headers,
json=contacts
)
response.raise_for_status()
return response.json()

def get_status(self, campaign_id: str) -> Dict:
"""Get campaign status"""
response = requests.get(
f"{self.base_url}/v1/telephony/campaigns/{campaign_id}/status",
headers=self.headers
)
response.raise_for_status()
return response.json()

def pause_campaign(self, campaign_id: str) -> Dict:
"""Pause campaign"""
response = requests.post(
f"{self.base_url}/v1/telephony/campaigns/{campaign_id}/pause",
headers=self.headers
)
response.raise_for_status()
return response.json()

def resume_campaign(self, campaign_id: str) -> Dict:
"""Resume campaign"""
response = requests.post(
f"{self.base_url}/v1/telephony/campaigns/{campaign_id}/resume",
headers=self.headers
)
response.raise_for_status()
return response.json()

def stop_campaign(self, campaign_id: str) -> Dict:
"""Stop and delete campaign"""
response = requests.delete(
f"{self.base_url}/v1/telephony/campaigns/{campaign_id}",
headers=self.headers
)
response.raise_for_status()
return response.json()

def wait_for_completion(self, campaign_id: str, check_interval: int = 5):
"""Wait for campaign to complete"""
while True:
status = self.get_status(campaign_id)
remaining = status['remaining_contacts']
active = status['active_calls']

print(f"Remaining: {remaining}, Active: {active}")

if remaining == 0 and active == 0:
print("Campaign completed!")
break

time.sleep(check_interval)

# Usage example
if __name__ == "__main__":
client = OminisCampaignClient(
base_url="https://demo-client-api.app.ominis.ai",
api_key="your-api-key"
)

# Create campaign
campaign = client.create_campaign(
name="Python Test Campaign",
caller_id="sofia/gateway/trunk/+15551234567",
max_concurrent=10
)

campaign_id = campaign['campaign_id']
print(f"Created campaign: {campaign_id}")

# Add contacts
contacts = [f"+1555{i:07d}" for i in range(1, 101)] # 100 contacts
result = client.add_contacts(campaign_id, contacts)
print(f"Added {result['contacts_added']} contacts")

# Monitor for 60 seconds
for i in range(12):
status = client.get_status(campaign_id)
print(f"Status: {status['active_calls']} active, {status['remaining_contacts']} remaining")
time.sleep(5)

# Stop campaign
client.stop_campaign(campaign_id)
print("Campaign stopped")

Background Task Orchestration

The Ominis Cluster Manager uses FastAPI's BackgroundTasks for asynchronous campaign processing.

Background Task Architecture

Background Task Implementation

The process_campaign() function runs asynchronously for each campaign:

Key Features:

  1. Non-Blocking: API returns immediately after campaign creation
  2. Concurrency Control: Respects max_concurrent limit
  3. Retry Logic: Automatically retries failed calls
  4. State Management: Updates campaign statistics in real-time
  5. Graceful Termination: Stops when contact list empty or status != "active"

Code Flow:

async def process_campaign(campaign_id: str, telephony: TelephonyPort):
"""Background task to process outbound campaign"""
campaign = active_campaigns.get(campaign_id)
if not campaign:
return

# Connect to campaign pod XML-RPC
xmlrpc_client = await get_campaign_xmlrpc_client()

# Main processing loop
while campaign.status == "active" and campaign.contact_list:
if campaign.can_originate_call(): # Check concurrency
contact = campaign.get_next_contact()
if contact:
try:
# Originate via campaign pod
call_uuid = await xmlrpc_client.originate_call(
aleg_endpoint=campaign.caller_id,
bleg_application="bridge",
bleg_args=contact,
timeout=campaign.timeout
)

if call_uuid:
campaign.calls_originated += 1
campaign.active_calls += 1
campaign.current_calls[call_uuid] = {
"contact": contact,
"start_time": datetime.now(),
"retry_count": 0
}
else:
campaign.calls_failed += 1

except Exception as e:
campaign.calls_failed += 1
logger.error(f"Error originating call: {e}")

await asyncio.sleep(1) # Rate limiting

Why Background Tasks?

  • ✅ Non-blocking API responses
  • ✅ Long-running operations without request timeout
  • ✅ Automatic lifecycle management by FastAPI
  • ✅ Exception handling and logging
  • ✅ No external task queue required (Redis, Celery)

Trade-offs (see ADR: In-Memory Campaign State vs Database):

  • ⚠️ State lost on API pod restart
  • ⚠️ No horizontal scaling (single API instance handles all campaigns)
  • ⚠️ Limited persistence (no historical campaign data)

Monitoring and Statistics

The Ominis Cluster Manager provides real-time campaign monitoring for operational visibility.

Campaign Statistics

MetricDescriptionUpdated
calls_originatedTotal calls successfully originatedReal-time
calls_failedTotal calls that failed to originateReal-time
active_callsCurrently active callsReal-time
remaining_contactsContacts not yet dialedReal-time
max_concurrentConcurrency limitStatic
retry_attemptsMax retry attempts per contactStatic
retry_delayDelay between retries (seconds)Static

Real-Time Monitoring

# Terminal 1: Monitor campaign status
watch -n 2 "curl -s https://demo-client-api.app.ominis.ai/v1/telephony/campaigns/CAMPAIGN_ID/status \
-H 'X-API-Key: demo-key' | jq '.'"

# Terminal 2: Monitor API logs
kubectl logs -f deployment/api -n client-demo-client | grep "Campaign"

# Terminal 3: Monitor campaign pod FreeSWITCH
kubectl exec -it deployment/freeswitch-campaign -n client-demo-client -- fs_cli -x "show channels"

Performance Metrics

Expected Throughput (progressive dialing):

  • max_concurrent=10, avg_call_duration=180s: ~200 calls/hour
  • max_concurrent=20, avg_call_duration=180s: ~400 calls/hour
  • max_concurrent=50, avg_call_duration=180s: ~1000 calls/hour

Latency:

  • Campaign creation: < 50ms (in-memory operation)
  • Contact upload: < 100ms (1000 contacts)
  • Status query: < 10ms (in-memory read)
  • Call origination: 100-500ms (XML-RPC to campaign pod)

Resource Usage (campaign pod):

  • CPU: 0.1-0.5 cores (scales with concurrent calls)
  • Memory: 256-512MB (minimal FreeSWITCH build)
  • Network: ~100 Kbps per concurrent call (G.711 codec)

Error Handling

The Ominis Cluster Manager provides comprehensive error handling for campaign operations.

Common Error Scenarios

Campaign Not Found

curl -X GET https://demo-client-api.app.ominis.ai/v1/telephony/campaigns/invalid-id/status \
-H "X-API-Key: demo-key"

Response (404 Not Found):

{
"code": "CAMPAIGN_NOT_FOUND",
"message": "Campaign invalid-id not found"
}

Resolution: Verify campaign ID with GET /campaigns endpoint.

Call Origination Failure

Campaign pod XML-RPC unreachable or SIP trunk failure.

Behavior:

  • Call marked as failed: calls_failed incremented
  • Contact requeued if retry attempts remain
  • Error logged: "Error originating call to {contact}: {error}"
  • Campaign continues processing remaining contacts

Troubleshooting:

# Check campaign pod status
kubectl get pods -n client-demo-client -l app=freeswitch-campaign

# Check campaign pod logs
kubectl logs deployment/freeswitch-campaign -n client-demo-client

# Verify XML-RPC connectivity from API pod
kubectl exec -it deployment/api -n client-demo-client -- \
curl http://freeswitch-campaign.client-demo-client.svc.cluster.local:8080/RPC2

# Check SIP trunk registration
kubectl exec -it deployment/freeswitch-campaign -n client-demo-client -- \
fs_cli -x "sofia status gateway trunk-name"

Concurrency Limit Exceeded

Client attempts to set max_concurrent > 100.

Response (400 Bad Request):

{
"detail": [
{
"loc": ["body", "max_concurrent"],
"msg": "ensure this value is less than or equal to 100",
"type": "value_error.number.not_le"
}
]
}

Resolution: Reduce max_concurrent to ≤ 100. For higher concurrency, contact Ominis.ai support.

Campaign Pod Unavailable

Campaign pod crashed or not deployed.

Behavior:

  • Campaign creation succeeds (API-side state created)
  • Background task fails to connect to XML-RPC
  • Campaign status set to "failed"
  • Error logged: "Failed to connect to campaign XML-RPC: {error}"

Resolution:

# Verify campaign pod deployment
kubectl get deployment freeswitch-campaign -n client-demo-client

# Redeploy if necessary
kubectl rollout restart deployment/freeswitch-campaign -n client-demo-client

# Verify service
kubectl get svc freeswitch-campaign -n client-demo-client

# Test connectivity
kubectl exec -it deployment/api -n client-demo-client -- \
nc -zv freeswitch-campaign.client-demo-client.svc.cluster.local 8080

ADR: Dedicated Campaign Pod Architecture

Context:

Outbound dialing operations have fundamentally different requirements than inbound queue operations:

  • Resource Allocation: Outbound campaigns can consume significant resources during peak dialing
  • Failure Isolation: Campaign failures should not impact inbound call center operations
  • Configuration: Campaign pod requires different FreeSWITCH modules and SIP trunk configuration
  • Scaling: Outbound and inbound traffic patterns differ, requiring independent scaling strategies
  • Monitoring: Separate pods enable targeted monitoring and alerting

Options Considered:

  1. Shared Queue Pods - Use existing queue pods for campaign operations

    • ❌ Resource contention with inbound queues
    • ❌ Configuration conflicts (different module requirements)
    • ❌ Failure coupling (campaign issues impact queue operations)
    • ✅ Simpler architecture (fewer pods)
    • ✅ Lower infrastructure cost
  2. Dedicated Campaign Pod - Separate FreeSWITCH instance for campaigns

    • ✅ Resource isolation
    • ✅ Independent failure domain
    • ✅ Optimized configuration (minimal modules)
    • ✅ Independent scaling
    • ✅ Clear architectural boundaries
    • ⚠️ Additional pod overhead
    • ⚠️ More complex deployment
  3. Campaign per Queue Pod - Dynamically allocate campaigns to queue pods

    • ⚠️ Complex orchestration logic
    • ⚠️ Still has resource contention issues
    • ⚠️ Unclear ownership and monitoring

Decision:

We chose Option 2: Dedicated Campaign Pod for the following reasons:

  1. Failure Isolation: Campaign pod failures do not impact inbound call center operations. Inbound queues continue operating normally even if campaigns crash or exhaust resources.

  2. Resource Guarantees: Queue pods have guaranteed resources for inbound operations. Campaigns cannot starve queue operations of CPU/memory.

  3. Optimized Configuration: Campaign pod uses minimal FreeSWITCH modules (no mod_callcenter, simplified dialplan). This reduces startup time, memory footprint, and attack surface.

  4. Independent Scaling: Campaign capacity can be scaled independently by adjusting campaign pod resources or deploying multiple campaign pods (future).

  5. Clear Responsibilities: Single-purpose components are easier to understand, monitor, and troubleshoot. Campaign issues are clearly isolated to the campaign pod.

  6. Simplified SIP Trunk Management: Campaign pod has its own SIP trunk configuration, avoiding interference with queue or registrar SIP profiles.

Consequences:

Positive:

  • ✅ Improved reliability for inbound operations
  • ✅ Clearer system architecture and boundaries
  • ✅ Easier troubleshooting (campaign issues isolated)
  • ✅ Better resource utilization (rightsizing per component)
  • ✅ Foundation for future horizontal scaling

Negative:

  • ⚠️ Additional pod overhead (~256MB memory, 0.25 CPU)
  • ⚠️ More Kubernetes resources to manage
  • ⚠️ Slightly more complex deployment (one additional pod)

Mitigation:

  • Use minimal FreeSWITCH build to reduce overhead
  • Provide clear deployment documentation
  • Include campaign pod in Helm chart for automated deployment

Related Decisions:

ADR: In-Memory Campaign State vs Database

Context:

Campaign state (contact lists, statistics, current calls) must be stored somewhere. Options include in-memory storage (Python dictionaries) or persistent storage (PostgreSQL, Redis).

Options Considered:

  1. In-Memory Storage - Store campaign state in Python dictionaries

    • ✅ Simple implementation (no additional dependencies)
    • ✅ Low latency (no database queries)
    • ✅ No database schema changes required
    • ❌ State lost on API pod restart
    • ❌ No horizontal scaling (multiple API pods would have separate state)
    • ❌ No historical campaign data
    • ❌ No persistence for long-running campaigns
  2. PostgreSQL Storage - Store campaigns in database

    • ✅ Persistent state across restarts
    • ✅ Historical campaign data
    • ✅ Supports horizontal scaling (shared database)
    • ✅ Enables advanced querying and reporting
    • ⚠️ Higher latency (database queries)
    • ⚠️ Schema migrations required
    • ⚠️ More complex implementation
  3. Redis Storage - Store campaigns in Redis

    • ✅ Fast in-memory performance
    • ✅ Persistent state (with Redis persistence)
    • ✅ Supports horizontal scaling
    • ✅ Pub/sub for real-time updates
    • ⚠️ Additional infrastructure dependency
    • ⚠️ Memory-limited (not suitable for huge campaigns)
    • ⚠️ Requires Redis deployment and management

Decision:

We chose Option 1: In-Memory Storage for the initial implementation with a clear migration path to PostgreSQL for production use.

Rationale:

  1. Simplicity: For MVP and initial deployments, in-memory storage provides the fastest path to a working solution with minimal dependencies.

  2. Performance: Campaign operations (status queries, contact retrieval) have extremely low latency with in-memory access.

  3. No Schema Changes: Avoids immediate database migrations, allowing campaign features to be deployed without database changes.

  4. Deployment Flexibility: Works in any environment (Kubernetes, Docker, local dev) without additional infrastructure.

  5. Clear Upgrade Path: Code is structured to easily swap in a database-backed campaign manager when persistence is required.

Consequences:

Positive:

  • ✅ Rapid development and deployment
  • ✅ Minimal infrastructure requirements
  • ✅ Excellent performance for status queries
  • ✅ Simple debugging (campaign state visible in API logs)

Negative:

  • ❌ Campaigns lost on API pod restart (unacceptable for production)
  • ❌ No horizontal scaling (API pod is single point of failure)
  • ❌ No campaign history or analytics
  • ❌ Contact lists limited by API pod memory

Mitigation & Migration Path:

Short-term (current implementation):

  • Document limitation clearly in API docs
  • Log campaign statistics for post-mortem analysis
  • Recommend single-replica API deployment

Medium-term (planned):

  • Implement PostgreSQL-backed campaign manager
  • Add database schema for campaigns and contacts
  • Migrate in-memory campaigns to database on API startup
  • Support campaign history and reporting

Long-term (future):

  • Support horizontal scaling with shared database
  • Add Redis for real-time campaign updates
  • Implement campaign analytics and reporting dashboard

Database Schema (planned):

CREATE TABLE campaigns (
campaign_id UUID PRIMARY KEY,
campaign_name VARCHAR(255) NOT NULL,
caller_id VARCHAR(255) NOT NULL,
status VARCHAR(50) NOT NULL,
max_concurrent INT NOT NULL,
retry_attempts INT NOT NULL,
retry_delay INT NOT NULL,
calls_originated INT DEFAULT 0,
calls_failed INT DEFAULT 0,
active_calls INT DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE TABLE campaign_contacts (
contact_id SERIAL PRIMARY KEY,
campaign_id UUID REFERENCES campaigns(campaign_id) ON DELETE CASCADE,
contact VARCHAR(255) NOT NULL,
status VARCHAR(50) NOT NULL, -- pending, dialing, completed, failed
retry_count INT DEFAULT 0,
last_attempt TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_campaign_contacts_campaign_id ON campaign_contacts(campaign_id);
CREATE INDEX idx_campaign_contacts_status ON campaign_contacts(status);

Related Decisions:

ADR: XML-RPC vs ESL for Call Origination

Context:

The Ominis Cluster Manager needs to originate outbound calls on the campaign pod. FreeSWITCH provides two primary interfaces: XML-RPC (HTTP-based) and ESL (Event Socket Library, TCP-based).

Options Considered:

  1. XML-RPC - HTTP-based FreeSWITCH control

    • ✅ Simple HTTP requests (no persistent connections)
    • ✅ Stateless (easy to retry and recover)
    • ✅ Standard HTTP authentication
    • ✅ Works well with async Python (aiohttp)
    • ⚠️ Higher latency per command (HTTP overhead)
    • ⚠️ No real-time events (must poll for call status)
  2. ESL (Event Socket Library) - TCP socket-based control

    • ✅ Low latency per command
    • ✅ Real-time event subscription
    • ✅ Single persistent connection
    • ⚠️ Complex connection management (reconnection logic)
    • ⚠️ Stateful (connection failures require recovery)
    • ⚠️ More complex Python implementation

Decision:

We chose Option 1: XML-RPC for campaign call origination.

Rationale:

  1. Simplicity: XML-RPC is simpler to implement and maintain. Each originate command is a standalone HTTP request with no connection state.

  2. Retry-Friendly: HTTP requests can be easily retried with standard HTTP client libraries. ESL requires custom reconnection logic.

  3. Stateless: No persistent connection to manage. API can originate calls without maintaining open sockets to campaign pod.

  4. Kubernetes-Native: HTTP health checks and service discovery work naturally. ESL requires custom health checking.

  5. Performance Sufficient: For campaign use cases, originating calls every 1-5 seconds, HTTP latency (10-50ms) is acceptable. ESL's lower latency (1-5ms) is not necessary.

  6. Authentication: Standard HTTP basic auth integrates with FreeSWITCH XML-RPC ACLs. ESL authentication is less standardized.

Consequences:

Positive:

  • ✅ Simple, maintainable codebase
  • ✅ Easy error handling and retry logic
  • ✅ No connection state management
  • ✅ Works well with async Python HTTP clients
  • ✅ Standard Kubernetes service discovery

Negative:

  • ⚠️ Higher latency per originate command (acceptable for campaigns)
  • ⚠️ No real-time call events (must poll FreeSWITCH for call status)
  • ⚠️ Slightly higher CPU usage from HTTP overhead

When to Use ESL Instead:

ESL is more appropriate for:

  • Real-time call control - Low-latency call transfers, DTMF, etc.
  • Event-driven architectures - Subscribe to FreeSWITCH events (CHANNEL_CREATE, CHANNEL_HANGUP)
  • High-frequency commands - Thousands of commands per second
  • Interactive applications - IVR socket handlers, real-time call monitoring

The Ominis Cluster Manager does use ESL for:

  • IVR socket handlers - Real-time DTMF and TTS
  • Real-time call monitoring (planned) - Live event subscriptions

Related Decisions:

Configuration Reference

Environment Variables

The campaign pod uses these environment variables (configured in config.py):

VariableDefaultDescription
CAMPAIGN_XMLRPC_URLhttp://freeswitch-campaign.client-demo-client.svc.cluster.local:8080/RPC2Campaign pod XML-RPC endpoint
CAMPAIGN_XMLRPC_USERNAMEfsadminXML-RPC basic auth username
CAMPAIGN_XMLRPC_PASSWORDWinnipeg2025XML-RPC basic auth password

Campaign Pod Deployment

The campaign pod is deployed via Kubernetes manifests in /freeswitch-campaign/k8s/:

Namespace:

apiVersion: v1
kind: Namespace
metadata:
name: client-demo-client

Deployment:

apiVersion: apps/v1
kind: Deployment
metadata:
name: freeswitch-campaign
namespace: client-demo-client
spec:
replicas: 1
selector:
matchLabels:
app: freeswitch-campaign
template:
metadata:
labels:
app: freeswitch-campaign
spec:
containers:
- name: freeswitch
image: 51.79.31.20:5001/freeswitch-campaign:latest
ports:
- containerPort: 5080
name: sip
protocol: UDP
- containerPort: 8080
name: xmlrpc
protocol: TCP
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"

Service:

apiVersion: v1
kind: Service
metadata:
name: freeswitch-campaign
namespace: client-demo-client
spec:
type: ClusterIP
ports:
- port: 8080
targetPort: 8080
protocol: TCP
name: xmlrpc
- port: 5080
targetPort: 5080
protocol: UDP
name: sip
selector:
app: freeswitch-campaign

FreeSWITCH Configuration

XML-RPC Enabled (autoload_configs/xml_rpc.conf.xml):

<configuration name="xml_rpc.conf" description="XML RPC">
<settings>
<param name="http-port" value="8080"/>
<param name="auth-realm" value="freeswitch"/>
<param name="auth-user" value="fsadmin"/>
<param name="auth-pass" value="Winnipeg2025"/>
</settings>
</configuration>

Minimal Modules (modules.conf.xml):

<modules>
<load module="mod_console"/>
<load module="mod_logfile"/>
<load module="mod_sofia"/>
<load module="mod_dptools"/>
<load module="mod_dialplan_xml"/>
<load module="mod_xml_rpc"/>
<!-- No mod_callcenter - not needed for outbound -->
</modules>

External SIP Profile (sip_profiles/external.xml):

<profile name="external">
<settings>
<param name="sip-port" value="5080"/>
<param name="auth-calls" value="false"/>
<param name="accept-blind-reg" value="false"/>
<param name="outbound-proxy" value="sip-provider.com"/>
</settings>
</profile>

Troubleshooting

Campaign Not Dialing

Symptoms: Campaign created and contacts added, but no calls originated.

Diagnosis:

# Check campaign status
curl https://demo-client-api.app.ominis.ai/v1/telephony/campaigns/CAMPAIGN_ID/status \
-H "X-API-Key: demo-key" | jq '.'

# Check API logs for background task
kubectl logs deployment/api -n client-demo-client | grep "Campaign.*CAMPAIGN_ID"

# Check if background task is running
kubectl logs deployment/api -n client-demo-client | grep "process_campaign"

Common Causes:

  1. Campaign paused: Check status field, resume with /resume endpoint
  2. No contacts: Verify remaining_contacts > 0
  3. Concurrency limit: Check active_calls >= max_concurrent
  4. Background task crashed: Check API logs for exceptions
  5. Campaign pod unreachable: Verify XML-RPC connectivity

High Call Failure Rate

Symptoms: Many calls in calls_failed, few in calls_originated.

Diagnosis:

# Check API logs for origination errors
kubectl logs deployment/api -n client-demo-client | grep "Error originating call"

# Check campaign pod FreeSWITCH logs
kubectl logs deployment/freeswitch-campaign -n client-demo-client | grep "originate"

# Check SIP trunk registration
kubectl exec -it deployment/freeswitch-campaign -n client-demo-client -- \
fs_cli -x "sofia status gateway trunk-name"

Common Causes:

  1. SIP trunk not registered: Check gateway status and credentials
  2. Invalid contact format: Verify phone numbers are E.164 or valid SIP URIs
  3. Network issues: Check connectivity to SIP provider
  4. Provider rate limiting: Contact SIP provider for rate limits
  5. Invalid caller_id: Verify A-leg endpoint is reachable

Campaign Pod Crashes

Symptoms: Campaign pod restarts frequently, campaigns fail.

Diagnosis:

# Check pod status
kubectl get pods -n client-demo-client -l app=freeswitch-campaign

# Check pod events
kubectl describe pod -n client-demo-client -l app=freeswitch-campaign

# Check resource usage
kubectl top pod -n client-demo-client -l app=freeswitch-campaign

# Check logs before crash
kubectl logs -p deployment/freeswitch-campaign -n client-demo-client

Common Causes:

  1. OOM (Out of Memory): Increase memory limits in deployment
  2. CPU throttling: Increase CPU limits
  3. Configuration error: Check FreeSWITCH configuration validity
  4. Liveness probe failure: Adjust probe timeouts

Resolution:

# Increase resource limits
kubectl edit deployment freeswitch-campaign -n client-demo-client

# Update resources section:
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
memory: "1Gi"
cpu: "1000m"

API Pod Restart Lost Campaigns

Symptoms: After API pod restart, all campaigns are gone.

Explanation: This is expected behavior with in-memory campaign storage (see ADR: In-Memory Campaign State vs Database).

Workarounds:

  1. Avoid API pod restarts during active campaigns
  2. Pause campaigns before maintenance windows
  3. Export campaign data via status endpoint before restart
  4. Implement persistent storage (see ADR for migration path)

Long-term Solution: Migrate to PostgreSQL-backed campaign storage.

Performance Tuning

Optimizing Throughput

To maximize campaign throughput:

  1. Increase Concurrency:

    {
    "max_concurrent": 50
    }
    • Higher concurrency = more simultaneous calls
    • Limited by campaign pod resources and SIP trunk capacity
  2. Reduce Call Timeout:

    {
    "timeout": 20
    }
    • Lower timeout = faster failure detection
    • Trade-off: May miss legitimate slow-answering contacts
  3. Minimize Retry Attempts:

    {
    "retry_attempts": 1,
    "retry_delay": 60
    }
    • Fewer retries = faster campaign completion
    • Trade-off: May miss contacts with temporary issues
  4. Scale Campaign Pod Resources:

    resources:
    requests:
    memory: "1Gi"
    cpu: "1000m"
    limits:
    memory: "2Gi"
    cpu: "2000m"

Reducing Latency

For faster campaign responsiveness:

  1. Increase Background Task Frequency:

    • Reduce sleep interval in process_campaign() (default: 1 second)
    • Trade-off: Higher CPU usage
  2. Optimize XML-RPC Connection:

    • Keep XML-RPC client persistent (already implemented)
    • Use connection pooling for multiple campaigns (future)
  3. Co-locate Campaign Pod:

    • Deploy campaign pod in same node as API pod (Kubernetes affinity)
    • Reduces network latency for XML-RPC calls

This campaign management guide connects to other parts of the Ominis Cluster Manager:

Core Infrastructure

Architectural Patterns

Operations

Summary

The Ominis Cluster Manager Campaign Management system provides enterprise-grade outbound dialing capabilities:

Dedicated Architecture - Separate campaign pod isolates outbound operations from inbound queues
Flexible Lead Management - Upload, manage, and automatically dial contact lists
Progressive Dialing - Intelligent concurrency control and retry logic
Background Orchestration - Non-blocking campaign processing with FastAPI
Real-Time Monitoring - Live campaign statistics and status tracking
XML-RPC Control - Reliable call origination via FreeSWITCH XML-RPC
Kubernetes-Native - Cloud-native deployment and scaling
Production-Ready - Comprehensive error handling and troubleshooting

Key Takeaways:

  1. Isolation is Key: Dedicated campaign pod prevents resource contention with inbound operations
  2. Progressive Dialing Works: Simple, predictable, and sufficient for most use cases
  3. Background Tasks Enable Scale: Async processing allows long-running campaigns without blocking
  4. In-Memory is Temporary: Current implementation prioritizes simplicity; migrate to database for production
  5. XML-RPC is Sufficient: HTTP-based control is simpler and adequate for campaign latency requirements

The campaign system is designed for evolution: Start with in-memory progressive dialing, migrate to persistent storage, add predictive dialing algorithms, and scale horizontally as needed.

For questions or support, contact Ominis.ai or refer to the complete documentation.


Powered by Ominis.ai - Cloud-Native Call Control Platform