Skip to main content

Business Monitoring Dashboard

Build a real-time dashboard using the Bulk Call Details API and Heartbeat API to monitor business performance, track ExoPhone health, and analyze call metrics.

Tags: Voice, Automation

What You'll Build

A monitoring dashboard that:

  • Receives real-time ExoPhone health alerts via Heartbeat webhooks
  • Fetches and aggregates call logs from the Bulk Call Details API
  • Tracks answered vs. missed calls, average duration, and peak hours
  • Exposes a simple Express.js endpoint for dashboard consumption
┌─────────────────────────────────────────────────┐
│ Monitoring Dashboard │
│ │
│ ExoPhone Health: ✓ OK Calls Today: 1,247 │
│ Answer Rate: 78.3% Avg Duration: 2m 14s │
│ Peak Hour: 11:00 AM Missed: 271 │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ Calls by Hour (bar chart) │ │
│ │ ████ ██████ █████████ ██████ ████ ██ │ │
│ └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘

Prerequisites

  • An Exotel account (Sign up here)
  • API credentials (API Key, API Token, Account SID) — see Authentication
  • An ExoPhone (virtual phone number)
  • Node.js 18+ (for native fetch) or Python 3.7+
  • A publicly accessible URL for receiving Heartbeat webhooks (use ngrok for development)

Architecture Overview

┌──────────────┐    Webhook POST     ┌──────────────────┐
│ Exotel │ ──────────────────► │ Your Server │
│ Heartbeat │ Health Status │ /webhook/health │
└──────────────┘ └────────┬─────────┘
│ Store
┌──────────────┐ GET /Calls ┌───────▼─────────┐
│ Exotel │ ◄────────────────── │ Dashboard │
│ Call Details │ ──────────────────► │ Aggregator │
│ API │ Call Records │ /api/metrics │
└──────────────┘ └──────────────────┘

Step 1: Set Up Heartbeat Webhooks

Configure a webhook endpoint to receive ExoPhone health status from the Heartbeat API. Navigate to Notifications Settings in your Exotel Dashboard to register your endpoint URL.

info

Heartbeat monitors all ExoPhones in your account. You cannot subscribe to individual ExoPhones. Your endpoint must return 200 OK to acknowledge receipt.

import express from 'express';

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

// In-memory store for health status (use Redis/DB in production)
let exophoneHealth = {
status: 'OK',
lastUpdated: null,
details: [],
};

