voxnix
v2.1.0
Published
Headless voice SDK for the Omnix CPaaS platform. Voxnix connects the browser to your PBX over SIP/WebSocket, manages the agent status (Ready / Not Ready / AUX) through the CPaaS API, and gives you **functions + events** to build your own calling UI in any
Readme
Voxnix
Headless voice SDK for the Omnix CPaaS platform. Voxnix connects the browser to your PBX over SIP/WebSocket, manages the agent status (Ready / Not Ready / AUX) through the CPaaS API, and gives you functions + events to build your own calling UI in any stack: React, Vue, Angular, or plain HTML.
Current version: 2.1.0. Voxnix ships no UI. Upgrading from 1.x? See Migrating from 1.x.
Contents
- Installation
- Quick start
- How it works
- Config reference
- Instance API
- State
- Events & callbacks
- Recipes
- Browser requirements
- Security
- Sample page
- Build
- Migrating from 1.x
- Changelog
Installation
npm install voxnix
# or
yarn add voxnixOr use a script tag (no bundler):
<script src="https://unpkg.com/voxnix/dist/voxnix.iife.js"></script>
<script>
const voice = OmnixWidget.init({ /* config */ });
</script>| File | Format | Use |
|-------------------|--------|------------------------------|
| voxnix.js | ESM | import in bundlers |
| voxnix.umd.cjs | UMD | require() / legacy loaders |
| voxnix.iife.js | IIFE | <script> tag → window.OmnixWidget |
Quick start
import { OmnixWidget } from 'voxnix';
const voice = OmnixWidget.init({
auth: {
username: '1001', // SIP extension
pwd_pbx: 'secret', // SIP password
pabx_host: 'sip.example.com', // PBX host
port: 8089, // WSS port (default 8089)
},
'X-CPAAS-Key-Fingerprint': 'sfp_v1_...', // CPaaS API key (agent status)
cpaas_base_url: 'https://api-cpaas.omnix.co.id',
onRegisterStatus: (s) => console.log('register:', s),
onCallStatus: (s) => console.log('call:', s),
onIncomingCall: (call) => console.log('incoming from', call.number),
});
voice.dial('1002'); // outgoing call
voice.hangup(); // end itHow it works
Registration flow
init()
└─ WebSocket connect wss://<sip_url | pabx_host>:<port>
└─ SIP REGISTER sip:<username>@<pbx_url | pabx_host>
├─ autoReady: true (default)
│ └─ POST Ready to CPaaS ──ok──► register_status 'REGISTERED' + agent_status 'READY'
│ └─fail─► register_status { status: 'FAILED', reason }
└─ autoReady: false
└─ GET extension status from CPaaS
├─ already Ready ──► 'REGISTERED' + 'READY'
└─ otherwise ──► 'SIP_CONNECTED' + 'NOT_READY' | 'AUX'
(call setReady() to go to 'REGISTERED')Before every agent-status change, Voxnix checks the extension on CPaaS. If CPaaS reports
is_register = 0, Voxnix sends a SIP re-REGISTER and polls CPaaS (3 × 1s) until it reports
the extension as registered. If it still isn't registered, the status call returns false.
register_status values
| Value | Meaning |
|-------------------------------|---------|
| 'CONNECTING' | Initial state (only visible via getState()) |
| 'SIP_CONNECTED' | SIP registered, agent not Ready yet (autoReady: false, or after setNotReady()) |
| 'REGISTERED' | SIP registered and agent Ready. Calls can be made and received |
| { status: 'FAILED', reason }| See reasons below |
Typical reason values:
Missing SIP credentials (username, password, host)WebSocket disconnected, retrying...: Voxnix keeps reconnecting on its own. When the socket comes back, the registration flow runs again.SIP registration failed: <cause>: for example, a wrong password.SIP UnregisteredFailed to hit extension ready API: the fingerprint is missing or wrong, or the CPaaS API rejected the request.Extension not registered after SIP re-register
Call status flow
outgoing: Idle ─dial()─► Calling... ─answered─► InCall ─hangup / remote BYE─► Idle
└─rejected / no answer─► Idle (+ call_failed)
incoming: Idle ─INVITE─► Ringing ─answer()─► InCall ─► Idle
└─reject() / caller cancels─► Idle (+ callUnanswered)Only one call at a time. A second incoming call while one is active is automatically rejected
with 486 Busy Here.
Config reference
OmnixWidget.init(config). There is one engine per page. Calling init() again destroys the previous instance first.
SIP credentials: auth
| Field | Type | Required | Description |
|-------------|----------|----------|-------------|
| username | string | ✔ | SIP extension (alias: user_pbx) |
| pwd_pbx | string | ✔ | SIP password (alias: secret) |
| pabx_host | string | ✔* | PBX host (aliases: pbxurl, host). http(s):// is stripped |
| port | number | | WSS port, default 8089 |
* Not required when pbx_url is given.
pabxConfig: { auth: {...} } is accepted as an alternative to auth.
Hosts & endpoints
| Option | Default | Description |
|------------------|----------------------------------|-------------|
| pbx_url | auth.pabx_host | SIP domain used in the SIP URI sip:<username>@<pbx_url> (alias: pabxConfig.pbx_url) |
| sip_url | SIP domain | WebSocket host: wss://<sip_url>:<port>. ws(s):// and a trailing / are stripped (alias: pabxConfig.sip_url) |
| cpaas_base_url | https://api-cpaas.omnix.co.id | CPaaS API base URL, must be https://. A trailing / is stripped (aliases: cpaasBaseUrl, env.CPAAS_BASE_URL, pabxConfig.cpaas_base_url) |
Use these when the SIP domain, the WebSocket host and the API live on different hosts:
OmnixWidget.init({
auth: { username: '1001', pwd_pbx: '***', pabx_host: 'sip.example.com' },
'X-CPAAS-Key-Fingerprint': 'sfp_v1_...',
cpaas_base_url: 'https://api.example.com/',
pbx_url: 'pbx.example.com', // SIP domain
sip_url: 'wss.example.com', // WSS host
});X-CPAAS-Key-Fingerprint
The API key Voxnix sends as a header to the CPaaS endpoints:
GET {cpaas_base_url}/voice/portal/extension?extension=<username> → read status / is_register
POST {cpaas_base_url}/voice/extensions/status → { extension, status, reason }Voxnix uses the first of these keys that is present:
| Priority | Key |
|----------|-----|
| 1 | 'X-CPAAS-Key-Fingerprint' |
| 2 | fingerprint |
| 3 | cpaasKeyFingerprint |
| 4 | env.fingerprint |
| 5 | env.X_CPAAS_Key_Fingerprint |
| 6 | pabxConfig['X-CPAAS-Key-Fingerprint'] |
Without a fingerprint, every agent-status call returns
false. WithautoReady: truethe engine then reportsFAILED("Failed to hit extension ready API") even though SIP registered fine.
Behaviour
| Option | Type | Default | Description |
|-------------|--------------------------|---------|-------------|
| autoReady | boolean | true | Set the agent Ready right after SIP registration. If false, the engine restores the current CPaaS status and waits for setReady() |
| ringtone | boolean | true | Play the built-in ringtone on an incoming call. Set false to play your own |
| contacts | { [number]: string } | — | Name lookup used for name when the caller sends no display name |
Callbacks
All callbacks are optional. Payloads are described in Events & callbacks.
| Option | Called when |
|--------------------|-------------|
| onRegisterStatus | Registration becomes 'REGISTERED' or FAILED. SIP_CONNECTED is not reported here; use on('register_status') to see it |
| onAgentStatus | Agent status changes |
| onCallStatus | Call status changes |
| onCallFailed | An outgoing call fails (rejected, busy, unreachable, …) |
| onIncomingCall | An incoming call starts ringing |
| onCallAnswered | A call (either direction) is connected |
| onCallEnded | A connected call ends |
| onCallUnanswered | An incoming call ends without being answered (missed, rejected, caller cancelled) |
Instance API
init() returns:
| Method | Returns | Description |
|--------|---------|-------------|
| dial(number) | — | Start an outgoing call to sip:<number>@<SIP domain>. Ignored if another call is active. Invalid characters → call_failed 'Invalid number' |
| answer() | — | Answer the ringing incoming call |
| reject() | — | Reject the ringing incoming call (same as hangup()) |
| hangup() | — | End the current call. Sends CANCEL while dialing, rejects while ringing, BYE when connected |
| mute(muted = true) | — | true mutes the microphone, false unmutes it. Only during a connected call |
| hold(onHold = true) | — | true holds the call, false resumes it. Only during a connected call |
| sendDtmf(tones) | — | Send DTMF during a connected call. Accepts one tone or a string: '5', '123#' |
| transfer(target, { attended = false }) | Promise<boolean> | (2.1+) Transfer the connected call to target. Blind: REFER straight away. Attended: hold the caller, call target, and REFER (Replaces) once it answers. On success our leg is hung up (onCallEnded fires) |
| setReady() | Promise<boolean> | Agent → Ready. Emits 'REGISTERED' + 'READY' on success |
| setNotReady() | Promise<boolean> | Agent → Not Ready (CPaaS Logout). Emits 'SIP_CONNECTED' + 'NOT_READY' |
| setAux(code = 'AUX', label = code) | Promise<boolean> | Agent → AUX. code is sent to CPaaS as the reason; label is for your UI |
| getState() | object | Snapshot of the current state, see State |
| on(event, cb) | () => void | Subscribe to an event. Returns an unsubscribe function |
| destroy() | — | End any call, unregister, close the WebSocket, and drop all listeners |
The call controls are safe to call at any time. Outside a valid state they do nothing.
State
getState() returns a fresh copy each time:
{
registerStatus: 'REGISTERED', // 'CONNECTING' | 'SIP_CONNECTED' | 'REGISTERED' | 'FAILED'
agentStatus: 'READY', // 'NOT_READY' | 'READY' | 'AUX'
currentAux: null, // { code, label } while AUX
callStatus: 'InCall', // 'Idle' | 'Calling...' | 'Ringing' | 'InCall'
callDuration: '01:23', // 'MM:SS' or 'HH:MM:SS'
isMuted: false,
isOnHold: false,
remote: { number: '1002', name: 'Jane', direction: 'outgoing', headers: {} }, // null when Idle
}Use it to draw your UI when it first mounts, then keep it in sync with events.
Events & callbacks
Subscribe with voice.on(event, cb). Events that have a callback option fire that callback too.
| Event | Payload | Callback |
|------------------------|---------|----------|
| register_status | 'SIP_CONNECTED' | 'REGISTERED' | { status: 'FAILED', reason } | onRegisterStatus (skips SIP_CONNECTED) |
| agent_status | 'READY' | 'NOT_READY' | 'AUX' (getState().currentAux holds the AUX code) | onAgentStatus |
| call_status | 'Idle' | 'Calling...' | 'Ringing' | 'InCall' | onCallStatus |
| call_duration | '00:42', every second while connected, '00:00' on end | — |
| call_failed | cause string, e.g. 'Busy', 'Rejected', 'Unavailable' | onCallFailed |
| incoming_call | { number, name, direction: 'incoming', headers } | onIncomingCall |
| sync_call_answered | { number, name, direction, headers } | onCallAnswered |
| sync_call_ended | { number, name, direction, headers, cause, originator } | onCallEnded |
| sync_call_unanswered | { number, name, direction, headers, cause, originator } | onCallUnanswered |
| mute_status | boolean | — |
| hold_status | boolean | — |
direction:'incoming'or'outgoing'.headers(2.1+): customX-*headers of an incoming INVITE, e.g.{ 'X-Call-Uuid': '…', 'X-Source-Type': 'whatsapp-call', 'X-Queue-Name': '…' }. Always{}for outgoing calls. These values come from the caller side, so treat them as untrusted.originator: who ended the call.'local'means you,'remote'means the other side,'system'means a network or SIP error.cause: the end reason, such as'Terminated','Canceled','Rejected','Busy'or'No Answer'.
Recipes
Outgoing call
voice.on('call_status', (s) => render(s));
voice.on('call_duration', (d) => (timer.textContent = d));
voice.on('call_failed', (cause) => toast(`Call failed: ${cause}`));
callBtn.onclick = () => voice.dial(input.value);
hangupBtn.onclick = () => voice.hangup();
muteBtn.onclick = () => voice.mute(!voice.getState().isMuted);
holdBtn.onclick = () => voice.hold(!voice.getState().isOnHold);
keypad.onclick = (e) => voice.sendDtmf(e.target.dataset.key);Incoming call
const voice = OmnixWidget.init({
...config,
onIncomingCall: ({ number, name }) => showRinging(name || number),
onCallAnswered: () => showInCall(),
onCallEnded: ({ cause }) => showIdle(),
onCallUnanswered: ({ number }) => addMissedCall(number),
});
answerBtn.onclick = () => voice.answer();
rejectBtn.onclick = () => voice.reject();Agent status (manual Ready)
const voice = OmnixWidget.init({ ...config, autoReady: false });
voice.on('register_status', (s) => {
if (s === 'SIP_CONNECTED') showSetReadyButton(); // connected, agent not Ready
if (s === 'REGISTERED') showDialer();
});
readyBtn.onclick = async () => { if (!(await voice.setReady())) toast('Failed to set Ready'); };
notReadyBtn.onclick = () => voice.setNotReady();
breakBtn.onclick = () => voice.setAux('BRK', 'Break');React hook
import { useEffect, useRef, useState } from 'react';
import { OmnixWidget } from 'voxnix';
export function useVoice(config) {
const ref = useRef(null);
const [state, setState] = useState(null);
useEffect(() => {
const voice = OmnixWidget.init(config);
ref.current = voice;
const sync = () => setState(voice.getState());
const events = ['register_status', 'agent_status', 'call_status', 'call_duration', 'mute_status', 'hold_status'];
events.forEach((e) => voice.on(e, sync));
sync();
return () => voice.destroy();
}, []); // init once
return [state, ref];
}
// const [state, voice] = useVoice(config);
// <button onClick={() => voice.current.dial('1002')}>Call</button>Vue 3
import { onMounted, onUnmounted, ref } from 'vue';
import { OmnixWidget } from 'voxnix';
export function useVoice(config) {
const state = ref(null);
let voice;
onMounted(() => {
voice = OmnixWidget.init(config);
const sync = () => (state.value = voice.getState());
['register_status', 'agent_status', 'call_status', 'call_duration', 'mute_status', 'hold_status']
.forEach((e) => voice.on(e, sync));
sync();
});
onUnmounted(() => voice?.destroy());
return { state, dial: (n) => voice.dial(n), hangup: () => voice.hangup() };
}Browser requirements
- HTTPS (or
localhost): browsers only give microphone access on secure origins. - Microphone permission: the browser asks for it on the first
dial()oranswer(). If the user denies it, the call fails (call_failed, oronCallUnansweredfor an incoming call). - Remote audio: Voxnix creates a hidden
<audio id="voxnix-remote-audio">indocument.body. Autoplay rules may block it until the user has clicked something on the page. Answering or dialing from a click handler counts as that interaction. - Ringtone: generated with WebAudio, which has the same user-interaction rule. Set
ringtone: falseto use your own sound.
Security
- Treat caller data as untrusted.
namecomes from the caller's SIP display name andnumberfrom their SIP URI. Render them withtextContent(or your framework's default escaping), neverinnerHTML. - Credentials live in the browser. The SIP password and
X-CPAAS-Key-Fingerprintare readable by anyone with devtools on that page. Fetch them from your backend after the agent logs in, use per-agent SIP credentials, and never hardcode them in frontend code. - HTTPS only.
cpaas_base_urlmust behttps://(http://localhostis allowed for development). Any other URL disables CPaaS API calls, and agent-status methods returnfalse. - Input validation.
dial()only accepts0-9 A-Z a-z + * # . _ -. Anything else emitscall_failedwith'Invalid number'. - Voxnix never logs credentials or API responses to the console.
Sample page
examples/index.html is a plain-HTML page that loads the script-tag
build. It calls OmnixWidget.init() with a config object, has buttons for every API method, and
shows a live getState() view and an event log.
npm run build
python3 -m http.server 5599 # from the repo root
# open http://localhost:5599/examples/Edit the CONFIG object at the top of the script to point it at your PBX.
Build
npm install
npm run build # ESM + UMD + IIFE into dist/
npm run build:lib # ESM + UMD only
npm run build:iife # IIFE only
npm run watch # rebuild ESM + UMD on changeMigrating from 1.x
- The UI is gone. There is no floating button or dialer panel. The options
placement,offset,container,showAuxButtonandauxOptionsare ignored. Build the UI with the Instance API and events. - New SIP engine. Call callbacks now receive
{ number, name, direction, ... }instead of a raw SIP session object. hold(true)now holds andhold(false)resumes (1.x had them reversed).onCallEndedfires once per call. Before, it could fire twice for incoming calls.- Call payloads include
headers(2.1+), theX-*headers of an incoming INVITE. Use it instead of reading headers from the raw SIP session. - Call transfer is
transfer(target, { attended })(2.1+) instead ofrefer()on the raw session. - New in 2.0:
sendDtmf,setReady,setNotReady,setAux,getState,on, plus the optionsonCallStatus,onCallFailed,ringtoneandcontacts. contactshas no built-in demo entries any more. Pass your own map.
Changelog
v2.1.0
transfer(target, { attended }): blind transfer (REFER), or attended (hold caller, consult call, REFER with Replaces)- Call payloads and
getState().remoteincludeheaders: theX-*headers of an incoming INVITE (X-Call-Uuid,X-Source-Type,X-Queue-Name, …) - Build runs on
prepack, sonpm pack/npm publishalways ship a freshdist
v2.0.0
- Removed the built-in React UI; Voxnix is now a headless SDK (no React / Tailwind dependencies)
- New internal SIP engine
- New API:
sendDtmf,setReady,setNotReady,setAux,getState,on; optionsonCallStatus,onCallFailed,ringtone,contacts - Configurable
cpaas_base_url,pbx_url(SIP domain),sip_url(WSS host) - Re-REGISTER + CPaaS poll when the extension reports
is_register = 0 - Security: credentials and API responses are no longer logged (1.0.23–1.0.24 logged the full config); HTTPS-only CPaaS URL;
dial()input validation; build before publish - Fixes: cancel while dialing, double
onCallEnded, invertedhold(), outgoing call duration and callbacks, busy second incoming call,destroy()not stopping SIP, IIFE globalOmnixWidget.init
v1.0.12
- Rollback DevToast / console interceptor feature
v1.0.11 and earlier
- Agent status bar (Ready / Not Ready / AUX) with configurable
auxOptions onAgentStatuscallbackautoReadyandshowAuxButtonconfig options- Keyboard shortcut support for dialer and DTMF
- React 17 / 18 compatibility layer
- Dual-SIP auth shape support (
authandpabxConfig.auth)
