@cemscale-voip/voip-sdk
v2.6.0
Published
VoIP SDK for CemScale multi-tenant platform — API client, WebRTC, React hooks
Maintainers
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-sdkAuthentication
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 → null1.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'); // trueAI 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:
messageis spoken aloud to the caller. Write it as natural speech.- Keep to 1-2 sentences max.
- If something fails, still return a
messagewith 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_da3d1ee6d3b51696922206dc139beab84239a7935049b5a5Step 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:
connectAiPlayground()creates a session object (no connection yet)session.start()requests mic permission, opens WebSocket, connects to Gemini- Your mic audio streams to the AI agent in real time
- The agent's voice plays through your speakers
- Tools fire exactly like phone calls (webhooks, SMS, leads — all real)
session.disconnect()ends the session and releases all resources
Requirements:
- Browser with
getUserMediasupport (Chrome, Firefox, Safari, Edge) - Microphone permission
- HTTPS (required for
getUserMediain 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); // 23Campaign 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 datacampaign.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 |
