Skip to main content

Progressive Dialer using APIs

Build a progressive dialer to enhance agent productivity by automatically connecting available agents to the next customer in the queue — reaching maximum customers in less time.

Tags: Contact Center v6, Auto Dialer

What You'll Build

A progressive dialer that:

  • Maintains a call queue and processes contacts sequentially
  • Checks agent availability before initiating each call
  • Uses the Connect Two Numbers API to bridge agent and customer
  • Handles call outcomes (answered, busy, no-answer, failed) with automatic retry logic
  • Tracks dialer performance metrics
┌─────────────────────────────────────────────────────┐
│ Progressive Dialer │
│ │
│ Queue: 150 remaining Dialed: 350 Retries: 42 │
│ │
│ ┌────────────┐ ┌──────────┐ ┌────────────┐ │
│ │ Queue │ │ Check │ │ Connect │ │
│ │ Contact │────►│ Agent │────►│ Two │ │
│ │ List │ │ Status │ │ Numbers │ │
│ └────────────┘ └──────────┘ └─────┬──────┘ │
│ │ │
│ ┌──────────┐ ┌─────▼──────┐ │
│ │ Retry │◄────│ Handle │ │
│ │ Queue │ │ Outcome │ │
│ └──────────┘ └────────────┘ │
└─────────────────────────────────────────────────────┘

Prerequisites

  • An Exotel account (Sign up here)
  • API credentials (API Key, API Token, Account SID) — see Authentication
  • At least one ExoPhone (virtual phone number)
  • Node.js 18+ or Python 3.7+
  • A publicly accessible URL for status callbacks
info

A progressive dialer only dials the next contact when an agent becomes available. This prevents abandoned calls (where a customer picks up but no agent is free) — unlike a predictive dialer which dials ahead based on estimated availability.

Architecture

┌────────────────────────────────────────────────────────────┐
│ Dialer Engine │
│ │
│ 1. Dequeue next contact from the call queue │
│ 2. Check if an agent is available (idle/ready) │
│ 3. Initiate call via Connect Two Numbers API │
│ - From = Agent phone number │
│ - To = Customer phone number │
│ - CallerId = ExoPhone │
│ 4. Exotel calls the agent first │
│ 5. When agent answers, Exotel dials the customer │
│ 6. Receive status callback with call outcome │
│ 7. On failure: add to retry queue with backoff │
│ 8. Repeat from step 1 │
└────────────────────────────────────────────────────────────┘

Step 1: Set Up the Call Queue

Create a queue manager that holds contacts to dial and supports retry logic.

const API_KEY = process.env.EXOTEL_API_KEY;
const API_TOKEN = process.env.EXOTEL_API_TOKEN;
const ACCOUNT_SID = process.env.EXOTEL_ACCOUNT_SID;
const SUBDOMAIN = process.env.EXOTEL_SUBDOMAIN || 'api.exotel.com';

const authHeader = 'Basic ' + Buffer.from(API_KEY + ':' + API_TOKEN).toString('base64');

class CallQueue {
constructor(options = {}) {
this.queue = [];
this.retryQueue = [];
this.completed = [];
this.maxRetries = options.maxRetries || 3;
this.retryDelayMs = options.retryDelayMs || 300000; // 5 minutes
}

// Load contacts into the queue
loadContacts(contacts) {
for (const contact of contacts) {
this.queue.push({
phoneNumber: contact.phoneNumber,
name: contact.name || '',
metadata: contact.metadata || {},
attempts: 0,
lastAttempt: null,
});
}
console.log(`Loaded ${contacts.length} contacts into queue`);
}

// Get the next contact to dial
getNext() {
// First, check retry queue for contacts ready to be retried
const now = Date.now();
const retryIndex = this.retryQueue.findIndex(
(c) => now - c.lastAttempt >= this.retryDelayMs,
);

if (retryIndex !== -1) {
return this.retryQueue.splice(retryIndex, 1)[0];
}

// Then, pull from the main queue
return this.queue.shift() || null;
}

// Handle call outcome
handleOutcome(contact, status) {
contact.attempts++;
contact.lastAttempt = Date.now();
contact.lastStatus = status;

if (status === 'completed') {
this.completed.push(contact);
return;
}

// Retry on busy, no-answer, or failed (up to max retries)
if (contact.attempts < this.maxRetries && ['busy', 'no-answer', 'failed'].includes(status)) {
this.retryQueue.push(contact);
console.log(`Retry queued: ${contact.phoneNumber} (attempt ${contact.attempts}/${this.maxRetries})`);
} else {
contact.finalStatus = status;
this.completed.push(contact);
}
}

// Get queue stats
getStats() {
return {
queued: this.queue.length,
retryPending: this.retryQueue.length,
completed: this.completed.length,
totalProcessed: this.completed.length,
};
}
}

