Skip to main content

Call Monitoring & Visualization

Build a plug-and-play reporting solution for monitoring sales, support, and campaign call performance with real-time data from Exotel APIs.

Tags: Data, Call Monitoring

What You'll Build

A call monitoring system that:

  • Fetches call details with date range, status, and direction filters
  • Parses call dispositions (completed, busy, no-answer, failed) for reporting
  • Generates chart-ready data: calls per agent, average handle time, call disposition breakdown
  • Receives real-time call status updates via webhooks
┌────────────────────────────────────────────────────────────┐
│ Call Performance Report — Jan 15, 2025 │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Completed │ │ No Answer │ │ Failed │ │
│ │ 1,024 │ │ 189 │ │ 34 │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ Agent Performance │ Avg Handle Time by Hour │
│ Agent A: 142 calls │ 9AM: 3m 12s │
│ Agent B: 128 calls │ 10AM: 2m 45s │
│ Agent C: 115 calls │ 11AM: 4m 03s │
└────────────────────────────────────────────────────────────┘

Prerequisites

  • An Exotel account (Sign up here)
  • API credentials (API Key, API Token, Account SID) — see Authentication
  • Node.js 18+ or Python 3.7+
  • A publicly accessible URL for status callback webhooks (use ngrok for development)

Step 1: Fetch Call Details with Filters

Query the Bulk Call Details API to retrieve call records for a specific date range. The API supports filtering by status, direction, duration, and phone number.

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');

async function fetchCallsByDateRange(startDate, endDate, filters = {}) {
const params = new URLSearchParams({
DateCreated: `gte:${startDate} 00:00:00;lte:${endDate} 23:59:59`,
PageSize: '100',
SortBy: 'DateCreated:desc',
});

// Apply optional filters
if (filters.status) params.set('Status', filters.status);
if (filters.direction) params.set('Direction', filters.direction);
if (filters.phoneNumber) params.set('PhoneNumber', filters.phoneNumber);
if (filters.minDuration) params.set('Duration', `gte:${filters.minDuration}s`);

const url = `https://${SUBDOMAIN}/v1/Accounts/${ACCOUNT_SID}/Calls.json?${params}`;

const response = await fetch(url, {
method: 'GET',
headers: { 'Authorization': authHeader },
});

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

return response.json();
}

// Paginate through all results
async function fetchAllCallsPaginated(startDate, endDate, filters = {}) {
let allCalls = [];
const params = new URLSearchParams({
DateCreated: `gte:${startDate} 00:00:00;lte:${endDate} 23:59:59`,
PageSize: '100',
});

if (filters.status) params.set('Status', filters.status);
if (filters.direction) params.set('Direction', filters.direction);

let url = `https://${SUBDOMAIN}/v1/Accounts/${ACCOUNT_SID}/Calls.json?${params}`;

while (url) {
const response = await fetch(url, {
method: 'GET',
headers: { 'Authorization': authHeader },
});
const data = await response.json();
allCalls = allCalls.concat(data.Calls || []);

url = data.Metadata?.NextPageUri
? `https://${SUBDOMAIN}${data.Metadata.NextPageUri}`
: null;
}

return allCalls;
}

Available Filters

ParameterExampleDescription
Statuscompletedcompleted, failed, busy, no-answer
Directioninboundinbound, outbound-dial, outbound-api
Durationgte:10s;lte:300sFilter by call duration range
PhoneNumber0XXXXXX4890Filter by ExoPhone number

Step 2: Parse Call Status for Reporting

Build a reporting layer that categorizes calls by disposition and computes key performance indicators (KPIs).

function generateCallReport(calls) {
const report = {
summary: { completed: 0, busy: 0, noAnswer: 0, failed: 0, total: 0 },
agentMetrics: {}, // Calls grouped by From number (agent)
hourlyBreakdown: {}, // Calls grouped by hour
durationBuckets: { short: 0, medium: 0, long: 0 },
};

for (const call of calls) {
report.summary.total++;

// Status distribution
switch (call.Status) {
case 'completed': report.summary.completed++; break;
case 'busy': report.summary.busy++; break;
case 'no-answer': report.summary.noAnswer++; break;
case 'failed': report.summary.failed++; break;
}

// Agent-level metrics (grouped by From number)
const agent = call.From;
if (!report.agentMetrics[agent]) {
report.agentMetrics[agent] = {
totalCalls: 0,
completed: 0,
totalDuration: 0,
};
}
report.agentMetrics[agent].totalCalls++;
if (call.Status === 'completed') {
report.agentMetrics[agent].completed++;
report.agentMetrics[agent].totalDuration += parseInt(call.Duration || '0', 10);
}

// Hourly breakdown
const hour = new Date(call.DateCreated).getHours();
const hourKey = `${hour.toString().padStart(2, '0')}:00`;
if (!report.hourlyBreakdown[hourKey]) {
report.hourlyBreakdown[hourKey] = { total: 0, completed: 0, totalDuration: 0 };
}
report.hourlyBreakdown[hourKey].total++;
if (call.Status === 'completed') {
report.hourlyBreakdown[hourKey].completed++;
report.hourlyBreakdown[hourKey].totalDuration += parseInt(call.Duration || '0', 10);
}

// Duration buckets (for completed calls)
if (call.Status === 'completed') {
const duration = parseInt(call.Duration || '0', 10);
if (duration < 60) report.durationBuckets.short++;
else if (duration < 300) report.durationBuckets.medium++;
else report.durationBuckets.long++;
}
}

// Compute average handle time per agent
for (const [agent, data] of Object.entries(report.agentMetrics)) {
data.avgHandleTime = data.completed > 0
? Math.round(data.totalDuration / data.completed)
: 0;
}

// Compute average handle time per hour
for (const [hour, data] of Object.entries(report.hourlyBreakdown)) {
data.avgHandleTime = data.completed > 0
? Math.round(data.totalDuration / data.completed)
: 0;
}

return report;
}

