CX Voice Connector & Outbound Dialer — Complete Architecture Guide
Document Type: Comprehensive technical reference for the CX Voice Connector and Outbound Dialer services.
Scope: Complete working of both services, their REST/ESL/CIM interfaces, contact lifecycle, and component linkage.
Based on: Source code from ecx_generic_connector, outbound-dialer, and freeswitch-scripts production repositories.
---
Table of Contents
---
1. Executive Summary
This document describes the Voice Connector and Outbound Dialer — the two Java/Spring Boot services that bridge FreeSWITCH with the rest of the CX platform.
|
Component |
Purpose |
Technology |
|---|---|---|
|
Voice Connector ( |
Bridges FreeSWITCH ESL events to CCM CIM messages; handles inbound/outbound call routing, agent reservation, and call control |
Java/Spring Boot, FreeSWITCH ESL |
|
Outbound Dialer |
Campaign-based outbound calling with IVR-based and agent-based modes; dials contacts via FreeSWITCH |
Java/Spring Boot, PostgreSQL, FreeSWITCH ESL |
|
FreeSWITCH Scripts |
Lua dialplan scripts that execute when calls arrive, transfer, or hang up |
Lua, FreeSWITCH API |
High-Level Architecture
OUTBOUND DIALER
|
| POST /agent
v
+----------------+ +----------------------+ +------------------+
| Campaign | | Voice Connector | | FreeSWITCH |
| Manager | | (ecx_generic_) | | (Media Server) |
| (Upload List) | | | | |
+----------------+ | InboundController | | vcApi.lua |
| | /request-agent | | consult_conf.lua|
| POST /contact| /cancel-agent | | cx_hangup.lua |
v | /end-chat | | customTransfer |
+----------------+ | | | agent_amd |
| Outbound Dialer| | OutboundController | | |
| (Spring Boot) | | /ccm-msg/receive | +--------+---------+
| | | | |
| -- Contacts DB | +----------+-----------+ | SIP
| -- ESL originate| | |
| -- Agent resv | CIM Msg | REST |
+----------------+ v v
| +------+------+
+-------v--------+ | Customer |
| CCM | | / Agent |
| (Conv. Mgr) | +-------------+
+-------+--------+
|
+-------v--------+
| Routing Engine |
| (Task/Queue) |
+----------------+
---
2. Voice Connector Architecture
The Voice Connector (ecx_generic_connector) is a Java/Spring Boot service that acts as the bridge between FreeSWITCH and the CX platform. It receives HTTP calls from FreeSWITCH Lua scripts, converts them to CIM messages, and sends them to CCM. It also receives CIM messages back from CCM (via the Routing Engine) and translates them into ESL commands for FreeSWITCH.
2.1 Source Code Structure
ecx_generic_connector/src/main/java/com/ef/mregc/
|-- commons/Constants.java
|-- config/GlobalProperties.java
|-- controller/
| |-- InboundController.java # Receives from FreeSWITCH / Dialer
| |-- OutboundController.java # Receives from CCM
|-- dto/
| |-- AgentDetails.java # {agentExtension, uuid, queueName}
| |-- CallDetails.java # Full call metadata
| |-- ContactDto.java # Dialer contact
| |-- DeliveryNotificationDetails.java
| |-- EndChatDetails.java
| |-- Response.java
|-- model/ContactEntity.java
|-- services/
|-- VcService.java # Core logic
|-- VcServiceInterface.java
|-- VoicemailService.java
|-- RestUtil.java # HTTP client
2.2 InboundController — Receiving from FreeSWITCH
Endpoints:
|
Endpoint |
Method |
Purpose |
Caller |
|---|---|---|---|
|
|
|
New call arrived, request agent reservation |
FreeSWITCH ( |
|
|
|
Call ended before agent reserved |
FreeSWITCH ( |
|
|
|
Call result from dialer |
Outbound Dialer |
|
|
|
Channel session ended |
Outbound Dialer / FreeSWITCH |
2.2.1 /request-agent
@PostMapping("/request-agent")
public ResponseEntity<Object> sendAgentRequest(@RequestBody @Valid CallDetails requestBody) {
return vcService.requestAgent(requestBody);
}
VcService.requestAgent() logic:
-
Extract call details from
CallDetailsDTO -
Build CIM
ASSIGN_RESOURCE_REQUESTEDmessage -
Send POST to CCM
/message/receive
CIM Payload:
GenericMessage body = new GenericMessage("ASSIGN_RESOURCE_REQUESTED");
body.setAdditionalDetail("callId", id); // SIP Call-ID (inbound) or FS UUID (outbound)
body.setAdditionalDetail("direction", direction); // "INBOUND" or "OUTBOUND"
body.setAdditionalDetail("mode", "QUEUE"); // Always QUEUE
body.setAdditionalDetail("metadata", metaData); // {uuid, eslHost}
body.setAdditionalDetail("resource", resource); // {type: "NAME"/"ID", value: queueName}
body.setAdditionalDetail("priority", priority);
// Voicemail support (if enabled)
body.setAdditionalDetail("isVoiceMailEnable", true);
body.setAdditionalDetail("agent_extension", ext); // Extension case
body.setAdditionalDetail("DID_number", did); // DID case
```
Critical field: callId:
- For inbound calls: callId = callSipId (the SIP Call-ID header)
- For outbound calls: callId = callUid (the FreeSWITCH UUID)
2.2.2 /cancel-agent
private CimMessage createAgentRequestCancelPayload(CallDetails callDetails) {
GenericMessage body = new GenericMessage("CANCEL_RESOURCE_REQUESTED");
body.setAdditionalDetail("callId", id);
body.setAdditionalDetail("direction", direction);
body.setAdditionalDetail("requestType", "VOICE");
body.setAdditionalDetail("reasonCode", callDetails.getErrorCode());
return new CimMessage(header, body);
}
Fired when:
- Customer hangs up before agent is reserved
- RONA (agent didn't answer)
- Transfer failure
- Any pre-bridge termination
2.2.3 /send-delivery-notification
private CimMessage createDeliveryNotificationPayload(DeliveryNotificationDetails dto) {
DeliveryStatus status = reasonCode.equalsIgnoreCase("NORMAL_CLEARING")
? DeliveryStatus.CONNECTED : DeliveryStatus.FAILED;
DeliveryNotification deliveryNotification = new DeliveryNotification(id, status);
deliveryNotification.setReasonCode(reasonCode);
return new CimMessage(id, header, deliveryNotification);
}
```
From: Outbound Dialer (after call ends)
To: CCM (for activity logging and conversation closure)
2.3 OutboundController — Receiving from CCM
Endpoint: POST /ccm-msg/receive/cim-messages
This is the return path from CCM to Voice Connector. It receives CIM messages from the Routing Engine via CCM's outbound publisher.
@PostMapping("/ccm-msg/receive/cim-messages")
public ResponseEntity<Object> receiveCimMessage(@RequestBody @Valid CimMessage cimMessage) {
String type = cimMessage.getBody().getType();
if (type.equalsIgnoreCase("VOICE")) {
// CIM contact -- save to dialer database
return vcService.saveContact(new ContactDto(cimMessage));
}
if (!type.equalsIgnoreCase("NOTIFICATION")) {
return response(cimMessage.getId(), 200, "");
}
NotificationType notificationType =
((NotificationMessage) cimMessage.getBody()).getNotificationType();
// Extract agent details from AGENT_RESERVED
if (notificationType.equals(NotificationType.AGENT_RESERVED)) {
AgentReservedDto agentReservedDto = ...;
TaskMedia taskMedia = agentReservedDto.getTask().findMediaByState(TaskMediaState.RESERVED);
agentExtension = agentReservedDto.getCcUser().getKeycloakUser()
.getAttributes().get("agentExtension")[0];
taskType = taskMedia.getType();
queueName = taskMedia.getQueue().getName();
} else if (notificationType.equals(NotificationType.NO_AGENT_AVAILABLE)) {
taskType = ...getRequestType();
}
return vcService.routeAgent(
cimMessage.getId(),
(String) taskType.getMetadata().get("eslHost"),
cimMessage.getHeader().getChannelData().getChannelCustomerIdentifier(),
cimMessage.getHeader().getChannelData().getServiceIdentifier(),
taskType.getDirection().toString(),
(String) taskType.getMetadata().get("uuid"),
taskType.getMode().toString(),
agentExtension,
queueName
);
}
```
2.4 VcService.routeAgent() — Routing Logic
public ResponseEntity<Object> routeAgent(String messageId, String eslHost, String customerNumber,
String serviceId, String direction, String callUuid,
String mode, String agentExtension, String queueName) {
// Named agent consult/transfer -- ignore (handled by FreeSWITCH dialplan)
if ((direction.equalsIgnoreCase("DIRECT_TRANSFER") || direction.equalsIgnoreCase("CONSULT"))
&& mode.equalsIgnoreCase("AGENT")) {
return response(messageId, 200, "IGNORING NAMED AGENT CASE");
}
if ("OUTBOUND".equals(direction)) {
handleAgentForOutbound(agentExtension, callUuid, queueName, customerNumber, serviceId);
} else {
handleAgentForInbound(agentExtension, eslHost, callUuid, queueName, customerNumber, serviceId);
}
}
```
2.4.1 handleAgentForInbound
public void handleAgentForInbound(String agentExtension, String eslHost, String uuid,
String queueName, String customerId, String serviceIdentifier) {
// 1. Connect ESL to FreeSWITCH
if (!inboundClient.canSend()) {
inboundClient.connect(new InetSocketAddress(eslHost, eslPort), eslPass, 10);
}
if (agentExtension.isEmpty()) {
// No agent available -- play IVR and schedule hangup
sendSyncEslCommand("uuid_broadcast",
uuid + " /usr/share/freeswitch/sounds/ivr_prompts/no_agent_available.wav");
sendSyncEslCommand("sched_hangup", "+6 " + uuid + " SERVICE_UNAVAILABLE");
} else {
// Set queue headers for agent routing
sendSyncEslCommand("uuid_setvar_multi",
uuid + " sip_h_X-queueType=NAME;sip_h_X-queue='" + queueName + "'");
// Transfer customer call to agent extension
String response = sendSyncEslCommand("uuid_transfer",
uuid + " " + agentExtension + " XML " + domain);
if (response.contains("-ERR")) {
sendEndChat(new EndChatDetails(customerId, serviceIdentifier));
}
}
}
```
2.4.2 handleAgentForOutbound
public void handleAgentForOutbound(String agentExtension, String uuid, String queueName,
String customerId, String serviceIdentifier) {
if (agentExtension.isEmpty()) {
sendEndChat(new EndChatDetails(customerId, serviceIdentifier));
return;
}
// Send agent details to dialer API
int status = restUtil.sendRequest(
new RequestParams()
.setApiUrl(globalProperties.getDialerAgentApi())
.setRequestBody(new AgentDetails(agentExtension, uuid, queueName))
.setMethod("POST")
).statusCode();
switch (status) {
case 200: break;
case 503: // Database down
sendEndChat(endChatDetails);
throw new DialerConnectionException("DIALER DATABASE DOWN");
default:
sendEndChat(endChatDetails);
throw new DialerConnectionException("DIALER ERROR");
}
}
```
2.5 ESL Command Reference
|
ESL Command |
Usage |
Example |
|---|---|---|
|
|
Transfer call to extension/dialplan |
|
|
|
Play audio or execute app on channel |
|
|
|
Schedule hangup after N seconds |
|
|
|
Set multiple channel variables |
|
|
|
Create outbound call |
|
2.6 Authentication
if (globalProperties.isAuthEnabled()) {
String token = getCachedToken(domain);
if (token == null) {
token = requestNewToken();
cacheToken(domain, token);
}
params.setToken(token);
int status = restUtil.sendRequest(params).statusCode();
if (status == 401) {
removeCachedToken(domain);
String newToken = requestNewToken();
cacheToken(domain, newToken);
params.setToken(newToken);
status = restUtil.sendRequest(params).statusCode();
}
}
---
3. Outbound Dialer -- Complete Architecture & Contact Lifecycle
The Outbound Dialer is a Spring Boot service that manages campaign-based outbound calling. It stores contacts in a per-tenant PostgreSQL database, dials them via FreeSWITCH ESL originate commands, and coordinates with the Voice Connector and Routing Engine to connect answered calls to agents.
3.1 Architecture Overview
[Campaign Manager / Admin UI]
|
| Uploads contact list, configures campaign
v
[Outbound Dialer (Spring Boot)]
|
| -- Stores contacts in PostgreSQL
| -- Schedules dialing (cron job)
| -- Respects maxConcurrentCalls, callsPerSecond
v
[DialerService -- retrieveContacts()]
|
| For each tenant:
| 1. Get pending contacts
| 2. Check gateway status: sofia status gateway {gatewayId}
| 3. Build originate command
v
[FreeSWITCH -- originate command]
|
| Command format:
| {sip_h_X-Call-Id={id}, session_in_hangup_hook=true,
| domain_name={tenant}, record_session=true,
| sip_h_X-Destination-Number={serviceIdentifier},
| call_direction=outbound, origination_uuid={id}}
| sofia/gateway/{gatewayId}/{customerNumber}
| {ivrScript} XML {tenant}
v
[Customer answers]
|
v
[Voice Connector -- /request-agent]
|
| Sends ASSIGN_RESOURCE_REQUESTED to CCM
| Direction = "OUTBOUND"
v
[CCM -> Routing Engine]
|
| Finds available agent
v
[AGENT_RESERVED]
|
| Voice Connector -> Dialer API: POST /agent
| { agentExtension, uuid, queueName }
v
[DialerService -- dialAgent()]
|
| Builds originate command to connect agent to customer
| { ... sip_h_X-agentExtension={ext} ... }
| sofia/gateway/{gatewayId}/{customerNumber} agent XML {tenant}
v
[FreeSWITCH]
|
| Bridges agent and customer
3.2 Technology Stack
|
Component |
Technology |
Purpose |
|---|---|---|
|
Framework |
Spring Boot 3.x |
REST API, scheduling, JPA |
|
Database |
PostgreSQL |
Contact storage per tenant |
|
ORM |
Spring Data JPA |
|
|
ESL Client |
|
Inbound ESL connection to FreeSWITCH |
|
HTTP Client |
Spring |
Voice Connector API calls |
|
Mapping |
ModelMapper |
DTO <-> Entity conversion |
|
Scheduling |
Spring |
Contact retrieval, stale cleanup |
3.3 Database Schema -- contacts Table
CREATE TABLE contacts (
id VARCHAR PRIMARY KEY,
customer_number VARCHAR NOT NULL,
campaign_type VARCHAR,
ivr VARCHAR,
gateway_id VARCHAR NOT NULL,
status VARCHAR,
call_result VARCHAR,
received_time TIMESTAMP,
dial_time TIMESTAMP,
campaign_id VARCHAR,
campaign_contact_id VARCHAR,
start_time TIMESTAMP,
end_time TIMESTAMP,
priority INT DEFAULT 0,
dialing_mode VARCHAR,
routing_mode VARCHAR,
resource_id VARCHAR,
queue_name VARCHAR,
tenant_id VARCHAR NOT NULL,
scheduling_metadata JSONB
);
3.4 Contact Status Lifecycle
UPLOAD DIAL ANSWER AGENT RESERVED HANGUP
| | | | |
v v v v v
pending ------> dialed --------> (IVR playing) ----> agent_pending ---> ended
| | | | |
| | (Gateway DOWN) | (AMD = MACHINE) | (No agent) |
| v v v |
| pending ended (AMD) ended (NO_AGENT) |
| | |
| | (Gateway INVALID) |
| v |
| failed |
| |
|<--------------------------------------------------------------------|
| (10-min cleanup resets old dialed/agent_pending -> pending)
|
v
stopped <--- Campaign stop action
Status Definitions:
|
Status |
Meaning |
Who Sets It |
|---|---|---|
|
|
Contact ready to be dialed |
Default on upload; reset after failure/timeout/stop |
|
|
Originate command sent to FreeSWITCH |
|
|
|
Agent requested from Routing Engine; awaiting reservation |
|
|
|
Call completed (any hangup cause) |
|
|
|
Originate failed, gateway down/invalid, or unexpected error |
Exception handler in dial methods |
|
|
Campaign paused by admin |
|
3.5 How a Contact Is Uploaded
Endpoint: POST /contact
Request body (ContactDto):
|
Field |
Required |
Description |
|---|---|---|
|
|
Yes |
Call UUID (v4) |
|
|
Yes |
Phone number to dial |
|
|
Yes |
|
|
|
Yes |
FreeSWITCH gateway name |
|
|
Yes |
Campaign grouping ID |
|
|
No |
IVR script name (for IVR campaigns) |
|
|
No |
Target queue name (for agent campaigns) |
|
|
No |
e.g. |
|
|
No |
Queue routing mode |
|
|
No |
Queue priority (default 0) |
|
|
No |
JSON object with webhook URL, custom data |
saveContact() logic:
-
Check if contact with same
customer_numberalready exists AND status is NOTendedAND NOTfailed -
Map
ContactDto->ContactEntity, settenantIdfrom MDC,status="pending",receivedTime=now() -
Save to PostgreSQL
-
If
campaignType="IVR": calldialIvr(contact)immediately
Note: The retrieveContacts() scheduled polling method is currently commented out. Contacts are dialed immediately on upload, not batched.
3.6 How Failed Contacts Are Handled
The dialer handles failures at three levels before the call is bridged, plus post-originate hangup handling.
Level 1: Gateway Failure (Pre-Originate)
Before sending any originate command, the dialer checks the gateway status via ESL:
ESL: sofia status gateway {gatewayId}
Responses:
|
Response |
Action |
Status |
call_result |
|---|---|---|---|
|
|
Proceed to originate |
-- |
-- |
|
|
Abort; mark failed |
|
|
|
Gateway not found |
Abort; mark failed |
|
|
On gateway DOWN/INVALID:
- contactRepository.setCallResult(id, "failed", "GATEWAY DOWN", now())
- sendCallResults(id, "GATEWAY_DOWN", customerNumber, tenantId) -- sends delivery notification to Voice Connector
- No retry -- contact stays in failed permanently
Level 2: Originate Exception (During Dial)
If gateway is UP but the originate command fails:
try {
sendOriginateCommand(command);
currentCalls.incrementAndGet();
contactRepository.updateContactStatus(id, "dialed", now(), tenantId);
} catch (Exception e) {
contactRepository.updateContactStatus(id, "failed", now(), tenantId);
log.error("COULDN'T DIAL...", e);
}
Failure reasons:
- ESL connection dropped
- FreeSWITCH rejected the originate command
- Invalid originate syntax
- Database transaction timeout during status update
Result: Status = failed, call_result = null (not set). No retry.
Level 3: Call Hangup with Failure Cause (Post-Originate)
If originate succeeds but the call fails later (busy, no answer, rejected), FreeSWITCH fires a CHANNEL_DESTROY event:
String callResult = event.getEventHeaders().get("variable_hangup_cause");
// Special case: NORMAL_CLEARING but never answered = NO_ANSWER
if (callResult.equals("NORMAL_CLEARING")
&& event.getEventHeaders().get("Caller-Channel-Answered-Time").equals("0")) {
callResult = "NO_ANSWER";
}
contactRepository.setCallResult(id, "ended", callResult);
sendCallResults(id, callResult, customerNum, tenantId);
```
Important: Post-originate failures get status ended (not failed). The call_result field contains the actual failure reason.
Common hangup causes:
|
Cause |
Meaning |
Scenario |
|---|---|---|
|
|
Mapped to |
Phone rang, no pickup |
|
|
Normal completion |
Conversation ended naturally |
|
|
Rejected |
DND, blocked number |
|
|
Busy |
Customer on another call |
|
|
Invalid |
Disconnected/non-existent number |
|
|
Timeout |
Carrier/network issue |
|
|
Cancelled |
System cancelled before answer |
Level 4: AMD Failure (Answering Machine Detection)
For agent-based campaigns with AMD enabled, FreeSWITCH executes the amd application in the agent_amd dialplan. When AMD completes, it fires a GENERAL event with subclass amd::done:
private void amdResultHandler(EslEvent event) {
if (event.getEventHeaders().get("Event-Subclass").equalsIgnoreCase("amd::done")) {
String amdResult = event.getEventHeaders().get("variable_amd_result");
if (!amdResult.equalsIgnoreCase("HUMAN")) {
amdResult = "ANSWERING_MACHINE";
}
sendCallEventToWebhook(id, amdResult, tenantId);
}
}
AMD results:
- HUMAN -- Continue with agent request
- MACHINE_START / MACHINE_END / NOTSURE -- Treated as ANSWERING_MACHINE
Note: The current code does NOT automatically hang up on machine detection. It sends a webhook event to the external system (e.g., Conversation Studio) and lets it decide. The commented-out code shows a previous version that killed the call:
// if (!amdResult.equalsIgnoreCase("HUMAN")) {
// sendEfswitchApiCommand("uuid_kill", id);
// }
Failure Handling Summary Matrix
|
Failure Point |
Status |
call_result |
Retry? |
Recovery |
|---|---|---|---|---|
|
Duplicate active contact |
-- |
-- |
No |
Upload rejected (406) |
|
Gateway DOWN (pre-originate) |
|
|
No |
Manual re-upload |
|
Gateway INVALID (pre-originate) |
|
|
No |
Fix gateway, re-upload |
|
Originate exception |
|
null |
No |
Manual re-upload |
|
No answer (post-originate) |
|
|
No |
Manual re-upload |
|
Busy (post-originate) |
|
|
No |
Manual re-upload |
|
Rejected (post-originate) |
|
|
No |
Manual re-upload |
|
AMD = MACHINE |
|
|
No |
Webhook decides |
|
Agent unavailable |
|
|
No |
-- |
|
Stale timeout (10 min) |
|
reset |
Yes |
Auto-reset by scheduler |
Critical Finding: The dialer does NOT implement automatic retry for failed or ended contacts. Once a contact reaches failed or ended, it is permanently finished. The only automatic recovery is the 10-minute stale cleanup that resets dialed/agent_pending contacts back to pending.
3.7 Campaign Types -- Detailed Flow
IVR-Based Campaign
-
Contact uploaded with
campaignType="IVR" -
saveContact()->dialIvr(contact)immediately -
ESL originate command sent to FreeSWITCH
-
FreeSWITCH calls customer via gateway
-
Customer answers -> FreeSWITCH executes
{ivr}dialplan script -
Customer interacts with IVR (DTMF, prompts)
-
Call ends ->
CHANNEL_DESTROY->hangupCallHandler() -
Delivery notification sent to Voice Connector
IVR originate command fields:
|
Variable |
Purpose |
|---|---|
|
|
Links FS channel to dialer contact record |
|
|
Triggers cx_hangup.lua on hangup |
|
|
Tenant isolation in FreeSWITCH |
|
|
Enables call recording |
|
|
Service identifier for CCM routing |
|
|
Caller ID displayed to customer |
|
|
Marks as outbound call |
|
|
Same as contact ID for correlation |
Agent-Based Campaign with AMD
-
Contact uploaded with
campaignType="AGENT" -
saveContact()->dialAgentAmd(contact)immediately -
ESL originate command sent to FreeSWITCH
-
FreeSWITCH calls customer via gateway
-
Customer answers ->
agent_amddialplan runs AMD -
AMD result received via ESL
GENERALevent (amd::done) -
HUMAN -> Continue
-
MACHINE -> Send webhook, external decides
-
Dialer sends agent request to Voice Connector:
-
Voice Connector -> CCM -> Routing Engine
-
AGENT_RESERVED-> Voice Connector POSTs to Dialer/agent -
Dialer receives agent ->
dialAgent(agentDetails) -
ESL command to bridge agent to existing customer call
-
Agent and customer talk
-
Hangup ->
CHANNEL_DESTROY-> cleanup
Agent-based originate command fields (additional):
|
Variable |
Purpose |
|---|---|
|
|
Do not bridge until customer actually answers |
|
|
"OUTBOUND" or campaign-specific type |
|
|
Stored in FS for scripts to reference |
|
|
Secondary correlation ID |
3.8 Agent Connection Flow (After Reservation)
When POST /agent is received from Voice Connector:
String agentExt = agentDetails.agent(); // e.g. "201"
String uuid = agentDetails.uuid(); // Call UUID
String queue = agentDetails.queue(); // e.g. "SalesQueue"
// If no agent was actually reserved
if (agentExt.isEmpty()) {
decrementCallCounter(currentCalls);
contactRepository.setCallResult(uuid, "ended", "NO_AGENT_AVAILABLE");
return response("null", 200, ""); // Empty agent = no-op
}
// Retrieve the contact record
ContactEntity contact = contactRepository.getAgentPendingContact(uuid, tenantId);
// Check gateway again
String gatewayStatus = checkGatewayStatus(id, gatewayId, customerNumber, tenantId);
// Build originate command to connect agent
String command = "{ignore_early_media=true, session_in_hangup_hook=true,
sip_h_X-Destination-Number={serviceIdentifier},
sip_h_X-CallType={dialingMode}, customer_number={customerNumber},
sip_h_X-CALL-VARIABLE0={id}, sip_h_X-Call-Id={id},
domain_name={tenant}, record_session=true,
call_direction=outbound,
sip_h_X-queueType=NAME, sip_h_X-queue='{queue}',
origination_uuid={id},
origination_caller_id_name={agentExt},
origination_caller_id_number={agentExt},
sip_h_X-agentExtension={agentExt},
effective_caller_id_number={customerNumber}}
sofia/gateway/{gatewayId}/{customerNumber} agent XML {tenant}";
sendOriginateCommand(command);
contactRepository.updateContactStatus(contact.getId(), "dialed", now(), tenantId);
```
Key fields for agent connection:
|
Variable |
Purpose |
|---|---|
|
|
Queue routing by name (not ID) |
|
|
Target queue for the agent |
|
|
Agent extension for direct routing |
|
|
Show customer's number to agent |
|
|
Show agent's extension as caller ID |
3.9 Rate Limiting & Throttling
public void sendOriginateCommand(String command) {
CallConfigs callConfigs = settings.getTenantDialerConfigs().get(tenantId);
long millisecPerCall = 1000 / callConfigs.getCallsPerSecond();
if (lastOriginatedTime == -1) {
lastOriginatedTime = System.currentTimeMillis();
} else {
long currentTime = System.currentTimeMillis();
long diff = currentTime - lastOriginatedTime;
if (diff < millisecPerCall) {
Thread.sleep(millisecPerCall - diff); // Throttle
}
}
inboundClient.sendBackgroundApiCommand("originate", command);
lastOriginatedTime = System.currentTimeMillis();
}
Important: lastOriginatedTime is a static variable, meaning it is shared across all tenants. Tenant A's call could throttle Tenant B's call.
3.10 Concurrent Call Limits (Per Tenant)
AtomicInteger currentCalls = getTenantCounter(tenantId);
int freeCallSlots = callConfigs.getMaxConcurrentCalls() - currentCalls.get();
if (freeCallSlots <= 0) {
// Skip this tenant -- at capacity
return;
}
```
Counter management:
- Incremented: after successful sendOriginateCommand()
- Decremented: in hangupCallHandler() on CHANNEL_DESTROY
- Safety: decrementCallCounter() prevents negative values
3.11 Stale Contact Cleanup (10-Minute Scheduler)
@Scheduled(fixedDelay = 10, timeUnit = TimeUnit.MINUTES)
public void clearOldContactsForAllTenants() {
// For each tenant:
// 1. Find contacts with status='agent_pending' OR 'dialed'
// AND dial_time < (now - maxCallTime minutes)
// 2. Send END_CHAT for agent-based contacts
// 3. Reset status back to 'pending' (batch update, 100 at a time)
}
Why this exists: If FreeSWITCH crashes, ESL disconnects, or a webhook is lost, contacts can get stuck in dialed or agent_pending forever. This scheduler recovers them.
Cleanup queries:
```sql
-- Find stale contacts
SELECT id FROM contacts
WHERE (status='agent_pending' OR status='dialed')
AND dial_time < :cutoffTime
AND tenant_id = :tenantId;
-- Reset them
UPDATE contacts SET status = 'pending', dial_time = NOW()
WHERE id IN (:idList);
```
3.12 Campaign Management
Endpoints:
|
Endpoint |
Action |
SQL Effect |
|---|---|---|
|
|
Start campaign |
|
|
|
Stop campaign |
|
|
|
Delete pending contacts |
|
Campaign division for dialing:
```java
// retrieveContacts() divides free call slots equally across campaigns
// Example: 10 free slots, 3 campaigns -> 3+3+4 distribution
```
3.13 Webhooks & Call Progress Events
When AMD completes or other call events occur, the dialer can POST to a webhook URL stored in schedulingMetadata:
private void sendCallEventToWebhook(String id, String event, String tenantId) {
Optional<ContactEntity> contact = contactRepository.findById(id);
JSONObject schedulingMetadata = new JSONObject(contact.get().getSchedulingMetadata());
String webhookUrl = schedulingMetadata.get("callEventsWebhookUrl").toString();
CallProgressEvent requestBody = new CallProgressEvent(
id,
contact.get().getSchedulingMetadata(),
id, // messageId
event, // "HUMAN" or "ANSWERING_MACHINE"
dialerUrlForRequests
);
restUtil.sendPostRequest(webhookUrl, requestBody, tenantId);
}
CallProgressEvent payload:
```json
{
"id": "call-uuid",
"header": {
"schedulingMetaData": { }
},
"body": {
"type": "CALL_PROGRESS_EVENT",
"messageId": "call-uuid",
"event": "HUMAN",
"dialerUrlForRequests": "https://dialer.example.com"
}
}
```
3.14 Call Control Endpoints
Endpoint: POST /call/{id}/action
|
Action |
Effect |
ESL Command |
|---|---|---|
|
|
Transfer a specific call to an agent extension |
|
|
|
Forcefully end a call |
|
3.15 Multi-Tenancy Architecture
// Per-tenant configuration stored in-memory
Map<String, CallConfigs> tenantDialerConfigs = new HashMap<>();
// Set via webhook on tenant creation:
POST /webhooks/tenants/created
{
"tenantId": "acme-corp",
"tenantSettings": {
"dialer": {
"serviceIdentifier": "cx-voice-acme-01",
"maxConcurrentCalls": 50,
"maxCallTime": 30,
"callsPerSecond": 10
}
}
}
// Removed via:
POST /webhooks/tenants/deleted
{ "tenantId": "acme-corp" }
```
Tenant isolation:
- tenantId stored in MDC for all operations
- All DB queries include tenant_id filter
- Call counters are per-tenant (ConcurrentHashMap<String, AtomicInteger>)
- FreeSWITCH uses domain_name={tenantId} for dialplan/context isolation
3.16 ESL Event Subscriptions
@PostConstruct
@Scheduled(fixedDelayString = "${connectDelay}", timeUnit = TimeUnit.SECONDS)
public void connectEsl() {
inboundClient.connect(new InetSocketAddress(eslIp, eslPort), eslPass, 10);
inboundClient.setEventSubscriptions(
IModEslApi.EventFormat.PLAIN,
"CHANNEL_DESTROY GENERAL"
);
}
// Event handler
public void onEslEvent(Context ctx, EslEvent event) {
switch (event.getEventName()) {
case "CHANNEL_DESTROY":
hangupCallHandler(event); // Call ended
break;
case "GENERAL":
amdResultHandler(event); // AMD completed
break;
}
}
```
Subscribed events:
- CHANNEL_DESTROY -- Fired when any channel is destroyed (hangup). Contains variable_hangup_cause.
- GENERAL with subclass amd::done -- Fired when AMD analysis completes.
3.17 Delivery Notification to Voice Connector
After any call ends, the dialer sends results back:
private void sendCallResults(String id, String callResult, String customerNum, String tenantId) {
DeliveryNotificationDetails requestBody = new DeliveryNotificationDetails(
id, // Call UUID
callConfigs.getServiceIdentifier(),
customerNum,
callResult // e.g. "NORMAL_CLEARING", "NO_ANSWER"
);
// POST to Voice Connector /send-delivery-notification
restUtil.sendPostRequest(
voiceConnector + "/send-delivery-notification",
requestBody,
tenantId
);
}
Voice Connector receives:
```java
// VcService.sendDeliveryNotification()
// Creates CIM Message with DeliveryNotification body
// Status = CONNECTED (if NORMAL_CLEARING) or FAILED (otherwise)
```
3.18 End-to-End Contact Lifecycle Example
Day 1, 09:00 -- Campaign manager uploads 100 contacts via POST /contact
-> All contacts saved with status=pending
09:01 -- First contact is dialed immediately
-> ESL originate sent to FreeSWITCH
-> Status=dialed, dial_time=09:01
09:02 -- Customer answers
-> FreeSWITCH executes agent_amd dialplan
-> AMD detects HUMAN
-> Dialer sends agent request to Voice Connector
-> Status=agent_pending
09:03 -- Routing Engine reserves agent
-> Voice Connector POSTs agent details to Dialer /agent
-> Dialer originates agent leg
-> Status=dialed (again)
09:05 -- Agent and customer talk, then hang up
-> CHANNEL_DESTROY event
-> hangupCallHandler(): status=ended, call_result=NORMAL_CLEARING
-> Delivery notification sent
-> Call counter decremented
09:06 -- Second contact: Gateway DOWN
-> checkGatewayStatus(): returns "DOWN"
-> status=failed, call_result=GATEWAY DOWN
-> No retry. Contact stays failed.
09:10 -- Third contact: No answer
-> Originate succeeds
-> Customer does not pick up
-> CHANNEL_DESTROY: call_result=NO_ANSWER
-> status=ended
-> No retry. Contact stays ended.
09:30 -- Fourth contact gets stuck (ESL disconnects mid-call)
-> Status remains 'dialed' forever
-> 10-minute scheduler runs at 09:40
-> Finds stale contact (dial_time < now-30min)
-> Resets status=pending
-> Contact will be re-dialed on next retrieveContacts() run
3.19 Known Limitations & Observations
|
Issue |
Description |
Impact |
|---|---|---|
|
No automatic retry |
Failed/ended contacts are never re-dialed automatically |
Requires manual re-upload or external retry logic |
|
Shared throttle timer |
|
Tenant A can throttle Tenant B |
|
Immediate dial on upload |
|
High volume uploads may overwhelm |
|
retrieveContacts() disabled |
The scheduled polling is commented out |
Only upload-triggered dialing works |
|
No max retry count |
Stale cleanup resets indefinitely |
Same contact could be dialed many times |
|
No DNC (Do Not Call) check |
No built-in suppression list |
Must be handled externally |
|
No timezone awareness |
|
May dial at wrong times for global campaigns |
|
Single gateway per contact |
No gateway fallback |
If gateway fails, contact fails |
|
No call recording URL capture |
|
Recordings exist on FS but not tracked in dialer |
3.20 Summary: Failure Handling Decision Tree
Contact Upload
|
v
+-----------------+
| Gateway Check |--DOWN--> status=failed, call_result=GATEWAY DOWN
| (sofia status) | send delivery notification
+-----------------+
|
UP
v
+-----------------+
| Originate |--Exception--> status=failed, call_result=null
| (bgapi) | log error
+-----------------+
|
OK
v
+-----------------+
| Customer Answers|
| AMD runs |--MACHINE--> send webhook (external decides)
| | status stays dialed/agent_pending
+-----------------+
|
HUMAN
v
+-----------------+
| Agent Request |--Fail (no agent)--> status=ended, call_result=NO_AGENT_AVAILABLE
| to Voice Conn. |
+-----------------+
|
AGENT_RESERVED
v
+-----------------+
| Agent Answers |--Fail--> status=ended, call_result=hangup_cause
| Call Completes |
+-----------------+
|
OK
v
+-----------------+
| CHANNEL_DESTROY |--> status=ended, call_result=NORMAL_CLEARING
| or other cause | (or NO_ANSWER, USER_BUSY, etc.)
+-----------------+
|
v
Delivery Notification -> Voice Connector
---
4. Component Linkage & Integration
This section describes how the Voice Connector and Outbound Dialer interact with each other, with FreeSWITCH, and with the rest of the CX platform.
4.1 Linkage Overview
+------------------+
| Outbound Dialer |
| (Spring Boot) |
+--------+---------+
|
| POST /agent
| {agentExtension, uuid, queue}
v
+------------------+ +------------------+ +------------------+
| FreeSWITCH | | Voice Connector | | CCM |
| (Lua Scripts) | | (Spring Boot) | | (Conv. Manager) |
+------------------+ +--------+---------+ +------------------+
| | |
| HTTP POST | HTTP POST | ActiveMQ
| /request-agent | /message/receive |
| /cancel-agent | |
v v v
FreeSWITCH Voice Connector Routing Engine
originates creates CIM reserves agent
call messages
| ^ |
| CHANNEL_DESTROY | AGENT_RESERVED |
| (hangup cause) | |
+------------------------+------------------------+
|
| POST /ccm-msg/receive
| (from Routing Engine)
v
Voice Connector
routes to:
- Inbound: ESL uuid_transfer
- Outbound: POST /agent to Dialer
4.2 Data Flow Matrix
|
Step |
From |
To |
Protocol |
Data |
|---|---|---|---|---|
|
1 |
Campaign Manager |
Outbound Dialer |
HTTP POST |
Contact list with phone numbers, campaign config |
|
2 |
Outbound Dialer |
FreeSWITCH |
ESL |
|
|
3 |
FreeSWITCH |
Voice Connector |
HTTP POST |
|
|
4 |
Voice Connector |
CCM |
HTTP POST |
CIM |
|
5 |
CCM |
Routing Engine |
ActiveMQ JMS |
Task creation, queue assignment |
|
6 |
Routing Engine |
Voice Connector |
HTTP POST |
CIM |
|
7 |
Voice Connector |
Outbound Dialer |
HTTP POST |
|
|
8 |
Outbound Dialer |
FreeSWITCH |
ESL |
Bridge agent to customer call |
|
9 |
FreeSWITCH |
Outbound Dialer |
ESL |
Hangup cause |
|
10 |
Outbound Dialer |
Voice Connector |
HTTP POST |
|
|
11 |
Voice Connector |
CCM |
HTTP POST |
CIM |
4.3 Identifier Correlation Across Components
The same call is known by different identifiers at different layers:
|
Layer |
Identifier Name |
Example |
Source |
|---|---|---|---|
|
Dialer |
|
|
Generated by campaign manager on upload |
|
FreeSWITCH |
|
Same as contact.id |
Set in originate command variables |
|
FreeSWITCH |
Channel |
Same as contact.id |
FreeSWITCH assigns from origination_uuid |
|
Voice Connector |
|
Same as contact.id |
Received from FreeSWITCH in |
|
CCM |
|
Same as contact.id |
Set in CIM |
|
CCM |
|
Same as contact.id |
|
|
Agent Desk |
|
Different SIP dialog ID |
SIP.js generates per dialog |
|
Agent Desk |
|
|
Constructed in |
Critical: For outbound calls, the contact.id = FreeSWITCH uuid = CCM callId = channelSession.id. This is the correlation key that ties all systems together.
4.4 Failure Propagation
When something fails at one layer, how does it affect the others?
|
Failure Point |
Voice Connector Action |
Dialer Action |
CCM Action |
|---|---|---|---|
|
Gateway DOWN (pre-originate) |
N/A (dialer catches first) |
Status= |
Receives FAILED notification |
|
Originate exception |
N/A |
Status= |
No CIM sent |
|
No answer |
Receives |
Status= |
Receives |
|
Customer answers, no agent |
N/A |
Status= |
Task closed, conversation ended |
|
Customer answers, agent reserved |
Routes to |
Bridges agent to call |
Agent assigned to conversation |
|
Agent doesn't answer (RONA) |
Receives |
Re-queues or ends |
Task re-queued or cancelled |
|
Transfer fails |
Receives |
N/A |
Agent removed from conversation |
4.5 Tenant Isolation
Both services are multi-tenant:
Voice Connector:
- tenantId from MDC (set via HTTP header tenantid)
- FreeSWITCH domain_name={tenantId} for dialplan isolation
- Cached JWT tokens per tenant domain
Outbound Dialer:
- tenantId from MDC
- ContactEntity.tenantId in PostgreSQL
- Per-tenant CallConfigs: maxConcurrentCalls, callsPerSecond, maxCallTime, serviceIdentifier
- Per-tenant call counters: ConcurrentHashMap<String, AtomicInteger>
- Set via webhook: POST /webhooks/tenants/created
---
5. Complete End-to-End Flows
5.1 Inbound Call Flow (PSTN to Agent)
Customer (PSTN)
|
v
SIP Gateway -> FreeSWITCH (INVITE to destination_number)
|
v
FreeSWITCH dialplan matches -> executes vcApi.lua
|
v
vcApi.lua: request_agent(serviceIdentifier, "INBOUND", queue, queueType, ...)
|
v
HTTP POST -> Voice Connector /request-agent
|
v
VcService.requestAgent() -> createAgentRequestPayload()
|
v
HTTP POST -> CCM /message/receive
CIM intent: ASSIGN_RESOURCE_REQUESTED
body.callId = callSipId (SIP Call-ID)
body.direction = "INBOUND"
|
v
CCM MessageProcessor -> createChannelSession -> publish ChannelSessionStarted
-> publish FindAgentEvent to conversation topic
|
v
ActiveMQ -> Routing Engine (MRE)
Task created: PENDING -> QUEUED -> RESERVED
|
v
MRE -> AGENT_RESERVED notification
|
v
ActiveMQ -> CCM OutboundController
-> Voice Connector /ccm-msg/receive/cim-messages
|
v
VcService.routeAgent() -> handleAgentForInbound()
|
v
ESL: uuid_setvar_multi (queue headers)
ESL: uuid_transfer {uuid} {agentExtension} XML {domain}
|
v
FreeSWITCH bridges call to agent's SIP endpoint
|
v
Agent Desk Wrapper (SIP.js) receives INVITE
-> postMessage to Angular: dialogState="confirmed"
|
v
sip.service.ts handleCallActiveEvent()
-> Creates CALL_LEG_STARTED CIM message
-> POST to CCM /message/receive
|
v
CM CallLegStartedEvent -> assignAgentAction()
-> Agent assigned to conversation
-> Conversation state = ACTIVE
5.2 Outbound Campaign Call Flow (Dialer to Customer to Agent)
Campaign Manager
|
v
POST /contact -> Outbound Dialer
Contact saved: status="pending"
|
v
dialAgentAmd(contact)
|
v
ESL: originate {vars} sofia/gateway/{gw}/{customerNumber} agent_amd XML {tenant}
|
v
FreeSWITCH calls customer
|
v
Customer answers
-> agent_amd dialplan runs AMD
|
v
AMD = HUMAN
|
v
Dialer: sendAgentRequest(contact)
|
v
POST {voiceConnector}/request-agent
{
callingNumber: customerNumber,
callUid: id,
direction: "OUTBOUND",
serviceIdentifier: serviceIdentifier,
queue: queueName,
queueType: "NAME"
}
|
v
Voice Connector -> CCM
CIM: ASSIGN_RESOURCE_REQUESTED
callId = callUid (FS UUID, not SIP Call-ID)
direction = "OUTBOUND"
|
v
CCM -> Routing Engine -> AGENT_RESERVED
|
v
Voice Connector -> POST /agent to Outbound Dialer
{ agentExtension: "201", uuid: "...", queue: "SalesQueue" }
|
v
Dialer: dialAgent(agentDetails)
|
v
ESL: originate {vars} sofia/gateway/{gw}/{customerNumber} agent XML {tenant}
(bridges agent to existing customer call)
|
v
Agent answers, talks to customer
|
v
Hangup -> CHANNEL_DESTROY
-> hangupCallHandler(): status="ended", call_result=NORMAL_CLEARING
-> sendCallResults() -> Voice Connector /send-delivery-notification
-> Voice Connector -> CCM
5.3 Outbound Call Failure Flow (No Answer)
Outbound Dialer -> originate
|
v
FreeSWITCH calls customer
|
v
Customer does NOT answer
|
v
FreeSWITCH hangs up
-> CHANNEL_DESTROY event
-> variable_hangup_cause = "NORMAL_CLEARING"
-> Caller-Channel-Answered-Time = "0"
|
v
Dialer hangupCallHandler()
-> Detects: answered=0 + NORMAL_CLEARING
-> Maps to: callResult = "NO_ANSWER"
-> contactRepository.setCallResult(id, "ended", "NO_ANSWER")
-> sendCallResults(id, "NO_ANSWER", customerNum, tenantId)
-> decrementCallCounter()
|
v
Voice Connector /send-delivery-notification
-> Creates CIM DeliveryNotification
-> Status = FAILED (not NORMAL_CLEARING)
-> POST to CCM /message/receive
|
v
CCM -> Conversation ends
-> No agent was ever involved
-> VoiceActivity created with single leg (if any)
5.4 Gateway Failure Flow
Outbound Dialer -> dialAgentAmd()
|
v
checkGatewayStatus(id, gatewayId, customerNumber, tenantId)
-> ESL: sofia status gateway {gatewayId}
-> Response: "Status: DOWN"
|
v
contactRepository.setCallResult(id, "failed", "GATEWAY DOWN", now())
sendCallResults(id, "GATEWAY_DOWN", customerNumber, tenantId)
|
v
Voice Connector /send-delivery-notification
-> CIM: DeliveryNotification FAILED
-> Reason: GATEWAY_DOWN
|
v
CCM -> Conversation does NOT start
-> No ChannelSession created
-> No task created
-> Contact remains in dialer DB: status="failed", call_result="GATEWAY DOWN"
---
6. FreeSWITCH Script Reference
|
Script |
Trigger |
Purpose |
Key Actions |
|---|---|---|---|
|
vcApi.lua |
Inbound dialplan ( |
Request agent from Voice Connector |
|
|
vcApi.lua (RONA) |
Transfer failure ( |
Handle failed transfer, re-queue or end |
|
|
vcApi.lua (directTransfer) |
Queue transfer ( |
Transfer to another agent queue |
|
|
vcApi.lua (voicemail) |
Voicemail flow ( |
Record voicemail |
|
|
consult_conf.lua |
DTMF A or C |
Consult transfer or conference |
DB lookup, |
|
cx_hangup.lua |
|
Handle call hangup, cleanup |
|
|
customTransfer.lua |
External transfer dialplan |
Transfer to PSTN/external |
|
|
agent_amd |
Dialplan for agent-based campaigns |
AMD detection |
|
|
barge.lua |
Supervisor barge-in |
Join conference silently |
|
6.1 vcApi.lua Arguments
|
argv[1] |
Mode |
Description |
|---|---|---|
|
(empty) |
Inbound IVR |
Standard inbound call from PSTN |
|
|
WebRTC |
Call from browser-based customer |
|
|
RONA |
Transfer to agent failed (agent didn't answer) |
|
|
Queue Transfer |
Blind transfer to another queue |
|
|
Voicemail |
Leave voicemail for agent/DID |
6.2 consult_conf.lua Modes
|
argv[1] |
Action |
A1 Hangup Code |
|---|---|---|
|
|
Bridge C1 to A2, remove A1 |
|
|
|
Create conference A1+A2+C1 |
|
|
|
Transfer to external PSTN |
|
|
|
Conference with external PSTN |
|
---
7. Known Limitations
Voice Connector
|
Limitation |
Description |
Impact |
|---|---|---|
|
Single ESL client |
One |
Connection bottleneck under high load |
|
No retry on CCM failure |
503 or timeout -> contact stuck |
Requires manual intervention or stale cleanup |
|
No circuit breaker |
Gateway failures not tracked over time |
Repeated failures to same gateway |
Outbound Dialer
|
Limitation |
Description |
Impact |
|---|---|---|
|
No automatic retry |
Failed/ended contacts never re-dialed |
Manual re-upload or external retry required |
|
Immediate dial on upload |
|
High volume uploads may overwhelm |
|
|
Scheduled polling commented out |
Only upload-triggered dialing works |
|
No max retry count |
Stale cleanup resets indefinitely |
Same contact could be dialed many times |
|
No DNC check |
No built-in Do-Not-Call list |
Must be handled externally |
|
No timezone awareness |
|
May dial at wrong times globally |
|
Single gateway per contact |
No fallback gateway |
Gateway failure = contact failure |
|
No recording URL capture |
|
Recordings on FS but not tracked |
Integration Gaps
|
Gap |
Description |
|---|---|
|
No real-time sync between Dialer and Voice Connector contact states |
Dialer doesn't query Voice Connector for call state |
|
No shared contact state cache |
Both services maintain independent views |
|
Delivery notification is fire-and-forget |
No acknowledgment or retry from CCM |
|
Agent reservation timeout not coordinated |
Dialer stale cleanup (10 min) may conflict with MRE RONA timeout |
|
Shared throttle timer |
|