npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@cemscale-voip/voip-sdk

v2.6.0

Published

VoIP SDK for CemScale multi-tenant platform — API client, WebRTC, React hooks

Readme

@cemscale-voip/voip-sdk

TypeScript SDK for integrating CemScale VoIP into your application. Includes an HTTP/WebSocket API client, a WebRTC softphone, and React hooks.

Installation

npm install @cemscale-voip/voip-sdk

Authentication

Use an API Key to authenticate. Get one from the dashboard (API Keys page).

import { VoIPClient } from '@cemscale-voip/voip-sdk';

const voip = new VoIPClient({
  apiUrl: 'https://voip-api.cemscale.com',
  apiKey: 'csk_live_your_key_here',
});

// Ready. All methods work immediately.

That's it. No login, no passwords, no tokens to manage.

Examples

Click-to-call

// Call a phone number from extension 1001
const { callUuid } = await voip.originate({
  fromExtension: '1001',
  toNumber: '+15551234567',
});

// Control the call
await voip.holdCall(callUuid);
await voip.transfer(callUuid, { targetExtension: '1002', type: 'blind' });
await voip.hangup(callUuid);

Three-Way Calling (Conferencia Tripartita) — Conference-Based, 1-Step

The three-way calling system uses FreeSWITCH conferences. When you add a participant, both call legs are transferred into a conference immediately. Person A is put on hold (muted + deaf + hears MOH). Person B rings directly into the conference. No separate merge step is needed — the conference is active from the moment you call addParticipant().


RESUMEN del flujo

PASO 0: El agente está en llamada con Persona A. currentCall.state === 'established'.
PASO 1: El agente presiona "Agregar" → addParticipant('numero de Persona B').
        La API transfiere ambas patas a conferencia, pone A en hold (MOH),
        y origina llamada a B directamente dentro de la conferencia.
        threeWaySession.state → 'dialing'  ← MOSTRAR "Llamando a Persona B..."
PASO 2: Persona B contesta → entra automáticamente a la conferencia.
        El SDK detecta via polling → threeWaySession.state → 'holding'
        El agente habla con B, A sigue en hold (MOH).
PASO 3a: SWAP — El agente quiere hablar con A en privado.
        swapParticipant(keepUuid=A, holdUuid=B) → B va a hold (MOH), A vuelve.
PASO 3b: MERGE — El agente presiona "Conferencia" (todos hablan).
        mergeThreeWay() → Todos salen de hold, los 3 se escuchan.
PASO 3c: CANCEL — Persona B no contestó.
        cancelAddParticipant() → Mata a B, reanuda a A, conferencia 2 personas.
PASO 4:  KICK — Sacar a alguien específico.
        dropParticipant(memberUuid) → Esa persona sale de la conferencia.

FLOW 1 — useVoIP hook (WebRTC softphone, recomendado)

1.1 — Configuración inicial
import { useVoIP } from '@cemscale-voip/voip-sdk';

const {
  isRegistered,               // boolean — SIP registrado?
  currentCall,                // WebRTCCallInfo | null — llamada activa
  error,                      // string | null — último error
  startPhone,                 // (ext, password, displayName?) => Promise<void>
  call,                       // (number) => Promise<WebRTCCallInfo>
  hangup,                     // () => Promise<void>
  toggleMute,                 // () => boolean

  // Three-way call
  addParticipant,             // (target: string) => Promise<void>
  mergeThreeWay,              // () => Promise<void>  — unmute/undeaf todos
  swapParticipant,            // (keepUuid, holdUuid) => Promise<void>
  dropParticipant,            // (memberUuid: string) => Promise<void>
  cancelAddParticipant,       // () => Promise<void>  — B no contestó
  endThreeWay,                // () => Promise<void>

  threeWaySession,            // ThreeWaySession | null
} = useVoIP({
  apiUrl: 'https://voip-api.cemscale.com',
  apiKey: 'csk_live_...',
});
1.2 — ThreeWaySession y ThreeWayParticipant
interface ThreeWaySession {
  conferenceName: string;       // "3way_<tenantId>_<random>"
  state: 'dialing' | 'holding' | 'active' | 'swapping';
  participants: ThreeWayParticipant[];
  pendingUuid?: string;        // UUID de Persona B mientras suena
  heldMemberUuid?: string;     // UUID del participante en hold
  originalBridgedLeg?: string;
}

interface ThreeWayParticipant {
  uuid: string;                // UUID del canal FreeSWITCH
  memberId: string;            // ID de miembro en la conferencia
  callerIdNumber: string;      // "+17865551234" o "1002"
  callerIdName: string;        // "John Smith"
  muted: boolean;              // true si está muteado
  deaf: boolean;               // true si está ensordecido
  status: 'active' | 'held';   // 'held' = muted + deaf + MOH
}
1.3 — Flujo COMPLETO paso a paso
// ═══════════════════════════════════════════════════════════
// PASO 0: Agente en llamada con Persona A
// ═══════════════════════════════════════════════════════════
// currentCall.state === 'established'  ← condición para botón "Agregar"
// threeWaySession === null

// ═══════════════════════════════════════════════════════════
// PASO 1: Agregar Persona B
// ═══════════════════════════════════════════════════════════
await addParticipant('+17866302522');
// La API:
//   1. Transfiere WebRTC + Persona A a conferencia (uuid_transfer -both)
//   2. Mutea + ensordece a Persona A + le pone MOH (uuid_displace)
//   3. Origina llamada a Persona B directamente a la conferencia
//   4. Retorna inmediatamente

// DESPUÉS:
// threeWaySession.state === 'dialing'     ← Persona B está sonando
// threeWaySession.conferenceName !== ''   ← Conferencia YA existe
// ❌ NO iniciar timer — B no ha contestado

// ═══════════════════════════════════════════════════════════
// PASO 2: Persona B contesta (detectado por polling automático)
// ═══════════════════════════════════════════════════════════
// El hook hace polling de getConferenceMembers() cada 2s.
// Cuando detecta que B entró a la conferencia:
// threeWaySession.state → 'holding'
// threeWaySession.participants → [agente(active), A(held), B(active)]
// El agente habla con B. A escucha MOH.

// ═══════════════════════════════════════════════════════════
// PASO 3a: SWAP — Hablar con A, poner B en hold
// ═══════════════════════════════════════════════════════════
await swapParticipant(personA.uuid, personB.uuid);
// B → muted + deaf + MOH
// A → unmuted + undeaf + stop MOH
// threeWaySession.state → 'holding'

// ═══════════════════════════════════════════════════════════
// PASO 3b: MERGE — Todos hablan (conferencia tripartita)
// ═══════════════════════════════════════════════════════════
await mergeThreeWay();
// Desmutea + undeaf a TODOS los participantes en hold
// threeWaySession.state → 'active'
// ✅ AHORA sí iniciar timer de conferencia

// ═══════════════════════════════════════════════════════════
// PASO 3c: CANCEL — B no contestó
// ═══════════════════════════════════════════════════════════
await cancelAddParticipant();
// Mata canal de B, desmutea + undeaf a A
// Conferencia sigue con 2 personas (agente + A)

// ═══════════════════════════════════════════════════════════
// PASO 4: KICK — Sacar a alguien de la conferencia
// ═══════════════════════════════════════════════════════════
await dropParticipant(personB.uuid);
// B sale de la conferencia (su canal cuelga)
// Si solo queda 1 participante, threeWaySession → null
1.4 — Qué mostrar en la UI en CADA estado
// ── SIN LLAMADA ──
if (!currentCall) {
  return <Dialpad onCall={call} />;
}

// ── LLAMADA ACTIVA, SIN TRIPARTITA ──
if (currentCall && !threeWaySession) {
  return (
    <InCallScreen>
      <CallTimer startTime={currentCall.answerTime} />
      <RemoteParty name={currentCall.remoteDisplayName} number={currentCall.remoteIdentity} />
      <MuteButton active={currentCall.muted} onPress={toggleMute} />
      <HangupButton onPress={hangup} />
      <AddParticipantButton onPress={() => showAddParticipantInput()} />
    </InCallScreen>
  );
}

