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
| State | Description | Can Add Contacts? | Active Calls? |
|---|---|---|---|
| Created | Initial state after creation | ✅ Yes | ❌ No |
| Active | Processing contacts and dialing | ✅ Yes | ✅ Yes |
| Paused | Temporarily suspended | ✅ Yes | ✅ Existing only |
| Stopped | Permanently terminated | ❌ No | ❌ All hung up |
| Completed | All 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
-
Background Task Initialization
- API starts
process_campaign()background task - Task connects to campaign pod XML-RPC
- API starts
-
Contact Processing Loop
- Check if concurrency limit allows new call
- Retrieve next contact from campaign list
- Send XML-RPC originate command to campaign pod
-
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
-
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
- Success: Increment
-
Campaign Termination
- Contact list exhausted → Campaign state =
completed - Manual stop → Hang up all active calls, state =
stopped
- Contact list exhausted → Campaign state =
Lead List Management
Campaigns require contact lists (leads) to dial. The Ominis Cluster Manager provides flexible lead management.
Lead Processing Flow
Lead Lifecycle
- Upload Contacts: POST to
/campaigns/{id}/contactswith array of phone numbers - Queue Management: Contacts stored in campaign's
contact_list(FIFO queue) - Contact Selection:
get_next_contact()pops first contact from list - Retry Handling: Failed calls requeued with retry counter incremented
- Completion: Contact removed when successful or max retries exceeded
Contact Formats
The Ominis Cluster Manager supports multiple contact formats:
| Format | Example | Use Case |
|---|---|---|
| E.164 Phone Number | +15551234567 | Standard PSTN dialing |
| Local Number | 5551234567 | Provider-specific routing |
| SIP URI | sip:user@domain.com | Direct SIP endpoint |
| FreeSWITCH Dial String | sofia/gateway/trunk/5551234567 | Advanced 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_concurrentlimit - ✅ 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 namecaller_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 Code | Error Code | Description |
|---|---|---|
| 400 | VALIDATION_ERROR | Invalid request parameters |
| 401 | UNAUTHORIZED | Missing or invalid API key |
| 500 | CAMPAIGN_CREATE_ERROR | Internal 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 contactspaused: Campaign temporarily suspendedstopped: Campaign permanently terminatedcompleted: 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
/resumeendpoint
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:
- Non-Blocking: API returns immediately after campaign creation
- Concurrency Control: Respects
max_concurrentlimit - Retry Logic: Automatically retries failed calls
- State Management: Updates campaign statistics in real-time
- 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
| Metric | Description | Updated |
|---|---|---|
calls_originated | Total calls successfully originated | Real-time |
calls_failed | Total calls that failed to originate | Real-time |
active_calls | Currently active calls | Real-time |
remaining_contacts | Contacts not yet dialed | Real-time |
max_concurrent | Concurrency limit | Static |
retry_attempts | Max retry attempts per contact | Static |
retry_delay | Delay 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/hourmax_concurrent=20, avg_call_duration=180s: ~400 calls/hourmax_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_failedincremented - 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:
-
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
-
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
-
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:
-
Failure Isolation: Campaign pod failures do not impact inbound call center operations. Inbound queues continue operating normally even if campaigns crash or exhaust resources.
-
Resource Guarantees: Queue pods have guaranteed resources for inbound operations. Campaigns cannot starve queue operations of CPU/memory.
-
Optimized Configuration: Campaign pod uses minimal FreeSWITCH modules (no mod_callcenter, simplified dialplan). This reduces startup time, memory footprint, and attack surface.
-
Independent Scaling: Campaign capacity can be scaled independently by adjusting campaign pod resources or deploying multiple campaign pods (future).
-
Clear Responsibilities: Single-purpose components are easier to understand, monitor, and troubleshoot. Campaign issues are clearly isolated to the campaign pod.
-
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:
-
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
-
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
-
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:
-
Simplicity: For MVP and initial deployments, in-memory storage provides the fastest path to a working solution with minimal dependencies.
-
Performance: Campaign operations (status queries, contact retrieval) have extremely low latency with in-memory access.
-
No Schema Changes: Avoids immediate database migrations, allowing campaign features to be deployed without database changes.
-
Deployment Flexibility: Works in any environment (Kubernetes, Docker, local dev) without additional infrastructure.
-
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:
-
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)
-
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:
-
Simplicity: XML-RPC is simpler to implement and maintain. Each originate command is a standalone HTTP request with no connection state.
-
Retry-Friendly: HTTP requests can be easily retried with standard HTTP client libraries. ESL requires custom reconnection logic.
-
Stateless: No persistent connection to manage. API can originate calls without maintaining open sockets to campaign pod.
-
Kubernetes-Native: HTTP health checks and service discovery work naturally. ESL requires custom health checking.
-
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.
-
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:
- Telephony Call Control: XML-RPC for Queue Operations
- IVR System: ESL Socket Handler for Interactive IVRs
Configuration Reference
Environment Variables
The campaign pod uses these environment variables (configured in config.py):
| Variable | Default | Description |
|---|---|---|
CAMPAIGN_XMLRPC_URL | http://freeswitch-campaign.client-demo-client.svc.cluster.local:8080/RPC2 | Campaign pod XML-RPC endpoint |
CAMPAIGN_XMLRPC_USERNAME | fsadmin | XML-RPC basic auth username |
CAMPAIGN_XMLRPC_PASSWORD | Winnipeg2025 | XML-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:
- Campaign paused: Check
statusfield, resume with/resumeendpoint - No contacts: Verify
remaining_contacts > 0 - Concurrency limit: Check
active_calls >= max_concurrent - Background task crashed: Check API logs for exceptions
- 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:
- SIP trunk not registered: Check gateway status and credentials
- Invalid contact format: Verify phone numbers are E.164 or valid SIP URIs
- Network issues: Check connectivity to SIP provider
- Provider rate limiting: Contact SIP provider for rate limits
- 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:
- OOM (Out of Memory): Increase memory limits in deployment
- CPU throttling: Increase CPU limits
- Configuration error: Check FreeSWITCH configuration validity
- 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:
- Avoid API pod restarts during active campaigns
- Pause campaigns before maintenance windows
- Export campaign data via status endpoint before restart
- 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:
-
Increase Concurrency:
{
"max_concurrent": 50
}- Higher concurrency = more simultaneous calls
- Limited by campaign pod resources and SIP trunk capacity
-
Reduce Call Timeout:
{
"timeout": 20
}- Lower timeout = faster failure detection
- Trade-off: May miss legitimate slow-answering contacts
-
Minimize Retry Attempts:
{
"retry_attempts": 1,
"retry_delay": 60
}- Fewer retries = faster campaign completion
- Trade-off: May miss contacts with temporary issues
-
Scale Campaign Pod Resources:
resources:
requests:
memory: "1Gi"
cpu: "1000m"
limits:
memory: "2Gi"
cpu: "2000m"
Reducing Latency
For faster campaign responsiveness:
-
Increase Background Task Frequency:
- Reduce sleep interval in
process_campaign()(default: 1 second) - Trade-off: Higher CPU usage
- Reduce sleep interval in
-
Optimize XML-RPC Connection:
- Keep XML-RPC client persistent (already implemented)
- Use connection pooling for multiple campaigns (future)
-
Co-locate Campaign Pod:
- Deploy campaign pod in same node as API pod (Kubernetes affinity)
- Reduces network latency for XML-RPC calls
Related Documentation
This campaign management guide connects to other parts of the Ominis Cluster Manager:
Core Infrastructure
- System Overview - Complete system architecture and component relationships
- Helm Infrastructure - Kubernetes deployment including campaign pod
Related API Categories
- Queue Management - Inbound call center operations (complementary to outbound campaigns)
- Telephony Call Control - XML-RPC client and call control operations used by campaigns
- Extension Management - SIP extension configuration for agent endpoints
Architectural Patterns
- Ports & Adapters Architecture - How campaign system follows clean architecture
- Database Schema - Planned campaign storage schema
- Testing Strategy - Campaign endpoint testing approach
Operations
- Cluster Infrastructure - Kubernetes cluster setup and management
- ACL Management - Network access control for campaign pod
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:
- Isolation is Key: Dedicated campaign pod prevents resource contention with inbound operations
- Progressive Dialing Works: Simple, predictable, and sufficient for most use cases
- Background Tasks Enable Scale: Async processing allows long-running campaigns without blocking
- In-Memory is Temporary: Current implementation prioritizes simplicity; migrate to database for production
- 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