Step 2: Check Agent Availability

Before dialing, verify that an agent is available. This can be done by maintaining an in-memory agent pool or integrating with the Contact Center API.

class AgentPool {
constructor(agents) {
// agents: [{ id, phoneNumber, name }]
this.agents = agents.map((agent) => ({
...agent,
status: 'available', // available, on_call, unavailable
currentCallSid: null,
}));
}

// Get the next available agent
getAvailable() {
return this.agents.find((a) => a.status === 'available') || null;
}

// Mark agent as busy (on a call)
markBusy(agentId, callSid) {
const agent = this.agents.find((a) => a.id === agentId);
if (agent) {
agent.status = 'on_call';
agent.currentCallSid = callSid;
}
}

// Mark agent as available again
markAvailable(agentId) {
const agent = this.agents.find((a) => a.id === agentId);
if (agent) {
agent.status = 'available';
agent.currentCallSid = null;
}
}

// Get pool status
getStatus() {
return {
total: this.agents.length,
available: this.agents.filter((a) => a.status === 'available').length,
onCall: this.agents.filter((a) => a.status === 'on_call').length,
unavailable: this.agents.filter((a) => a.status === 'unavailable').length,
};
}
}

// Initialize agent pool
const agentPool = new AgentPool([
{ id: 'agent-1', phoneNumber: '+919876500001', name: 'Agent A' },
{ id: 'agent-2', phoneNumber: '+919876500002', name: 'Agent B' },
{ id: 'agent-3', phoneNumber: '+919876500003', name: 'Agent C' },
]);
tip

For production systems, integrate with the Contact Center v6 API to get real-time agent presence (ready, busy, after-call-work, offline) rather than managing state locally. See the Contact Center Overview for details.

Step 3: Connect Agent to Customer

Use the Connect Two Numbers API to bridge the call. Exotel calls the agent first; once they answer, the customer is dialed.

const EXOPHONE = process.env.EXOTEL_EXOPHONE; // Your ExoPhone number

async function connectAgentToCustomer(agent, customer) {
const url = `https://${SUBDOMAIN}/v1/Accounts/${ACCOUNT_SID}/Calls/connect`;

const params = new URLSearchParams({
From: agent.phoneNumber, // Agent is called first
To: customer.phoneNumber, // Then customer is dialed
CallerId: EXOPHONE, // Your ExoPhone (visible to customer)
Record: 'true', // Record for quality monitoring
TimeOut: '30', // Ring timeout: 30 seconds
StatusCallback: 'https://your-server.com/webhook/dialer-callback',
StatusCallbackEvents: ['terminal', 'answered'],
StatusCallbackContentType: 'application/json',
CustomField: JSON.stringify({
agentId: agent.id,
customerPhone: customer.phoneNumber,
contactName: customer.name,
}),
});

const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': authHeader,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params.toString(),
});

if (!response.ok) {
throw new Error(`Call failed: ${response.status}`);
}

const data = await response.json();
return data.Call?.Sid;
}

Step 4: Handle Call Outcomes

Set up the status callback webhook to process call outcomes and feed results back into the dialer.

import express from 'express';

const app = express();
app.use(express.json());

// Track active calls: CallSid -> { agent, contact }
const activeCalls = new Map();