// ── DIALING — Persona B está sonando ──
if (threeWaySession?.state === 'dialing') {
  return (
    <InCallScreen>
      <CallingBanner>
        <Spinner />
        <Text>Llamando a Persona B...</Text>
      </CallingBanner>
      <CancelButton onPress={cancelAddParticipant} />
      <HangupButton onPress={hangup} />
    </InCallScreen>
  );
}

// ── HOLDING — Un participante en hold, otro activo ──
if (threeWaySession?.state === 'holding' || threeWaySession?.state === 'swapping') {
  return (
    <InCallScreen>
      {threeWaySession.participants.map(p => (
        <ParticipantRow key={p.uuid}>
          <ParticipantName name={p.callerIdName} number={p.callerIdNumber} />
          <ParticipantStatus status={p.status} />  {/* 'held' o 'active' */}
          {p.status === 'held' && (
            <UnholdButton onPress={() => {
              const other = threeWaySession.participants.find(x => x.uuid !== p.uuid && x.status === 'active');
              if (other) swapParticipant(p.uuid, other.uuid);
            }} />
          )}
        </ParticipantRow>
      ))}
      <MergeButton onPress={mergeThreeWay} label="Conferencia (3)" />
      <HangupButton onPress={hangup} />
    </InCallScreen>
  );
}

// ── ACTIVE — Conferencia tripartita (todos hablan) ──
if (threeWaySession?.state === 'active') {
  return (
    <ConferenceScreen>
      <ConferenceTimer startTime={conferenceStartTime} />
      {threeWaySession.participants.map(p => (
        <ParticipantRow key={p.uuid}>
          <ParticipantName name={p.callerIdName} number={p.callerIdNumber} />
          <DropButton onPress={() => dropParticipant(p.uuid)} />
        </ParticipantRow>
      ))}
      <EndConferenceButton onPress={hangup} />
    </ConferenceScreen>
  );
}
1.5 — Errores comunes

| Error | Causa | Solución | |-------|-------|----------| | "FreeSWITCH UUID not resolved yet" | Llamaste addParticipant antes de que la llamada conecte | Espera currentCall.state === 'established' y fsChannelUuid !== null | | "No active call" | currentCall es null | Verifica que haya una llamada activa | | Timer se inicia antes de contestar | Iniciaste timer en state='dialing' | Solo iniciar timer cuando state === 'active' | | addParticipant lanza error 500 | El backend no pudo originar a Persona B | La llamada con A se reanuda automáticamente |


FLOW 2 — VoIPClient directo (sin hook)

import { VoIPClient } from '@cemscale-voip/voip-sdk';

const voip = new VoIPClient({ apiUrl: '...', apiKey: '...' });

// PASO 1: Agregar participante (conferencia se crea inmediatamente)
const result = await voip.addCallParticipant(fsUuid, {
  toNumber: '+17866302522',
});

// result = {
//   conferenceName: "3way_<tenantId>_<hex>",
//   state: "dialing",
//   participants: [
//     { uuid: "webrtc-uuid", callerIdNumber: "1001", status: "active", ... },
//     { uuid: "personA-uuid", callerIdNumber: "+1786...", status: "held", muted: true, deaf: true },
//   ],
//   newCallUuid: "personB-uuid",
//   heldMemberUuid: "personA-uuid",
//   originalBridgedLeg: "personA-uuid",
// }

// PASO 2: Detectar cuando B contesta (polling)
const pollInterval = setInterval(async () => {
  const { conference } = await voip.getConferenceMembers(result.conferenceName);
  const activeCount = conference.members.filter(m => !m.muted || !m.deaf).length;
  if (activeCount >= 2) {
    clearInterval(pollInterval);
    // B contestó — ahora puedes swap/merge
  }
}, 2000);

// PASO 3a: Swap (hablar con A, poner B en hold)
await voip.swapCallParticipant({
  conferenceName: result.conferenceName,
  keepUuid: personA_uuid,   // persona con quien hablar
  holdUuid: personB_uuid,   // persona que va a hold
});

// PASO 3b: Merge (todos hablan)
await voip.mergeThreeWay({
  conferenceName: result.conferenceName,
});

// PASO 3c: Cancel (B no contestó)
await voip.cancelAddParticipant({
  conferenceName: result.conferenceName,
  newCallUuid: result.newCallUuid,
  heldMemberUuid: result.heldMemberUuid,
});

// PASO 4: Kick (sacar a alguien)
await voip.kickParticipant({
  conferenceName: result.conferenceName,
  memberUuid: personB_uuid,
});

MÁQUINA DE ESTADOS de threeWaySession

null ──addParticipant()──> { state: 'dialing', pendingUuid: '...' }
   │                                   │
   │                          B contesta (polling automático)
   │                                   │
   │                                   ▼
   │                          { state: 'holding', participants: [...] }
   │                              │             │            │
   │                     swapParticipant()  mergeThreeWay()  dropParticipant()
   │                              │             │            │
   │                              ▼             ▼            │
   │                     { state: 'holding' }  { state: 'active' }
   │                              │             │
   │                     swapParticipant()      dropParticipant() (≤1 left)
   │                              │             │
   │                              ▼             ▼
   │                     { state: 'holding' }   null
   │
   │──cancelAddParticipant()──> null (B killed, A unheld, 2-person conference)
   │
   │──currentCall === null──> null (cleanup automático)

Métodos de tres vías — Referencia completa

| Método | Parámetros | Qué hace | |--------|-----------|----------| | addCallParticipant(fsUuid, { toNumber?, toExtension? }) | UUID de FS, destino | Transfiere ambas patas a conferencia, hold A, originate B | | cancelAddParticipant({ conferenceName, newCallUuid, heldMemberUuid }) | Datos de addCallParticipant | Mata B, desmutea A, conferencia 2 personas | | swapCallParticipant({ conferenceName, keepUuid, holdUuid }) | Conference, UUIDs | Mute+deaf+MOH holdUuid, unmute+undeaf keepUuid | | mergeThreeWay({ conferenceName }) | Conference | Desmutea+undeaf TODOS los en hold | | kickParticipant({ conferenceName, memberUuid }) | Conference, UUID | Saca participante de conferencia (cuelga) | | getConferenceMembers(conferenceName) | Conference | Polling: miembros actuales de la conferencia |

Deprecated methods

| Método | Reemplazo | |--------|-----------| | mergeCalls(params) | mergeThreeWay({ conferenceName }) | | mergeDirectCalls(params) | addCallParticipant() + mergeThreeWay() |

Extensions

const { extensions } = await voip.listExtensions();
const { extension } = await voip.getExtension('ext-id');
await voip.createExtension({ extension: '1004', password: 'SecurePass1!', displayName: 'Dave' });

CRM User Mapping

// Link a CRM user to a VoIP extension
await voip.updateCrmMapping('ext-id', {
  crmUserId: 'crm-user-123',
  crmMetadata: { department: 'Sales' },
});

// Look up extension by CRM user (click-to-call from contact page)
const { extension } = await voip.getExtensionByCrmUser('crm-user-123');
await voip.originate({ fromExtension: extension.extension, toNumber: '+15551234567' });

Real-Time Events (WebSocket)

voip.connectWebSocket();

voip.onWsEvent('call_start', (event) => {
  console.log('New call:', event.data);
});

voip.onWsEvent('presence_change', (event) => {
  console.log(event.data.extension, event.data.status);
});

voip.onWsEvent('call_end', (event) => {
  // Log to CRM
  logCallToCRM(event.data);
});

Call Forwarding & CRM Integration

