During my years at Bravo Telecom and TeleFlip, I built VoIP systems that handled thousands of concurrent calls, integrated with legacy PBX systems, and provided real-time call analytics. Building telecom software taught me that voice communication has unique challenges that most web developers never encounter.
Here's everything I learned about building VoIP systems with Node.js.
Understanding VoIP Fundamentals
VoIP (Voice over IP) converts voice into data packets sent over IP networks. The core protocol is SIP (Session Initiation Protocol), which handles call setup, management, and teardown.
Key Components
- SIP Server: Handles call signaling (Asterisk, FreeSWITCH, Kamailio)
- Media Server: Processes audio streams (RTP/SRTP)
- Application Server: Business logic (Node.js)
- Database: CDRs (Call Detail Records), user data
The Call Flow
Caller -> SIP Client -> SIP Server -> Application Server (Node.js)
-> Routes call
-> Media Server (plays IVR, records)
-> Routes to Agent
Building a Node.js VoIP Application Server
We used Node.js to interface with Asterisk via AMI (Asterisk Manager Interface) and AGI (Asterisk Gateway Interface):
// infrastructure/asterisk/ami-client.ts
import { EventEmitter } from 'events';
import * as net from 'net';
export class AsteriskAMI extends EventEmitter {
private client: net.Socket;
private connected = false;
constructor(
private host: string,
private port: number,
private username: string,
private password: string
) {
super();
this.connect();
}
private connect(): void {
this.client = net.createConnection(this.port, this.host);
this.client.on('connect', () => {
this.login();
});
this.client.on('data', (data) => {
this.handleData(data.toString());
});
this.client.on('error', (error) => {
console.error('AMI connection error:', error);
this.reconnect();
});
}
private async login(): Promise<void> {
this.send({
Action: 'Login',
Username: this.username,
Secret: this.password,
});
}
private send(command: Record<string, string>): void {
const message =
Object.entries(command)
.map(([key, value]) => `${key}: ${value}`)
.join('\r\n') + '\r\n\r\n';
this.client.write(message);
}
async originate(
channel: string,
context: string,
exten: string,
priority: number,
callerID: string
): Promise<void> {
this.send({
Action: 'Originate',
Channel: channel,
Context: context,
Exten: exten,
Priority: priority.toString(),
CallerID: callerID,
});
}
async hangup(channel: string): Promise<void> {
this.send({
Action: 'Hangup',
Channel: channel,
});
}
private handleData(data: string): void {
const lines = data.split('\r\n');
const event: Record<string, string> = {};
for (const line of lines) {
const match = line.match(/^([^:]+):\s*(.*)$/);
if (match) {
event[match[1]] = match[2];
}
}
if (event.Event) {
this.emit(event.Event, event);
}
}
private reconnect(): void {
setTimeout(() => {
this.connect();
}, 5000);
}
}Implementing Click-to-Call
A common VoIP feature: user clicks a button on a web page, and their phone rings to connect them to a customer:
// application/use-cases/initiate-call.use-case.ts
@Injectable()
export class InitiateCallUseCase {
constructor(
private readonly ami: AsteriskAMI,
private readonly callRepository: CallRepository,
private readonly websocket: WebSocketService
) {}
async execute(
agentId: string,
customerPhone: string
): Promise<{ callId: string }> {
const agent = await this.agentRepository.findById(agentId);
if (!agent) {
throw new NotFoundException('Agent not found');
}
// Create call record
const call = Call.create({
agentId: agent.id,
customerPhone,
status: CallStatus.INITIATING,
direction: CallDirection.OUTBOUND,
});
await this.callRepository.save(call);
// Originate call through Asterisk
// This rings the agent's phone first
await this.ami.originate(
`SIP/${agent.extension}`, // Ring agent's phone
'outbound-calls', // Dialplan context
customerPhone, // When agent answers, dial customer
1, // Priority
customerPhone // Caller ID shown to agent
);
// Notify agent via WebSocket
this.websocket.sendToUser(agent.id, 'call-initiated', {
callId: call.id,
customerPhone,
});
return { callId: call.id };
}
}Client-side implementation:
const ClickToCallButton = ({ customerPhone }) => {
const [calling, setCalling] = useState(false);
const socket = useWebSocket();
useEffect(() => {
socket.on('call-initiated', ({ callId, customerPhone }) => {
toast.success(`Calling ${customerPhone}...`);
setCalling(true);
});
socket.on('call-answered', ({ callId }) => {
toast.success('Call connected');
setCalling(false);
});
socket.on('call-ended', ({ callId }) => {
setCalling(false);
});
}, [socket]);
const handleCall = async () => {
await api.post('/calls/initiate', { customerPhone });
};
return (
<button
onClick={handleCall}
disabled={calling}
className="btn btn-primary"
>
{calling ? 'Calling...' : `Call ${customerPhone}`}
</button>
);
};Building an IVR (Interactive Voice Response) System
IVR systems play messages and collect DTMF input (keypad presses):
// application/ivr/ivr-controller.ts
export class IVRController {
constructor(
private readonly agi: AGIServer,
private readonly ttsService: TextToSpeechService
) {
this.setupRoutes();
}
private setupRoutes(): void {
// Main menu
this.agi.on('main-menu', async (context) => {
const choice = await context.getOption(
'/var/lib/asterisk/sounds/main-menu.wav',
'123', // Accept digits 1, 2, or 3
5000 // 5 second timeout
);
switch (choice) {
case '1':
await context.goto('sales-queue');
break;
case '2':
await context.goto('support-queue');
break;
case '3':
await context.goto('billing-queue');
break;
default:
await context.goto('main-menu'); // Replay menu
}
});
// Sales queue
this.agi.on('sales-queue', async (context) => {
await context.say('Connecting you to sales');
// Get caller ID
const callerNumber = context.request.callerid;
// Look up customer
const customer = await this.customerRepository.findByPhone(
callerNumber
);
// Add to queue with priority
await context.queueCall('sales', {
priority: customer?.isPremium ? 10 : 1,
timeout: 300,
announcePosition: true,
});
});
}
}
// AGI Server implementation
export class AGIServer extends EventEmitter {
private server: net.Server;
constructor(port: number) {
super();
this.server = net.createServer(this.handleConnection.bind(this));
this.server.listen(port);
}
private handleConnection(socket: net.Socket): void {
const context = new AGIContext(socket);
context.on('ready', (request) => {
// Route based on extension
this.emit(request.extension, context);
});
}
}
class AGIContext extends EventEmitter {
private request: any = {};
constructor(private socket: net.Socket) {
super();
this.readRequest();
}
private async readRequest(): Promise<void> {
// Parse AGI environment variables
// Format: "agi_variable: value"
const readline = require('readline');
const rl = readline.createInterface({ input: this.socket });
for await (const line of rl) {
if (line === '') break; // Empty line marks end of request
const match = line.match(/^agi_(\w+):\s*(.*)$/);
if (match) {
this.request[match[1]] = match[2];
}
}
this.emit('ready', this.request);
}
async streamFile(filename: string): Promise<void> {
return this.sendCommand(`STREAM FILE ${filename} ""`);
}
async getOption(
filename: string,
digits: string,
timeout: number
): Promise<string> {
const response = await this.sendCommand(
`GET OPTION ${filename} "${digits}" ${timeout}`
);
// Parse response: "200 result=49" (49 is ASCII for '1')
const match = response.match(/result=(\d+)/);
if (match && match[1] !== '0') {
return String.fromCharCode(parseInt(match[1]));
}
return '';
}
async say(text: string): Promise<void> {
// Use TTS to generate audio
const audioFile = await this.ttsService.generate(text);
await this.streamFile(audioFile);
}
async queueCall(
queueName: string,
options: {
priority?: number;
timeout?: number;
announcePosition?: boolean;
}
): Promise<void> {
const opts = [
`priority=${options.priority || 0}`,
`timeout=${options.timeout || 300}`,
options.announcePosition ? 'announce-position=yes' : '',
].join(',');
await this.sendCommand(`EXEC Queue ${queueName},${opts}`);
}
private async sendCommand(command: string): Promise<string> {
return new Promise((resolve) => {
this.socket.write(command + '\n');
this.socket.once('data', (data) => {
resolve(data.toString());
});
});
}
}Call Recording and CDR Processing
Recording calls and processing call detail records:
// infrastructure/asterisk/cdr-processor.ts
@Injectable()
export class CDRProcessor {
constructor(
private readonly ami: AsteriskAMI,
private readonly callRepository: CallRepository,
private readonly analyticsService: AnalyticsService
) {
this.listenForCDRs();
}
private listenForCDRs(): void {
this.ami.on('Cdr', async (event) => {
await this.processCDR({
uniqueId: event.UniqueID,
source: event.Source,
destination: event.Destination,
callerID: event.CallerID,
startTime: new Date(event.StartTime),
answerTime: event.AnswerTime ? new Date(event.AnswerTime) : null,
endTime: new Date(event.EndTime),
duration: parseInt(event.Duration),
billableSeconds: parseInt(event.BillableSeconds),
disposition: event.Disposition, // ANSWERED, NO ANSWER, BUSY, FAILED
recordingFile: event.RecordingFile,
});
});
}
private async processCDR(cdr: CDRData): Promise<void> {
// Find associated call record
const call = await this.callRepository.findByUniqueId(cdr.uniqueId);
if (!call) {
console.warn(`No call found for CDR: ${cdr.uniqueId}`);
return;
}
// Update call with CDR data
call.setDuration(cdr.duration);
call.setDisposition(cdr.disposition);
if (cdr.recordingFile) {
call.setRecordingPath(cdr.recordingFile);
}
await this.callRepository.save(call);
// Process for analytics
await this.analyticsService.recordCall({
agentId: call.agentId,
customerId: call.customerId,
duration: cdr.duration,
disposition: cdr.disposition,
timestamp: cdr.startTime,
});
// If call was answered and recorded, queue for transcription
if (cdr.disposition === 'ANSWERED' && cdr.recordingFile) {
await this.queueTranscription(call.id, cdr.recordingFile);
}
}
private async queueTranscription(
callId: string,
recordingFile: string
): Promise<void> {
await this.queue.add('transcribe-call', {
callId,
recordingFile,
});
}
}Real-Time Call Monitoring
Display live call statistics on a dashboard:
// application/services/call-monitoring.service.ts
@Injectable()
export class CallMonitoringService {
private activeCalls: Map<string, ActiveCall> = new Map();
constructor(
private readonly ami: AsteriskAMI,
private readonly websocket: WebSocketService
) {
this.monitorCalls();
}
private monitorCalls(): void {
// Track new calls
this.ami.on('Newchannel', (event) => {
this.activeCalls.set(event.Channel, {
channel: event.Channel,
callerID: event.CallerIDNum,
state: 'ringing',
startTime: new Date(),
});
this.broadcastUpdate();
});
// Track answered calls
this.ami.on('Newstate', (event) => {
if (event.ChannelStateDesc === 'Up') {
const call = this.activeCalls.get(event.Channel);
if (call) {
call.state = 'active';
call.answerTime = new Date();
this.broadcastUpdate();
}
}
});
// Track ended calls
this.ami.on('Hangup', (event) => {
const call = this.activeCalls.get(event.Channel);
if (call) {
call.endTime = new Date();
call.duration = Math.floor(
(call.endTime.getTime() - call.startTime.getTime()) / 1000
);
this.activeCalls.delete(event.Channel);
this.broadcastUpdate();
}
});
}
private broadcastUpdate(): void {
const stats = {
activeCalls: this.activeCalls.size,
calls: Array.from(this.activeCalls.values()),
avgCallDuration: this.calculateAvgDuration(),
};
this.websocket.broadcast('call-stats', stats);
}
getActiveCallsForAgent(agentId: string): ActiveCall[] {
return Array.from(this.activeCalls.values()).filter(
(call) => call.agentId === agentId
);
}
private calculateAvgDuration(): number {
const durations = Array.from(this.activeCalls.values())
.filter((call) => call.answerTime)
.map((call) =>
Math.floor((Date.now() - call.answerTime!.getTime()) / 1000)
);
if (durations.length === 0) return 0;
return Math.floor(
durations.reduce((sum, d) => sum + d, 0) / durations.length
);
}
}Dashboard component:
const CallDashboard = () => {
const [stats, setStats] = useState({
activeCalls: 0,
calls: [],
avgCallDuration: 0,
});
useEffect(() => {
const socket = getWebSocket();
socket.on('call-stats', (data) => {
setStats(data);
});
return () => socket.off('call-stats');
}, []);
return (
<div className="dashboard">
<div className="stat-card">
<h3>Active Calls</h3>
<p className="text-4xl">{stats.activeCalls}</p>
</div>
<div className="stat-card">
<h3>Avg Duration</h3>
<p className="text-4xl">
{Math.floor(stats.avgCallDuration / 60)}:
{(stats.avgCallDuration % 60).toString().padStart(2, '0')}
</p>
</div>
<div className="calls-list">
{stats.calls.map((call) => (
<CallCard key={call.channel} call={call} />
))}
</div>
</div>
);
};Lessons Learned
Lesson 1: SIP is Complex
SIP has many edge cases—NAT traversal, codec negotiation, early media. Don't try to implement SIP from scratch; use proven servers like Asterisk.
Lesson 2: Asterisk is Powerful but Quirky
Asterisk is industry-standard but has a learning curve. AGI and AMI are callback-based and require careful error handling.
Lesson 3: Call Quality Depends on Networks
VoIP quality degrades with packet loss, latency, and jitter. Always test on real networks, not just localhost.
Lesson 4: CDRs are Gold
Call Detail Records are invaluable for analytics, billing, and compliance. Process them reliably.
Lesson 5: Real-Time Monitoring is Essential
Supervisors need live dashboards showing active calls, queue depths, and agent status.
When to Build vs. Buy
Build when:
- You need deep customization
- You're integrating with existing systems
- You have telecom expertise on the team
Buy (Twilio, etc.) when:
- You need basic calling/SMS
- You want fast time-to-market
- You don't have VoIP expertise
Conclusion
Building VoIP systems with Node.js and Asterisk gave us complete control over call flows, IVR systems, and integrations. We handled millions of calls reliably across Bravo Telecom and TeleFlip.
The key is understanding SIP fundamentals, using proven infrastructure like Asterisk, and building robust application logic in Node.js to orchestrate calls and process data.
After 27 years of building software, VoIP remains one of the most challenging but rewarding domains—when a customer calls and the system routes them perfectly, that's real-world impact.

Jason Cochran
Sofware Engineer | Cloud Consultant | Founder at Strataga
27 years of experience building enterprise software for oil & gas operators and startups. Specializing in SCADA systems, field data solutions, and AI-powered rapid development. Based in Midland, TX serving the Permian Basin.