JSPM

  • Created
  • Published
  • Downloads 1364
  • Score
    100M100P100Q133054F
  • License UNLICENSED

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

Package Exports

  • @cemscale-voip/voip-sdk
  • @cemscale-voip/voip-sdk/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);

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=..."
}

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,
} 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'],
  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.forwardedTo is non-null when the call was forwarded
      saveCdr(ended);
      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"
  }
}

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

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
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 |
| `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 |

### 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 |

### 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 |
|--------|-------------|
| `getPresence()` | Simple map |
| `getPresenceDetailed()` | Detailed with call info |

### 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.

```tsx
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);

Feature Codes (Phone Dial Pad)

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