// List only forwarded calls (calls that hit an extension and were diverted)
const { calls } = await voip.listCalls({ forwarded: 'true' });
for (const call of calls) {
  console.log(`${call.caller_id_number} -> ext ${call.destination} -> FWD ${call.forwarded_to}`);
  // forwarded_to: the external/internal number the call was forwarded to (null if not forwarded)
  // audio_url: recording URL (forwarded calls are now recorded end-to-end)
  if (call.audio_url) {
    const playUrl = voip.getRecordingAudioUrl(call.id);
    console.log(`Recording: ${playUrl}`);
  }
}

// Search by the external number calls were forwarded to
const { calls: fwdCalls } = await voip.listCalls({ forwardedTo: '+1305' });

// Get a single call with forwarding info + recording
const { call } = await voip.getCall('call-uuid');
if (call.forwarded_to) {
  logForwardedCall(call.caller_id_number, call.destination, call.forwarded_to);
  // Play the recording of the forwarded conversation:
  const audioUrl = voip.getRecordingAudioUrl(call.id);
  // => "https://api.example.com/api/recordings/<id>/audio?apiKey=..."
}

Call Monitoring (Eavesdrop)

Listen in on live calls for quality assurance or supervisor coaching.

// Start monitoring a live call from extension 1001
const result = await voip.eavesdropCall('target-call-uuid', '1001');
// Admin extension 1001 receives an incoming call → answers → hears the target call

// Start in whisper mode (admin can coach the agent)
const result = await voip.eavesdropCall('target-call-uuid', '1001', 'whisper');

// Stop monitoring by hanging up the spy channel
await voip.hangup(result.spyUuid);

DTMF controls after answering: 1 = whisper, 2 = barge (3-way), 3 = mute, 0 = toggle mute.

Webhooks — Typed Event Payloads

The SDK exports typed interfaces for every webhook event payload. Import them to get full IntelliSense in your CRM webhook handler:

import type {
  WebhookEnvelope,
  CallStartedEvent,
  CallAnsweredEvent,
  CallForwardedEvent,
  CallEndedEvent,
  VoicemailNewEvent,
} from '@cemscale-voip/voip-sdk';

// Register a webhook subscription
await voip.createWebhook({
  name: 'CRM Events',
  url: 'https://your-crm.com/api/voip-webhook',
  events: ['call.started', 'call.answered', 'call.forwarded', 'call.ended', 'voicemail.new'],
  secret: 'your-hmac-secret', // optional — enables X-Webhook-Signature header
});

call.forwarded — 3-stage lifecycle

The call.forwarded event fires at up to 3 stages during a forwarded call. Each delivery includes a status field so your CRM knows the current stage:

| Status | When | What it means | |---|---|---| | initiated | Call created | Forward will happen (only for always type) | | answered | Forwarded party picks up | Forward is connected (all types) | | completed | Call ends | Full CDR data: duration, recording, hangup cause |

// Express webhook handler with full type safety
app.post('/api/voip-webhook', (req, res) => {
  const envelope = req.body as WebhookEnvelope;

  switch (envelope.event) {
    case 'call.forwarded': {
      const fwd = envelope.data as CallForwardedEvent;
      // fwd.status: 'initiated' | 'answered' | 'completed'
      // fwd.forwardedTo: '+13055559999'
      // fwd.forwardType: 'always' | 'no-answer' | 'busy'
      // fwd.originalDestination: '1001' (the extension)
      // fwd.caller: '+17861234567' (who called)

      if (fwd.status === 'initiated') {
        // Real-time: show "Forwarding to +1305..." in CRM UI
        updateCrmCallStatus(fwd.callUuid, `Forwarding to ${fwd.forwardedTo}`);
      }

      if (fwd.status === 'answered') {
        // Forward connected — update CRM
        updateCrmCallStatus(fwd.callUuid, `Connected to ${fwd.forwardedTo}`);
      }

      if (fwd.status === 'completed') {
        // Call ended — save CDR with recording
        saveCdrWithForwarding({
          callUuid: fwd.callUuid,
          forwardedTo: fwd.forwardedTo,
          duration: fwd.duration,       // seconds (only on completed)
          hangupCause: fwd.hangupCause, // only on completed
          recordingFile: fwd.recordingFile, // only on completed
        });
      }
      break;
    }

    case 'call.ended': {
      const ended = envelope.data as CallEndedEvent;
      // ended.status: 'completed' | 'failed' | 'missed' | 'voicemail'
      // ended.forwardedTo is non-null when the call was forwarded
      // ended.was_voicemail is true when a voicemail message was left
      if (ended.status === 'voicemail') {
        console.log(`Voicemail left for ${ended.destination}`);
      }
      saveCdr(ended);
      break;
    }

    case 'voicemail.new': {
      const vm = envelope.data as VoicemailNewEvent;
      // vm.extension: '1001' (extension that received the voicemail)
      // vm.caller: '+17861234567'
      // vm.duration: 23 (seconds)
      // vm.recordingFile: '/var/lib/voip-voicemail/.../msg.mp3'
      notifyVoicemail(vm);
      break;
    }

    case 'call.answered': {
      const answered = envelope.data as CallAnsweredEvent;
      // answered.forwardedTo is present when a forwarded call was answered
      if (answered.forwardedTo) {
        console.log(`Forwarded call answered: ${answered.forwardedTo}`);
      }
      break;
    }
  }

  res.sendStatus(200);
});

Webhook payload examples

call.forwarded (status: completed):

{
  "event": "call.forwarded",
  "timestamp": "2026-04-07T14:15:02Z",
  "data": {
    "callUuid": "a1b2c3d4-...",
    "caller": "+17861234567",
    "originalDestination": "1001",
    "forwardedTo": "+13055559999",
    "forwardType": "always",
    "status": "completed",
    "duration": 142,
    "billsec": 138,
    "hangupCause": "NORMAL_CLEARING",
    "recordingFile": "/var/lib/voip-recordings/.../uuid.wav",
    "inboundDid": "+17862757830",
    "timestamp": "2026-04-07T14:17:24Z"
  }
}

call.ended (with forwarding):

{
  "event": "call.ended",
  "timestamp": "2026-04-07T14:17:24Z",
  "data": {
    "callUuid": "a1b2c3d4-...",
    "direction": "inbound",
    "caller": "+17861234567",
    "destination": "+17862757830",
    "duration": 142,
    "billsec": 138,
    "hangupCause": "NORMAL_CLEARING",
    "status": "completed",
    "forwardedTo": "+13055559999",
    "recordingFile": "/var/lib/voip-recordings/.../uuid.mp3"
  }
}

voicemail.new:

{
  "event": "voicemail.new",
  "timestamp": "2026-05-14T18:15:30Z",
  "data": {
    "callUuid": "a1b2c3d4-...",
    "extension": "1001",
    "caller": "+17861234567",
    "callerName": "John Doe",
    "duration": 23,
    "recordingFile": "/var/lib/voip-voicemail/.../msg.mp3",
    "timestamp": "2026-05-14T18:15:30Z"
  }
}

Call Queues

const { queues } = await voip.listQueues();
const { stats } = await voip.getQueueStats('queue-id');
console.log(`Agents: ${stats.agents.available}/${stats.agents.total}`);
console.log(`Service level: ${stats.today.serviceLevel}%`);

Business Hours

await voip.createSchedule({
  name: 'Office Hours',
  timezone: 'America/New_York',
  schedules: [
    { day: 'monday', enabled: true, startTime: '09:00', endTime: '17:00' },
    { day: 'tuesday', enabled: true, startTime: '09:00', endTime: '17:00' },
  ],
  afterHoursAction: 'voicemail',
});

const { isOpen } = await voip.getScheduleStatus('schedule-id');

Blocklist

await voip.blockNumber({ number: '+15559999999', reason: 'Spam', direction: 'inbound' });
const { blocked } = await voip.checkBlocked('+15559999999'); // true

AI Agents (Voice AI)

AI agents are virtual receptionists powered by Google Gemini 3.1 Live API. They answer phone calls, hold natural conversations in English/Spanish, and perform real actions during the call (create leads, send SMS, book appointments, transfer to humans).