app.post('/webhook/dialer-callback', (req, res) => {
const { CallSid, Status, EventType, ConversationDuration, CustomField } = req.body;

// Parse the custom metadata
let meta = {};
try {
meta = JSON.parse(CustomField || '{}');
} catch (e) {
// Ignore parse errors
}

console.log(`[Dialer Callback] ${CallSid}: ${Status} (${EventType})`);

if (EventType === 'answered') {
// Call connected — agent and customer are talking
console.log(`Agent ${meta.agentId} connected to ${meta.customerPhone}`);
}

if (EventType === 'terminal') {
// Call ended — process the outcome
const callInfo = activeCalls.get(CallSid);

if (callInfo) {
// Mark agent as available
agentPool.markAvailable(callInfo.agent.id);

// Handle the call outcome in the queue
callQueue.handleOutcome(callInfo.contact, Status);

activeCalls.delete(CallSid);

console.log(
`Call complete: ${meta.customerPhone}${Status}, ` +
`duration: ${ConversationDuration || 0}s`,
);
}

// Trigger next call in the dialer loop
processNextCall();
}

res.status(200).json({ received: true });
});

Step 5: Build the Dialer Engine

Combine all components into the main dialer loop that continuously processes the queue.

const callQueue = new CallQueue({ maxRetries: 3, retryDelayMs: 300000 });

async function processNextCall() {
// Check if there is an available agent
const agent = agentPool.getAvailable();
if (!agent) {
console.log('[Dialer] No agents available, waiting...');
return;
}

// Get next contact from queue
const contact = callQueue.getNext();
if (!contact) {
console.log('[Dialer] Queue empty, dialer idle');
return;
}

try {
console.log(`[Dialer] Connecting ${agent.name} to ${contact.phoneNumber}`);

// Mark agent as busy
agentPool.markBusy(agent.id, null);

// Initiate the call
const callSid = await connectAgentToCustomer(agent, contact);

// Track the active call
activeCalls.set(callSid, { agent, contact });
agentPool.markBusy(agent.id, callSid);

console.log(`[Dialer] Call initiated: ${callSid}`);
} catch (error) {
console.error(`[Dialer] Call failed: ${error.message}`);

// Mark agent available again and retry the contact
agentPool.markAvailable(agent.id);
callQueue.handleOutcome(contact, 'failed');

// Try the next contact
processNextCall();
}
}

// Start the dialer
function startDialer(contacts) {
callQueue.loadContacts(contacts);

// Process calls for all available agents
const availableCount = agentPool.getStatus().available;
for (let i = 0; i < availableCount; i++) {
processNextCall();
}

// Periodic check for retry-eligible contacts
setInterval(() => {
const stats = callQueue.getStats();
const poolStatus = agentPool.getStatus();

console.log(
`[Dialer Stats] Queue: ${stats.queued} | Retries: ${stats.retryPending} | ` +
`Completed: ${stats.completed} | Agents available: ${poolStatus.available}`,
);

if (poolStatus.available > 0 && (stats.queued > 0 || stats.retryPending > 0)) {
processNextCall();
}
}, 10000); // Check every 10 seconds
}

// Usage
startDialer([
{ phoneNumber: '+919876543210', name: 'Customer A' },
{ phoneNumber: '+919876543211', name: 'Customer B' },
{ phoneNumber: '+919876543212', name: 'Customer C' },
// ... more contacts
]);

app.listen(3000, () => console.log('Progressive dialer running on :3000'));
warning

Rate limits apply. The default call capacity is 60 calls/minute. Ensure your dialer respects this limit. Add a delay between calls or implement a token-bucket rate limiter. Contact Exotel support to increase your capacity.

Dialer Metrics Example

{
"queue": {
"queued": 42,
"retryPending": 8,
"completed": 350
},
"agents": {
"total": 3,
"available": 1,
"onCall": 2,
"unavailable": 0
},
"performance": {
"totalDialed": 400,
"connected": 310,
"noAnswer": 52,
"busy": 23,
"failed": 15,
"connectRate": "77.5%",
"avgHandleTime": "3m 24s"
}
}

Next Steps