← Back to Blog
VoIP Integration: Building Telecom Solutions with Node.js

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.

Share this article

Help others discover this content


Jason Cochran

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.