SDK Methods

| Method | Description | |--------|-------------| | createAiAgent(params) | Create a new AI agent | | updateAiAgent(agentId, params) | Update any field on an agent | | deleteAiAgent(agentId) | Delete an agent | | getAiAgent(agentId) | Get agent details (includes system_prompt) | | listAiAgents() | List all agents | | listAiVoices() | List available voices (30 Chirp 3 HD voices) | | previewAiVoice(voice, text) | Preview a voice with sample text | | moveAiAgent(agentId, newTenantId) | Move agent to another tenant | | aiAgentsHealth() | Check AI Bridge connectivity |

Create an Agent

const { agent } = await voip.createAiAgent({
  agent_id: 'my-receptionist',       // unique slug, cannot change after creation
  display_name: 'Sofia',             // name the AI uses
  company: 'Acme Corp',              // company context
  role: 'Receptionist',              // role context
  language: 'en',                    // 'en', 'es', etc.
  voice: 'Sulafat',                  // use listAiVoices() for options
  speak_first: true,                 // AI greets caller first
  temperature: 0.5,                  // 0-2, lower = more consistent
  thinking_level: 'minimal',         // 'minimal' | 'medium' | 'high'
  system_prompt: 'You are Sofia...',  // full AI instructions
  tools: [
    'transfer_to_human',             // built-in: transfer call
    'end_call',                      // built-in: hang up
    'create_lead',                   // webhook: create CRM lead
    'leave_message',                 // webhook: save message
    'schedule_appointment',          // webhook: book appointment
    'send_sms',                      // webhook: send text message
    'check_policy_status',           // webhook: look up policy
  ],
  tools_webhook_url: 'https://your-crm.com/api/webhooks/ai-tools',
  max_duration_s: 600,               // max call length (seconds)
  idle_timeout_s: 30,                // silence timeout (seconds)
});

Update an Agent

Every field is editable after creation (except agent_id). Only send the fields you want to change:

// Change the voice and name
await voip.updateAiAgent('my-receptionist', {
  display_name: 'Isabella',
  voice: 'Aoede',
});

// Change the system prompt
await voip.updateAiAgent('my-receptionist', {
  system_prompt: 'You are Isabella, a bilingual receptionist...',
});

// Change tools
await voip.updateAiAgent('my-receptionist', {
  tools: ['transfer_to_human', 'end_call', 'send_sms'],
});

// Change behavior
await voip.updateAiAgent('my-receptionist', {
  temperature: 0.3,
  speak_first: false,
  max_duration_s: 300,
  idle_timeout_s: 15,
});

// Change everything at once
await voip.updateAiAgent('my-receptionist', {
  display_name: 'Daniela',
  company: 'New Company',
  role: 'Sales Agent',
  language: 'es',
  voice: 'Sulafat',
  speak_first: true,
  temperature: 0.5,
  thinking_level: 'minimal',
  max_duration_s: 600,
  idle_timeout_s: 30,
  system_prompt: 'You are Daniela...',
  tools: ['transfer_to_human', 'end_call', 'create_lead', 'send_sms'],
  tools_webhook_url: 'https://your-crm.com/api/webhooks/ai-tools',
  greeting: 'Hola, gracias por llamar',
  farewell: 'Que tengas buen dia',
});

Clearing fields: Send null to clear string fields (tools_webhook_url: null). Send [] to clear tools.

All Agent Fields Reference

| Field | Type | Default | Description | |-------|------|---------|-------------| | agent_id | string | required | Unique slug (cannot change after creation) | | display_name | string | required | Name the AI uses on calls | | company | string | display_name | Company name for AI context | | role | string | "AI Assistant" | Role/title for AI context | | language | string | "en" | Language code: en, es, fr, etc. | | voice | string | "Kore" | Chirp 3 HD voice name | | system_prompt | string | "" | Full instructions for the AI | | speak_first | boolean | true | Whether AI greets caller first | | temperature | number | 0.7 | Creativity: 0.0 precise, 1.0 creative | | thinking_level | string | "minimal" | "minimal", "medium", "high" | | tools | string[] | [] | Tools the agent can invoke | | tools_webhook_url | string/null | null | URL for webhook tool calls | | greeting | string/null | null | Override greeting phrase | | farewell | string/null | null | Override farewell phrase | | max_duration_s | number | 600 | Max call duration (seconds) | | idle_timeout_s | number | 30 | Silence timeout (seconds) | | enable_transcription | boolean | false | Enable real-time transcription |

Assign Agent to a Phone Number

After creating an agent, link it to a DID so inbound calls go to the AI:

await voip.updateDid('did-uuid', {
  inboundRoute: 'ai_agent',
  routeTarget: 'my-receptionist',    // must match agent_id
  aiTransferType: 'extension',       // where transfer_to_human sends calls
  aiTransferTarget: '1001',          // extension/queue to transfer to
});
// Transfer types: 'extension', 'queue', 'ringgroup', 'ivr', 'external', 'voicemail'

Move Agent Between Tenants

const result = await voip.moveAiAgent('my-receptionist', 'new-tenant-uuid');
console.log(result.previous_tenant_id, '->', result.new_tenant_id);

AI Tools & Webhook Integration

When the AI agent invokes a webhook tool during a live call, the platform sends a POST to your tools_webhook_url. Your CRM processes the action and responds with JSON. The message field in your response is what the AI speaks to the caller.

Architecture

Caller speaks -> Gemini AI -> invokes tool (e.g. send_sms)
  -> VoIP Platform -> POST to VoIP API -> POST to YOUR webhook
  -> Your CRM processes (sends SMS, creates lead, etc.)
  -> Your CRM responds: { status: "sent", message: "Text sent" }
  -> Gemini speaks to caller: "I just sent you the text"

The 7 Standard Tools

| Tool | Type | What it does | |------|------|-------------| | transfer_to_human | Built-in | Transfers call to the extension/queue configured on the DID | | end_call | Built-in | Hangs up after 2s delay so AI can say goodbye | | create_lead | Webhook | Creates a lead/prospect in your CRM | | leave_message | Webhook | Saves a message from the caller | | schedule_appointment | Webhook | Books an appointment | | send_sms | Webhook | Sends an SMS text message during the live call | | check_policy_status | Webhook | Looks up a policy/account status |

Webhook Request (what your CRM receives)

Every webhook tool call arrives as a POST with this format:

{
  "function": "send_sms",
  "args": {
    "to_phone": "+17865551234",
    "message": "Your appointment is confirmed for Friday at 2pm",
    "purpose": "appointment_confirmation",
    "caller_name": "Ana Martinez"
  },
  "call_id": "ff6a1693-b2f8-123f-b8b1-0a7dee2809a7",
  "tenant_id": "6a097cb2-504f-43be-819a-b76209976818"
}

Headers:

| Header | Value | |--------|-------| | Content-Type | application/json | | Authorization | Bearer csk_live_... (your VOIP_TOOLS_WEBHOOK_KEY) | | X-Webhook-Source | voiceai-platform |

Webhook Response (what your CRM must return)

{
  "status": "sent",
  "message": "The text message has been sent to your phone."
}

| Field | Type | Description | |-------|------|-------------| | status | string | Result: "sent", "created", "saved", "scheduled", "found", "not_found", "error", "failed" | | message | string | What the AI speaks to the caller. Write naturally, 1-2 sentences max. |

Rules:

  • message is spoken aloud to the caller. Write it as natural speech.
  • Keep to 1-2 sentences max.
  • If something fails, still return a message with a graceful fallback.
  • Your webhook has a 9 second timeout.
  • Return HTTP 200 for success, 500 for errors.

Tool Arguments Reference

create_lead | Arg | Type | Required | Description | |-----|------|----------|-------------| | first_name | string | Yes | Caller's first name | | last_name | string | No | Last name | | phone | string | Yes | Phone number | | email | string | No | Email if provided | | company | string | No | Company name | | interest | string | No | What they want | | notes | string | No | Additional context |