Step 3: Build Chart-Ready Data Structures

Transform the raw report into data structures that frontend charting libraries (Chart.js, Recharts, etc.) can consume directly.

function buildChartData(report) {
// Pie chart: Call disposition
const dispositionChart = {
labels: ['Completed', 'No Answer', 'Busy', 'Failed'],
datasets: [{
data: [
report.summary.completed,
report.summary.noAnswer,
report.summary.busy,
report.summary.failed,
],
backgroundColor: ['#22c55e', '#f59e0b', '#ef4444', '#6b7280'],
}],
};

// Bar chart: Calls per agent
const agents = Object.entries(report.agentMetrics)
.sort((a, b) => b[1].totalCalls - a[1].totalCalls);

const agentChart = {
labels: agents.map(([agent]) => agent),
datasets: [
{
label: 'Completed',
data: agents.map(([, data]) => data.completed),
backgroundColor: '#22c55e',
},
{
label: 'Other',
data: agents.map(([, data]) => data.totalCalls - data.completed),
backgroundColor: '#e5e7eb',
},
],
};

// Line chart: Average handle time by hour
const hours = Object.entries(report.hourlyBreakdown)
.sort(([a], [b]) => a.localeCompare(b));

const handleTimeChart = {
labels: hours.map(([hour]) => hour),
datasets: [{
label: 'Avg Handle Time (seconds)',
data: hours.map(([, data]) => data.avgHandleTime),
borderColor: '#3b82f6',
tension: 0.3,
}],
};

return { dispositionChart, agentChart, handleTimeChart };
}

Step 4: Set Up Status Callback Webhooks

For real-time monitoring, configure status callbacks when making calls. Exotel sends a POST request to your URL whenever a call status changes.

import express from 'express';

const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// Store for real-time call events
const recentCallEvents = [];

// Status callback webhook receiver
app.post('/webhook/call-status', (req, res) => {
const event = {
callSid: req.body.CallSid,
status: req.body.Status, // completed, failed, busy, no-answer
eventType: req.body.EventType, // terminal or answered
from: req.body.From,
to: req.body.To,
direction: req.body.Direction,
duration: req.body.ConversationDuration,
startTime: req.body.StartTime,
endTime: req.body.EndTime,
customField: req.body.CustomField,
receivedAt: new Date().toISOString(),
};

recentCallEvents.push(event);

// Keep only the last 1000 events in memory
if (recentCallEvents.length > 1000) {
recentCallEvents.shift();
}

console.log(`[Call ${event.eventType}] ${event.callSid}: ${event.status}`);
res.status(200).json({ received: true });
});

// Expose real-time events for dashboard
app.get('/api/live-events', (req, res) => {
const limit = parseInt(req.query.limit || '50', 10);
res.json(recentCallEvents.slice(-limit));
});

// When making calls, include the StatusCallback URL
async function makeCallWithCallback(from, to, callerId) {
const url = `https://${SUBDOMAIN}/v1/Accounts/${ACCOUNT_SID}/Calls/connect`;

const params = new URLSearchParams({
From: from,
To: to,
CallerId: callerId,
StatusCallback: 'https://your-server.com/webhook/call-status',
StatusCallbackEvents: ['terminal', 'answered'],
StatusCallbackContentType: 'application/json',
});

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

return response.json();
}

Status Callback Parameters

ParameterDescription
CallSidUnique call identifier
Statuscompleted, failed, busy, no-answer
EventTypeterminal or answered
ConversationDurationDuration both parties were connected (seconds)
Directioninbound, outbound-dial, outbound-api
CustomFieldCustom metadata from the original request
tip

Set StatusCallbackContentType to application/json for easier parsing. If not specified, Exotel defaults to multipart/form-data.

Step 5: Combine Into a Reporting API

Wire everything together into a complete reporting endpoint.

app.get('/api/report', async (req, res) => {
try {
const { start, end, direction, agent } = req.query;

if (!start || !end) {
return res.status(400).json({ error: 'start and end params required (YYYY-MM-DD)' });
}

const filters = {};
if (direction) filters.direction = direction;
if (agent) filters.phoneNumber = agent;

const calls = await fetchAllCallsPaginated(start, end, filters);
const report = generateCallReport(calls);
const charts = buildChartData(report);

res.json({
dateRange: { start, end },
summary: report.summary,
charts,
agentMetrics: report.agentMetrics,
hourlyBreakdown: report.hourlyBreakdown,
durationBuckets: report.durationBuckets,
generatedAt: new Date().toISOString(),
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
info

For production dashboards, cache the API responses and refresh on a schedule (e.g., every 5 minutes) rather than querying Exotel on every dashboard page load. This reduces API calls and improves response times.

Next Steps