// Heartbeat webhook receiver
app.post('/webhook/heartbeat', (req, res) => {
const payload = req.body;

exophoneHealth = {
status: payload.Status, // OK, WARNING, or CRITICAL
lastUpdated: new Date().toISOString(),
details: payload.ExoPhones || [],
};

console.log(`[Heartbeat] Status: ${payload.Status}`);

if (payload.Status === 'WARNING' || payload.Status === 'CRITICAL') {
console.warn(`[ALERT] ExoPhone health issue detected: ${payload.Status}`);
// Trigger alerts: Slack, PagerDuty, email, etc.
}

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

// Expose health status for the dashboard
app.get('/api/health', (req, res) => {
res.json(exophoneHealth);
});

app.listen(3000, () => console.log('Dashboard server running on :3000'));

The Heartbeat API sends payloads with these status values:

StatusMeaning
OKAll ExoPhones are healthy
WARNINGOne or more ExoPhones have issues
CRITICALAll ExoPhones are affected

Step 2: Fetch Bulk Call Details

Use the Bulk Call Details API to retrieve call records for a given date range. This data powers the dashboard metrics.

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 fetchCallDetails(startDate, endDate, pageSize = 100) {
const dateFilter = `gte:${startDate} 00:00:00;lte:${endDate} 23:59:59`;
const url = `https://${SUBDOMAIN}/v1/Accounts/${ACCOUNT_SID}/Calls.json`
+ `?DateCreated=${encodeURIComponent(dateFilter)}`
+ `&PageSize=${pageSize}`;

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

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

return response.json();
}

// Fetch all pages using cursor-based pagination
async function fetchAllCalls(startDate, endDate) {
let allCalls = [];
let url = `https://${SUBDOMAIN}/v1/Accounts/${ACCOUNT_SID}/Calls.json`
+ `?DateCreated=${encodeURIComponent(`gte:${startDate} 00:00:00;lte:${endDate} 23:59:59`)}`
+ `&PageSize=100`;

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

const data = await response.json();
allCalls = allCalls.concat(data.Calls || []);

// Use cursor-based pagination
url = data.Metadata?.NextPageUri
? `https://${SUBDOMAIN}${data.Metadata.NextPageUri}`
: null;
}

return allCalls;
}
warning

The Bulk Call Details API allows querying up to 6 months of historical data with a maximum 1-month range per request. Use cursor-based pagination (NextPageUri / PrevPageUri) for large result sets.

Step 3: Aggregate Call Metrics

Process the raw call data to compute dashboard metrics: answer rate, average duration, peak hours, and call status distribution.

function aggregateCallMetrics(calls) {
const metrics = {
total: calls.length,
answered: 0,
missed: 0,
busy: 0,
failed: 0,
totalDuration: 0,
hourlyDistribution: new Array(24).fill(0),
};

for (const call of calls) {
// Count by status
switch (call.Status) {
case 'completed':
metrics.answered++;
metrics.totalDuration += parseInt(call.Duration || '0', 10);
break;
case 'no-answer':
metrics.missed++;
break;
case 'busy':
metrics.busy++;
break;
case 'failed':
metrics.failed++;
break;
}

// Track hourly distribution
const hour = new Date(call.DateCreated).getHours();
metrics.hourlyDistribution[hour]++;
}

// Compute derived metrics
metrics.answerRate = metrics.total > 0
? ((metrics.answered / metrics.total) * 100).toFixed(1)
: '0.0';

metrics.avgDurationSeconds = metrics.answered > 0
? Math.round(metrics.totalDuration / metrics.answered)
: 0;

metrics.avgDurationFormatted = formatDuration(metrics.avgDurationSeconds);

metrics.peakHour = metrics.hourlyDistribution.indexOf(
Math.max(...metrics.hourlyDistribution)
);

return metrics;
}

function formatDuration(seconds) {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}m ${secs}s`;
}

Step 4: Build the Dashboard Endpoint

Combine health monitoring and call metrics into a single dashboard API.

import express from 'express';

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

let exophoneHealth = { status: 'OK', lastUpdated: null, details: [] };

// Heartbeat webhook (from Step 1)
app.post('/webhook/heartbeat', (req, res) => {
exophoneHealth = {
status: req.body.Status,
lastUpdated: new Date().toISOString(),
details: req.body.ExoPhones || [],
};
res.status(200).json({ received: true });
});

// Dashboard metrics endpoint
app.get('/api/dashboard', async (req, res) => {
try {
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD

const calls = await fetchAllCalls(today, today);
const metrics = aggregateCallMetrics(calls);

res.json({
health: exophoneHealth,
metrics: {
totalCalls: metrics.total,
answered: metrics.answered,
missed: metrics.missed,
busy: metrics.busy,
failed: metrics.failed,
answerRate: `${metrics.answerRate}%`,
avgDuration: metrics.avgDurationFormatted,
peakHour: `${metrics.peakHour}:00`,
hourlyDistribution: metrics.hourlyDistribution,
},
generatedAt: new Date().toISOString(),
});
} catch (error) {
console.error('Dashboard error:', error.message);
res.status(500).json({ error: 'Failed to generate dashboard data' });
}
});

// Daily summary with date range
app.get('/api/dashboard/range', async (req, res) => {
try {
const { start, end } = req.query;
if (!start || !end) {
return res.status(400).json({ error: 'start and end query params required (YYYY-MM-DD)' });
}

const calls = await fetchAllCalls(start, end);
const metrics = aggregateCallMetrics(calls);

res.json({ metrics, generatedAt: new Date().toISOString() });
} catch (error) {
res.status(500).json({ error: error.message });
}
});

app.listen(3000, () => console.log('Dashboard running on http://localhost:3000'));

Step 5: Filter by Direction and Phone Number

Enhance your dashboard by filtering calls by direction (inbound vs. outbound) and by specific ExoPhone numbers.

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

// Filter by direction: inbound, outbound-dial, outbound-api
if (options.direction) {
params.set('Direction', options.direction);
}

// Filter by ExoPhone number
if (options.phoneNumber) {
params.set('PhoneNumber', options.phoneNumber);
}

// Filter by call status
if (options.status) {
params.set('Status', options.status);
}

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

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

return response.json();
}

// Example: Get today's inbound completed calls for a specific ExoPhone
const inboundCalls = await fetchFilteredCalls('2025-01-15', '2025-01-15', {
direction: 'inbound',
status: 'completed',
phoneNumber: '0XXXXXX4890',
});
tip

Use the Direction filter to separate inbound and outbound call metrics on your dashboard. This helps identify whether missed calls are from customers trying to reach you (inbound) or failed outreach attempts (outbound).

Sample Dashboard Response

{
"health": {
"status": "OK",
"lastUpdated": "2025-01-15T10:30:00.000Z",
"details": []
},
"metrics": {
"totalCalls": 1247,
"answered": 976,
"missed": 189,
"busy": 52,
"failed": 30,
"answerRate": "78.3%",
"avgDuration": "2m 14s",
"peakHour": "11:00",
"hourlyDistribution": [12, 8, 3, 2, 5, 18, 45, 89, 112, 134, 145, 152, 128, 98, 87, 65, 54, 42, 28, 12, 5, 2, 1, 0]
},
"generatedAt": "2025-01-15T14:22:33.000Z"
}

Next Steps