leave_message | Arg | Type | Required | Description | |-----|------|----------|-------------| | caller_name | string | Yes | Who is leaving the message | | caller_phone | string | Yes | Callback number | | for_person | string | No | Who the message is for | | message | string | Yes | Message content | | urgent | boolean | No | Marked as urgent | | callback_preferred_time | string | No | When to call back |

schedule_appointment | Arg | Type | Required | Description | |-----|------|----------|-------------| | caller_name | string | Yes | Name | | phone | string | Yes | Phone | | date | string | Yes | Date ("Friday", "2026-04-18") | | time | string | No | Time ("2pm", "morning") | | duration | string | No | Duration ("30 minutes") | | purpose | string | Yes | Reason for appointment | | notes | string | No | Additional notes |

send_sms | Arg | Type | Required | Description | |-----|------|----------|-------------| | to_phone | string | Yes | Phone with country code ("+17865551234") | | message | string | Yes | Text message content | | purpose | string | No | Category: "appointment_confirmation", "address_info", etc. | | caller_name | string | No | For personalization |

check_policy_status | Arg | Type | Required | Description | |-----|------|----------|-------------| | policy_number | string | Yes | Policy/account number | | phone | string | No | Verification | | last_name | string | No | Verification |

transfer_to_human (built-in, no webhook) | Arg | Type | Required | Description | |-----|------|----------|-------------| | reason | string | Yes | Why caller wants transfer | | department | string | No | Specific department |

end_call (built-in, no webhook) | Arg | Type | Required | Description | |-----|------|----------|-------------| | reason | string | Yes | Why: "conversation_complete", "caller_goodbye", "no_response" | | summary | string | No | Brief call summary |

CRM Webhook Setup

IMPORTANT: Your webhook endpoint must NOT have your CRM's global auth middleware. It uses its own auth via the Authorization: Bearer header.

Step 1 — Add environment variable:

VOIP_TOOLS_WEBHOOK_KEY=csk_live_da3d1ee6d3b51696922206dc139beab84239a7935049b5a5

Step 2 — Create the endpoint (register WITHOUT global auth middleware):

import crypto from 'crypto';

// Auth validation — checks Bearer token
function validateAuth(req: any): boolean {
  const expected = process.env.VOIP_TOOLS_WEBHOOK_KEY;
  if (!expected) return false;
  const auth = req.headers.authorization || '';
  const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
  if (token.length !== expected.length) return false;
  try {
    return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected));
  } catch { return false; }
}

// IMPORTANT: Register WITHOUT global auth middleware (no wrap(), no requireAuth)
app.post('/api/webhooks/ai-tools', async (req, res) => {
  if (!validateAuth(req)) {
    return res.status(401).json({ status: 'error', message: 'Unauthorized' });
  }

  const { function: toolName, args, call_id, tenant_id } = req.body;

  switch (toolName) {

    case 'create_lead': {
      const lead = await db.leads.create({
        data: {
          firstName: args.first_name,
          lastName: args.last_name || '',
          phone: args.phone,
          email: args.email || null,
          company: args.company || null,
          interest: args.interest || null,
          notes: args.notes || null,
          source: 'ai_phone_call',
          callId: call_id,
          tenantId: tenant_id,
        },
      });
      return res.json({
        status: 'created',
        message: `Record created for ${args.first_name}. A team member will follow up.`,
      });
    }

    case 'leave_message': {
      await db.messages.create({
        data: {
          callerName: args.caller_name,
          callerPhone: args.caller_phone,
          forPerson: args.for_person || null,
          content: args.message,
          urgent: args.urgent || false,
          callbackTime: args.callback_preferred_time || 'anytime',
          callId: call_id,
          tenantId: tenant_id,
        },
      });
      return res.json({
        status: 'saved',
        message: args.urgent
          ? `Flagged as urgent. Team will call ${args.caller_name} back right away.`
          : `Message saved. Someone will call ${args.caller_name} back soon.`,
      });
    }

    case 'schedule_appointment': {
      await db.appointments.create({
        data: {
          callerName: args.caller_name,
          phone: args.phone,
          date: args.date,
          time: args.time || null,
          duration: args.duration || '30 minutes',
          purpose: args.purpose,
          notes: args.notes || null,
          callId: call_id,
          tenantId: tenant_id,
        },
      });
      return res.json({
        status: 'scheduled',
        message: `Appointment confirmed for ${args.date}${args.time ? ' at ' + args.time : ''}.`,
      });
    }

    case 'send_sms': {
      // Use YOUR SMS provider (Twilio, Telnyx, etc.)
      const result = await smsProvider.send({
        to: args.to_phone,
        body: args.message,
        from: process.env.SMS_FROM_NUMBER,
      });

      if (!result.success) {
        return res.json({
          status: 'failed',
          message: 'Unable to send the text right now. Let me note your number and we will send it shortly.',
        });
      }
      return res.json({
        status: 'sent',
        message: 'Text message sent to ' + args.to_phone + '. Check your messages in a moment.',
      });
    }

    case 'check_policy_status': {
      const policy = await db.policies.findFirst({
        where: { policyNumber: args.policy_number },
      });
      if (!policy) {
        return res.json({
          status: 'not_found',
          message: `No policy found with number ${args.policy_number}. Can you double-check?`,
        });
      }
      return res.json({
        status: 'found',
        message: `Policy ${args.policy_number} is ${policy.status}.`,
      });
    }

    default:
      return res.json({
        status: 'noted',
        message: 'Noted. A team member will follow up.',
      });
  }
});

Step 3 — Test with curl:

curl -X POST https://your-crm.com/api/webhooks/ai-tools \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_VOIP_TOOLS_WEBHOOK_KEY" \
  -d '{
    "function": "send_sms",
    "args": { "to_phone": "+17865551234", "message": "Test SMS" },
    "call_id": "test-123",
    "tenant_id": "test"
  }'
# Expected: {"status":"sent","message":"Text message sent to +17865551234..."}

Example Flow: SMS During a Call

1. Caller: "Can you send me the address by text?"
2. AI: "Sure! Let me confirm your number — is it 786-555-1234?"
3. Caller: "Yes"
4. AI invokes: send_sms({ to_phone: "+17865551234", message: "Our address: 123 Main St, Miami FL 33101" })
5. Platform POSTs to your webhook
6. Your CRM sends the SMS via Twilio and responds: { status: "sent", message: "Text sent" }
7. AI: "Done! I just sent you a text with our address. Anything else?"

Playground — Test Agents from the Browser

Test any AI agent directly from your browser without a phone call. Opens your microphone, connects to the agent, and you can have a real conversation. All tools (send_sms, create_lead, etc.) work exactly like a real phone call.

// Create a playground session
const session = voip.connectAiPlayground('my-receptionist');

// Listen for events
session.on('ready', () => {
  console.log('Connected! Speak into your mic.');
});

session.on('toolCall', ({ function: name, args }) => {
  console.log('Agent invoked:', name, args);
  // e.g. { function: 'send_sms', args: { to_phone: '+1786...', message: '...' } }
});

session.on('toolResult', ({ function: name, status, message }) => {
  console.log('Tool result:', name, status, message);
  // e.g. { function: 'send_sms', status: 'sent', message: 'Text sent' }
});

session.on('end', ({ reason }) => {
  console.log('Session ended:', reason);
});

session.on('error', (err) => {
  console.error('Playground error:', err.message);
});

// Start the session (requests microphone permission)
await session.start();

// Mute/unmute your mic
session.mute();
session.unmute();

// Check state
console.log(session.isConnected); // true
console.log(session.isMuted);     // false

// Disconnect when done
session.disconnect();

How it works:

  1. connectAiPlayground() creates a session object (no connection yet)
  2. session.start() requests mic permission, opens WebSocket, connects to Gemini
  3. Your mic audio streams to the AI agent in real time
  4. The agent's voice plays through your speakers
  5. Tools fire exactly like phone calls (webhooks, SMS, leads — all real)
  6. session.disconnect() ends the session and releases all resources

Requirements:

  • Browser with getUserMedia support (Chrome, Firefox, Safari, Edge)
  • Microphone permission
  • HTTPS (required for getUserMedia in production)

Events reference:

| Event | Data | When | |-------|------|------| | ready | — | WebSocket connected, Gemini session ready | | toolCall | { function, args } | Agent invoked a tool | | toolResult | { function, status, message } | Tool execution result | | end | { reason } | Session ended (agent hangup, transfer, user disconnect) | | error | Error | Connection error, mic denied, Gemini error |

Call History

Query past AI agent calls (CDR records):

// Get last 50 calls
const { calls, total } = await voip.listAiCalls();

// Filter by agent
const { calls } = await voip.listAiCalls({ agent_id: 'my-receptionist', limit: 20 });

// Each call record contains:
for (const call of calls) {
  console.log(call.agent_id);       // which agent handled it
  console.log(call.caller_id);      // who called
  console.log(call.duration_s);     // call duration in seconds
  console.log(call.tokens_input);   // Gemini tokens used (input)
  console.log(call.tokens_output);  // Gemini tokens used (output)
  console.log(call.end_reason);     // how it ended: completed, transferred, error
  console.log(call.started_at);     // ISO timestamp
}

Playground Transcript

The playground emits real-time transcript events for what the AI agent says:

const session = voip.connectAiPlayground('my-agent');

session.on('transcript', ({ role, text }) => {
  // role: 'assistant' (what the AI says)
  console.log(`[${role}]: ${text}`);
});

await session.start();

Webhook Events

The platform sends these webhook events to your CRM:

| Event | When | Data | |-------|------|------| | call.start | New call begins | call_uuid, agent_id, tenant_id, caller_id, inbound_did | | call.end | Call ends | call_uuid, agent_id, tenant_id, duration_s, end_reason | | call.transfer | Transfer to human | call_uuid, agent_id, tenant_id, reason, summary | | Tool calls | During call | function, args, call_id, tenant_id |

CDR Export

const csv = await voip.exportCalls({
  dateFrom: '2026-03-01',
  dateTo: '2026-03-31',
  direction: 'inbound',
});

API Key Management

// Create a key for a new integration
const { apiKey } = await voip.createApiKey({ name: 'Mobile App', role: 'readonly' });
console.log(apiKey.key); // csk_live_... — save this, shown only once

// List all keys
const { apiKeys } = await voip.listApiKeys();

// Revoke a key
await voip.revokeApiKey('key-id');

Connecting a WebRTC Softphone

Your app doesn't need to know or store SIP passwords. Use the API key to fetch credentials on the fly:

// 1. Get SIP credentials for the extension (API key handles auth)
const { sipCredentials } = await voip.getSipCredentialsByNumber('1001');

// sipCredentials = {
//   extension:    "1001",
//   displayName:  "Alice",
//   password:     "auto-provided",   <-- API returns it, your app doesn't store it
//   sipDomain:    "sip.cemscale.com",
//   wsUri:        "wss://sip.cemscale.com/ws",
//   registrar:    "sip:sip.cemscale.com"
// }

// 2. Also get TURN credentials for NAT traversal
const turn = await voip.getTurnCredentials();

Two methods available:

| Method | Use when | |--------|----------| | getSipCredentials(extensionId) | You have the extension UUID | | getSipCredentialsByNumber('1001') | You have the extension number |

WebRTC Softphone (Browser Only)

For building a browser-based softphone, use the WebRTCPhone class or the useVoIP React hook. These require SIP credentials (extension + password), not an API key.

Check microphone access

Before starting the phone, check that the user has granted microphone permission:

import { WebRTCPhone } from '@cemscale-voip/voip-sdk';

const hasAccess = await WebRTCPhone.checkMicrophoneAccess();
if (!hasAccess) {
  alert('Microphone access is required to make calls.');
}

React hook example

import { useVoIP } from '@cemscale-voip/voip-sdk';

function PhoneWidget() {
  const { isRegistered, currentCall, login, startPhone, call, answer, hangup, toggleHold, toggleMute, error } = useVoIP({
    apiUrl: 'https://voip-api.cemscale.com',
    sipDomain: 'sip.cemscale.com',
    wsUri: 'wss://sip.cemscale.com/ws',
  });

  return (
    <div>
      <p>{isRegistered ? 'Online' : 'Offline'}</p>
      {error && <p style={{ color: 'red' }}>{error}</p>}
      {currentCall ? (
        <>
          <button onClick={hangup}>Hangup</button>
          <button onClick={toggleHold}>{currentCall.held ? 'Resume' : 'Hold'}</button>
          <button onClick={toggleMute}>{currentCall.muted ? 'Unmute' : 'Mute'}</button>
        </>
      ) : (
        <button onClick={() => call('+15551234567')}>Call</button>
      )}
    </div>
  );
}

Reliability features (built-in)

The WebRTCPhone handles these scenarios automatically:

| Feature | Behavior | |---------|----------| | SIP transport reconnection | Auto-reconnects up to 3 times (4s delay) if the WebSocket drops | | Auto re-registration | Re-sends SIP REGISTER after transport reconnects | | Auto-renewal before expiry | Automatically renews SIP registration 60 seconds before it expires | | Media recovery | Reattaches remote audio after hold/unhold or renegotiation via pc.ontrack | | ICE monitoring | Emits error event when ICE connection enters failed or disconnected state | | Autoplay detection | Emits an error event (AUTOPLAY_BLOCKED) if the browser blocks audio playback | | Hold guard | Prevents hold/unhold race conditions from rapid toggling | | Busy call rejection | Incoming calls during active call are rejected with SIP 486 and emit callStateChanged |

WebRTCPhone events

Listen for events via the on() method:

const phone = new WebRTCPhone(config);

phone.on('registered', () => console.log('SIP registered'));
phone.on('registrationFailed', (error) => console.error('Registration failed:', error));
phone.on('callStateChanged', (callInfo) => console.log('Call state:', callInfo.state));
phone.on('error', (err) => console.error('Phone error:', err));

| Event | Payload | When | |-------|---------|------| | registered | none | SIP registration succeeds (including after reconnect) | | registrationFailed | Error | SIP registration fails or re-registration after reconnect fails | | callStateChanged | WebRTCCallInfo | Call state changes (ringing, answered, held, terminated, etc.) | | error | Error | ICE failure, autoplay blocked, media error |


## All Methods

### Calls
| Method | Description |
|--------|-------------|
| `originate({ fromExtension, toNumber })` | Start a call |
| `hangup(uuid)` | Hang up |
| `transfer(uuid, { targetExtension, type })` | Transfer (blind/attended) |
| `holdCall(uuid, hold?)` | Hold / resume |
| `parkCall(uuid, slot?)` | Park a call |
| `eavesdropCall(uuid, extension, mode?)` | Eavesdrop on a call |
| `getParkedCalls()` | List parked calls |
| `listCalls(params?)` | CDR history |
| `getCall(id)` | Single CDR record |
| `getActiveCalls()` | Active calls |
| `getCallStats(period?)` | Call statistics |
| `exportCalls(params?)` | Export CDR as CSV |

### Extensions
| Method | Description |
|--------|-------------|
| `listExtensions()` | List all |
| `getExtension(id)` | Get detail |
| `createExtension(params)` | Create |
| `updateExtension(id, params)` | Update |
| `deleteExtension(id)` | Delete |
| `setCallForward(id, params)` | Set forwarding |
| `getCallForward(id)` | Get forwarding |
| `getExtensionByCrmUser(crmUserId)` | Lookup by CRM user |
| `updateCrmMapping(id, params)` | Set CRM mapping |
| `getSipCredentials(id)` | SIP credentials by extension UUID |
| `getSipCredentialsByNumber('1001')` | SIP credentials by extension number |

### Conferences
| Method | Description |
|--------|-------------|
| `listConferences()` | List active |
| `getConference(name)` | Detail + members |
| `joinConference(name, callUuid)` | Add call to conference |
| `transferToConference(uuid, name?)` | Transfer call to conference |
| `kickFromConference(name, memberId)` | Remove member |
| `muteConferenceMember(name, memberId, mute)` | Mute/unmute |
| `deafConferenceMember(name, memberId, deaf)` | Deaf/undeaf |
| `lockConference(name, lock)` | Lock/unlock |
| `recordConference(name, action)` | Start/stop recording |

### Ring Groups
| Method | Description |
|--------|-------------|
| `listRingGroups()` | List all |
| `createRingGroup(params)` | Create |
| `updateRingGroup(id, params)` | Update |
| `deleteRingGroup(id)` | Delete |

### Call Queues
| Method | Description |
|--------|-------------|
| `listQueues()` | List all |
| `createQueue(params)` | Create |
| `updateQueue(id, params)` | Update |
| `deleteQueue(id)` | Delete |
| `pauseQueueAgent(queueId, agentId, paused)` | Pause/unpause |
| `loginQueueAgent(queueId, agentId, loggedIn)` | Login/logout |
| `getQueueStats(queueId)` | Real-time stats |

### IVR
| Method | Description |
|--------|-------------|
| `listIvrMenus()` | List all |
| `getIvrMenu(id)` | Get detail |
| `createIvrMenu(params)` | Create |
| `updateIvrMenu(id, params)` | Update |
| `deleteIvrMenu(id)` | Delete |
| `uploadIvrAudio(id, file, filename)` | Upload greeting audio |
| `uploadIvrInvalidAudio(id, file, filename)` | Upload invalid-input audio |
| `getIvrAudioUrl(id)` | Get greeting audio URL |
| `getIvrInvalidAudioUrl(id)` | Get invalid-input audio URL |
| `downloadIvrAudio(id)` | Download greeting audio |
| `downloadIvrInvalidAudio(id)` | Download invalid-input audio |
| `deleteIvrAudio(id)` | Delete greeting audio |
| `deleteIvrInvalidAudio(id)` | Delete invalid-input audio |
| `generateIvrTts(id, params)` | Generate TTS greeting |
| `listIvrVoices()` | List available TTS voices |

### Webhooks
| Method | Description |
|--------|-------------|
| `listWebhooks()` | List all |
| `createWebhook(params)` | Create |
| `updateWebhook(id, params)` | Update |
| `deleteWebhook(id)` | Delete |
| `testWebhook(id)` | Send test event |
| `listWebhookDeliveries(id)` | Delivery history |

### Voicemail
| Method | Description |
|--------|-------------|
| `listVoicemails(params?)` | List messages |
| `getVoicemail(id)` | Get single |
| `getVoicemailAudioUrl(id)` | Audio URL |
| `getVoicemailDownloadUrl(id)` | Download URL |
| `getVoicemailCount(extension?)` | Unread/total |
| `markVoicemailRead(id)` | Mark as read |
| `deleteVoicemail(id)` | Delete |
| `bulkDeleteVoicemails(params?)` | Bulk delete |
| `getVoicemailGreeting(extension?)` | Get greeting settings |
| `updateVoicemailGreeting(params)` | Update greeting settings |
| `uploadVoicemailGreetingAudio(file, ext?, name?)` | Upload custom greeting |
| `getVoicemailGreetingAudioUrl(extension)` | Greeting audio URL |
| `deleteVoicemailGreetingAudio(extension?)` | Delete greeting audio |

**Voicemail examples:**

```typescript
// List voicemails for extension 1001 — extension is required
const { messages, unread, pagination } = await client.listVoicemails({
  extension: '1001',
  unreadOnly: false,
  limit: 20,
});

for (const msg of messages) {
  console.log(`${msg.caller_name} (${msg.caller_id}) — ${msg.duration_seconds}s`);

  // Linked call data: status, recording, direction
  if (msg.call) {
    console.log(`  Call: ${msg.call.status}, recording: ${msg.call.cdn_url}`);
  }

  // Play audio in browser <audio> tag
  const audioUrl = client.getVoicemailAudioUrl(msg.id);
  // => "https://api.cemscale.com/api/voicemail/<id>/audio?apiKey=..."

  // Mark as read
  await client.markVoicemailRead(msg.id);
}

// Unread count badge
const { total, unread: unreadBadge } = await client.getVoicemailCount('1001');
console.log(`Unread: ${unreadBadge} / ${total}`);

Blocklist

| Method | Description | |--------|-------------| | listBlockedNumbers() | List blocked | | blockNumber(params) | Block a number | | unblockNumber(id) | Unblock | | checkBlocked(number) | Check if blocked |

Business Hours

| Method | Description | |--------|-------------| | listSchedules() | List schedules | | getSchedule(id) | Get detail | | createSchedule(params) | Create | | updateSchedule(id, params) | Update | | deleteSchedule(id) | Delete | | getScheduleStatus(id) | Open or closed? |

SIP Trunks

| Method | Description | |--------|-------------| | listTrunks() | List trunks | | getTrunk(id) | Get detail | | createTrunk(params) | Create | | updateTrunk(id, params) | Update | | deleteTrunk(id) | Delete | | getTrunkStatus(id) | Check gateway status | | syncTrunk(id) | Sync config to FreeSWITCH | | listActiveGateways() | List active gateways |

API Keys

| Method | Description | |--------|-------------| | listApiKeys() | List keys | | getApiKeyDetail(id) | Get detail | | createApiKey(params) | Create (returns full key once) | | updateApiKey(id, params) | Update | | revokeApiKey(id) | Delete | | regenerateApiKey(id) | Regenerate (new key) |

DIDs

| Method | Description | |--------|-------------| | listDids() | List numbers | | createDid(params) | Assign DID | | updateDid(id, params) | Update routing | | deleteDid(id) | Remove |

AI Agents

| Method | Description | |--------|-------------| | listAiAgents(tenantId?) | List all AI agents | | getAiAgent(agentId) | Get agent details (includes system_prompt) | | createAiAgent(params) | Create new AI agent | | updateAiAgent(agentId, params) | Update agent config | | deleteAiAgent(agentId) | Delete agent | | aiAgentsHealth() | Check AI Bridge connectivity |

Tenants (superadmin)

| Method | Description | |--------|-------------| | listTenants() | List all tenants | | getTenant(id) | Get detail | | createTenant(params) | Create tenant | | updateTenant(id, params) | Update tenant | | deleteTenant(id) | Delete tenant | | getTenantStats(id) | Tenant statistics |

Recordings

| Method | Description | |--------|-------------| | listRecordings(params?) | List recordings | | getRecordingUrl(id) | Get presigned S3 URL | | getRecordingAudioUrl(id) | Direct audio stream URL | | getRecordingDownloadUrl(id) | Download URL | | deleteRecording(id) | Delete |

Presence

| Method | Description | |--------|-------------| | setPresence(extensionId, status) | Set manual status ('busy' / 'available' / 'offline') | | getPresence() | Simple map | | getPresenceDetailed() | Detailed with call info |

Presence Status Management

Control extension availability from the CRM. When an agent sets themselves as busy, the phone still rings but the call is rejected with SIP 486 and routed to voicemail. Setting to offline marks the extension as unavailable (useful when logging out of the CRM). Setting back to available restores normal behavior.

// Get current presence for all extensions
const { presence } = await client.getPresence();
// { '1001': 'available', '1002': 'busy', '1003': 'offline' }

// Set an extension to busy (next call → voicemail)
await client.setPresence('ext-uuid-here', 'busy');

// Set an extension to offline (CRM logout / away)
await client.setPresence('ext-uuid-here', 'offline');

// Set back to available (calls ring normally)
await client.setPresence('ext-uuid-here', 'available');

WebSocket (real-time): When any extension changes presence, all connected CRM clients receive presence_change instantly.

client.connectWebSocket();

client.onWsEvent('presence_change', (event) => {
  // event.data.extension → '1001'
  // event.data.status → 'busy'
  updateAgentBadge(event.data.extension, event.data.status);
});

React with usePresence hook:

import { usePresence } from '@cemscale-voip/voip-sdk/hooks';

function AgentPanel({ client, extensionId }) {
  const { presence, getStatus } = usePresence(client);
  const myStatus = getStatus('1001');
  // 'available' | 'busy' | 'on_call' | 'offline' | 'dnd' | 'ringing'

  return (
    <div>
      <span className={`badge ${myStatus}`}>{myStatus}</span>
      <button onClick={() => client.setPresence(extensionId, 'available')}>
        Set Available
      </button>
      <button onClick={() => client.setPresence(extensionId, 'busy')}>
        Set Busy
      </button>
      <button onClick={() => client.setPresence(extensionId, 'offline')}>
        Set Offline
      </button>
    </div>
  );
}

Reports

| Method | Description | |--------|-------------| | getDashboardStats() | Dashboard numbers | | getCallsByDay(params?) | Daily breakdown | | getTopExtensions(params?) | Most active extensions |

WebSocket Events

| Event | Data | |-------|------| | presence_snapshot | Full presence state on connect | | presence_change | { extension, status, callUuid } | | registration_change | { extension, registered, ip, timestamp } | | call_start | { callUuid, caller, destination, direction } | | call_answer | { callUuid } | | call_end | { callUuid, duration, hangupCause } |

WebSocket reconnection

The VoIPClient WebSocket reconnects automatically with exponential backoff:

  • Initial delay: 3 seconds, max: 60 seconds
  • Max retries: 10 before emitting an error event and stopping
  • Call connectWebSocket() again to reset the retry counter

React Hooks

useVoIP

All-in-one hook that combines VoIPClient + WebRTCPhone. Returns client, phone, isRegistered, currentCall, error, and action methods (login, startPhone, call, answer, hangup, toggleHold, toggleMute, sendDtmf, blindTransfer, originate, etc.).

usePresence

Real-time extension presence via WebSocket with REST polling fallback. Automatically handles presence_snapshot, presence_change, and registration_change events.

import { usePresence } from '@cemscale-voip/voip-sdk/hooks';

const { presence, getStatus, isRealtime } = usePresence(client);
// presence: Map<extension, status>
// getStatus('1001') => 'available' | 'on_call' | 'ringing' | 'offline'

useCallStatus

Real-time active call tracking via WebSocket.

import { useCallStatus } from '@cemscale-voip/voip-sdk/hooks';

const { activeCalls, recentCalls, stats, activeCount } = useCallStatus(client);

Campaign Originate (Press-1 / Voice Broadcast)

Server-side outbound dialing with IVR, queue routing, and full lifecycle tracking.

Launching a Campaign Call

const result = await client.campaignOriginate({
  toNumber: '+17866302522',
  callerIdNumber: '+16292762555',
  callerIdName: 'Campaign',
  onAnswer: {
    type: 'transfer',
    flowType: 'ivr',        // 'ivr' | 'queue' | 'ring_group' | 'extension'
    destination: '4001',    // extension code or UUID
  },
  ringTimeoutSec: 30,
  maxDurationSec: 300,
  amd: true,                // answering machine detection
  record: false,
  metadata: {
    campaignId: 'camp-abc-123',
    recipientId: 'rec-xyz-456',
    companyId: 'comp-789',
  },
}, { idempotencyKey: 'unique-key-123' });

console.log(result.callUuid);  // '18936df0-...'
console.log(result.status);    // 'ringing'

Getting Campaign Call Detail

After the call ends, fetch the full lifecycle data:

const detail = await client.getCampaignDetail('18936df0-...');

console.log(detail.outcome);              // 'agent_answered'
console.log(detail.timing.ringDurationSec); // 6
console.log(detail.timing.billsec);       // 285

// AMD
console.log(detail.amd.result);           // 'unknown_assumed_human'
console.log(detail.amd.durationMs);       // 3000

// IVR
console.log(detail.ivr?.menuName);        // 'Press 1'
console.log(detail.ivr?.digitPressed);    // '1'
console.log(detail.ivr?.attempts);        // 1
console.log(detail.ivr?.result);          // 'digit_pressed'

// Queue
console.log(detail.queue?.queueName);     // 'Press 1 Agents'
console.log(detail.queue?.waitSeconds);   // 262
console.log(detail.queue?.answeredBy);    // '1006'
console.log(detail.queue?.answeredByName); // 'Angelica Gonzalez'
console.log(detail.queue?.talkSeconds);   // 23

Campaign Webhook Events

Subscribe your CRM to these webhook events to receive real-time notifications at every stage of the campaign call lifecycle. Configure webhooks via POST /api/webhooks with the event names below.

Event Timeline (chronological order)

campaign.call_initiated  →  Call placed, ringing contact
        │
campaign.answered        →  Contact picked up the phone
        │
campaign.amd_result      →  AMD: human / machine / unknown
        │                    (machine → call hangs up, no further events)
campaign.ivr_completed   →  IVR done: digit_pressed / timeout / caller_hangup
        │
campaign.queue_joined    →  Caller entered queue (pressed the right digit)
        │
campaign.agent_answered  →  Agent picked up the queue call
        │
campaign.completed       →  Final summary with ALL data

campaign.completed Payload (the gold-standard event)

{
  "event": "campaign.completed",
  "timestamp": "2026-06-12T20:56:31.000Z",
  "data": {
    "callUuid": "96afef31-...",
    "campaignId": "camp-abc-123",
    "recipientId": "rec-xyz-456",
    "companyId": "comp-789",
    "toNumber": "+17866302522",
    "callerIdNumber": "+16292762555",
    "outcome": "agent_answered",
    "timing": {
      "startedAt": "2026-06-12T20:51:42.000Z",
      "answeredAt": "2026-06-12T20:51:46.000Z",
      "endedAt": "2026-06-12T20:56:31.000Z",
      "ringDurationSec": 4,
      "totalDurationSec": 289,
      "billsec": 285
    },
    "amd": {
      "enabled": true,
      "result": "unknown_assumed_human",
      "durationMs": 3000
    },
    "ivr": {
      "menuName": "Press 1",
      "digitPressed": "1",
      "attempts": 1,
      "result": "digit_pressed"
    },
    "queue": {
      "entered": true,
      "queueName": "Press 1 Agents",
      "queueId": "32fb10de-...",
      "waitSeconds": 262,
      "result": "answered",
      "answeredBy": "1006",
      "answeredByName": "Angelica Gonzalez",
      "talkSeconds": 23
    },
    "recording": {
      "enabled": true,
      "url": "https://cdn.example.com/recordings/96afef31.mp3",
      "durationSec": 285
    },
    "hangupCause": "NORMAL_CLEARING",
    "status": "completed",
    "disposition": "transferred"
  }
}

Outcome Values

| Outcome | Meaning | |---------|---------| | no_answer | Contact didn't answer (ring timeout) | | amd_machine | AMD detected answering machine / voicemail | | ivr_timeout | Answered, heard IVR, didn't press anything | | ivr_hangup | Answered, hung up during IVR | | queue_timeout | Pressed digit, entered queue, no agent answered | | queue_hangup | Pressed digit, entered queue, hung up while waiting | | agent_answered | Pressed digit, entered queue, agent answered — full success | | transferred | Transferred to extension or flow (non-queue) | | completed | Call completed normally | | failed | Technical failure |

Checking Active Campaign Calls

const { activeChannels, maxChannels, calls } = await client.getCampaignActive();
console.log(`${activeChannels}/${maxChannels} channels in use`);

Feature Codes (Phone Dial Pad)

| Code | Function | |------|----------| | *3 | Conference room | | *70 | Park call | | *71XX | Retrieve parked call from slot XX | | *97 | Check voicemail |