WebSocket Reference
This page is the message-level reference for the Vodia PBX WebSocket protocol. This page assumes you have already established a session and opened the socket.
The reference is organised as follows:
- Message envelope conventions — how messages are framed and discriminated
- Quick reference — every message type at a glance
- Bootstrap sequence — what the client must send on connect
- Outbound call timeline — annotated frame-by-frame
- Inbound call timeline — annotated frame-by-frame
- Message details — schema and real example for every message
- Field reference — deep-dive on the largest payloads (
invitesdp,call-state.calls[i]) - Reconnection — handling disconnects
Message envelope conventions
Every WebSocket frame is a UTF-8 JSON string. There are no binary frames. Two discriminator styles coexist:
Action-discriminated messages have a top-level action field naming the operation:
{"action": "blf", "add": ["500", "502"]}
This is the dominant style. Both directions use it, and the action name selects the rest of the schema. All client→server messages use this style; most server→client messages do too.
Flat-field messages have no action field. Instead, one of a small set of reserved keys is present at the top level, and the key itself names the message type. They are only sent by the server, and they all relate to WebRTC/SIP signalling:
{"invitesdp": "v=0\r\no=- ...", "callid": "abc@pbx", "from": "...", ...}
The reserved flat-field keys are: sdp, invitesdp, candidate, bye, ringtones, response, cancel, cancelresponse, info, change. A client implementation typically checks for these keys first, then falls through to switching on action.
The official websocket.js module follows this exact precedence order:
if (msg.sdp) { /* handle SDP */ }
else if (msg.invitesdp) { /* handle inbound INVITE */ }
else if (msg.candidate) { /* handle ICE candidate */ }
else if (msg.bye) { /* handle hangup */ }
// ...
else switch (msg.action) { /* handle action messages */ }
Quick reference
Client → server
| Action | Purpose | Group |
|---|---|---|
own-calls | Subscribe to own call-state stream | Bootstrap |
orbit-calls | Subscribe to call-park orbit updates | Bootstrap |
domain-calls | Subscribe to tenant-wide call state | Bootstrap |
ringtones | Request the user's ringtone set | Bootstrap |
sip-register | Register browser as a SIP endpoint | Bootstrap |
blf | Subscribe / unsubscribe BLF for extensions | Monitoring |
sdp-packet | Send WebRTC SDP offer (outbound call) | Outbound call |
ice-candidate | Send ICE candidate | Both directions |
sip-ack | Send SIP ACK | Outbound call |
sip-ringing | Send SIP 180 Ringing | Inbound call |
sdp-200ok | Send SIP 200 OK with SDP answer (accept inbound) | Inbound call |
sip-bye | Hang up call | In-call control |
bye-response | Acknowledge a remote BYE | In-call control |
mute | Mute / unmute call | In-call control |
wrtc-hold | Hold / resume call | In-call control |
rec-call | Start / stop call recording | In-call control |
Server → client (action-discriminated)
| Action | Purpose | Group |
|---|---|---|
sip-register | Confirms SIP registration | Bootstrap ack |
new-vm | New voicemail count | Counter |
missed-calls | Missed call counter update | Counter |
chats | Unread chat counter update | Counter |
blf | BLF state update for a subscribed extension | Monitoring |
call-state | Full call object for all visible calls | Monitoring |
user-change | User settings or state changed (refresh hint) | Monitoring |
stats-change | Statistics changed (refresh hint) | Monitoring |
activity | Activity-feed update | Monitoring |
chat | New chat / SMS thread notification | Messaging |
callerid | Caller ID for an active call | Per-call |
rec-start | Recording started | Per-call |
rec-stop | Recording stopped | Per-call |
alert | System alert | System |
Server → client (flat-field)
| Top-level key | Purpose | Group |
|---|---|---|
sdp | WebRTC SDP from server (e.g. 183, 200 OK) | Outbound call |
invitesdp | Inbound SIP INVITE with SDP | Inbound call |
candidate | Server ICE candidate | Both directions |
bye | Remote hangup | In-call control |
ringtones | Initial ringtone list | Bootstrap ack |
Bootstrap sequence
Immediately after onopen fires, the official web client sends six messages in this exact order. Most of them are subscriptions that prime the server to push state for this session. A custom client should send the same sequence — or at least the subset relevant to its use case — before the server starts pushing useful state.
↑ {"action":"own-calls","subscribe":true}
↑ {"action":"orbit-calls","subscribe":true}
↑ {"action":"blf","add":["<own-username>"]}
↑ {"action":"ringtones"}
↑ {"action":"domain-calls","subscribe":true}
↑ {"action":"sip-register","useragent":"<UA string>"}
Within ~300ms the server replies with the initial state for each subscription:
↓ {"action":"blf","user":"<own-username>","calls":[]}
↓ {"action":"blf","user":"<own-username>","dnd":false}
↓ {"action":"blf","user":"<own-username>","chat":true}
↓ {"action":"blf","user":"<own-username>","mwi":false}
↓ {"ringtones":["/img/ringer1.wav","/img/ringer2.wav", ...]}
↓ {"action":"call-state","time":...,"calls":[]}
↓ {"action":"sip-register","add":"wrtc-52950","expires":3600}
↓ {"action":"new-vm","count":0}
↓ {"action":"missed-calls","count":0,"reload":false}
↓ {"action":"chats","count":0}
The sip-register server reply contains the add field (registration ID, e.g. "wrtc-52950") and expires in seconds. The client should refresh the registration before expires elapses by sending sip-register again. The official client refreshes at expires/2 or expires - 15, whichever is larger.
Following bootstrap, the client typically issues blf add:[<extension>,...] for any other extensions it wants to monitor.
Outbound call timeline
The frames below are taken from a real outbound call from extension 501 to +15555550199. Times are relative to the first frame, in seconds. Phone numbers and SDP material have been sanitised.
0.000 ↑ {"action":"sdp-packet","callid":"6f273940@app","to":"+15555550199",
"sdp":{"sdp":"v=0\r\n...","type":"offer"}}
0.060 ↑ {"action":"ice-candidate","callid":"6f273940@app",
"candidate":{"candidate":"candidate:1566297096 1 udp 2122260223 192.168.1.10 54377 typ host ...",
"sdpMid":"0","sdpMLineIndex":0,"usernameFragment":"dLkT"}}
0.062 ↑ {"action":"ice-candidate","callid":"6f273940@app","candidate":{...}}
0.150 ↓ {"sdp":"v=0\r\n...","code":183,"cseq":"1","callid":"6f273940@app",
"from":"<sip:501@pbx.example.com>;tag=xznau4","from-user":"501","from-display":"",
"to":"<sip:+15555550199@pbx.example.com>;tag=d7aff443db","to-user":"+15555550199","to-display":"",
"video":"false"}
0.180 ↓ {"candidate":"candidate:401712 1 udp 394836 203.0.113.10 62650 typ host ...",
"callid":"6f273940@app",
"adr":[["10.0.0.6",62650],["203.0.113.10",62650]]}
0.200 ↓ {"action":"callerid","callid":"6f273940@app",
"assertedid":"\"Bob Smith\" <sip:5550199@pbx.example.com>",
"name":"Bob Smith","number":"5550199"}
1.000 ↓ {"action":"call-state","time":...,"calls":[
{"id":10,"type":"extcall","from-number":"501","to-number":"+15555550199",
"account":"501","state":"alerting", ...}]}
5.500 ↓ {"action":"call-state","time":...,"calls":[
{"id":10,...,"state":"connected","connect":"...","rec":true}]}
5.510 ↑ {"action":"sip-ack","callid":"6f273940@app"}
7.200 ↑ {"action":"rec-call","id":"6f273940@app","startstop":"on"}
7.250 ↓ {"action":"rec-start","callid":"6f273940@app"}
12.000 ↑ {"action":"mute","callid":"6f273940@app","muted":true}
14.500 ↑ {"action":"mute","callid":"6f273940@app","muted":false}
20.000 ↑ {"action":"wrtc-hold","callid":"6f273940@app","holdcmd":"sendonly"}
20.150 ↓ {"action":"call-state","time":...,"calls":[{"id":10,...,"state":"connected"}]}
35.000 ↑ {"action":"rec-call","id":"6f273940@app","startstop":"off"}
35.150 ↓ {"action":"rec-stop","callid":"6f273940@app"}
42.000 ↑ {"action":"sip-bye","callid":"6f273940@app"}
42.150 ↓ {"action":"call-state","time":...,"calls":[]}
Notes:
- The client creates the
callidlocally (e.g. random hex +@app). All call-scoped messages reference the same callid for the lifetime of the call. sdp-packetcarries an SDP offer. The server's flat-fieldsdpreply is the SDP answer wrapped in a SIP envelope (thecodefield is the SIP status code:183for Session Progress,200for OK).ice-candidateis trickled — the client sends as many as it gathers; the server replies with its own candidates as separate flat-fieldcandidatemessages.call-statetransitions throughalerting→connected→ (other transitional states likeconfirmed,early) → empty array on hangup.- The recording flag (
recin the call object) is independent of explicitrec-callrequests — the PBX may auto-record per policy.rec-start/rec-stopserver pushes signal the actual state change.
Inbound call timeline
A real inbound queue call from +15555550199 into queue 400, which rang extensions 501 and 502 simultaneously. Extension 501's client picked up.
0.000 ↓ {"action":"call-state","time":...,"calls":[
{"id":13,"type":"acd","from-number":"+15555550199","to-number":"+15555550100",
"account":"400","state":"alerting","extension":["501","502"],
"trunk":["Main Trunk*"], ...}]}
0.007 ↓ {"invitesdp":"v=0\r\n...","callid":"7swcwut@pbx","cseq":"5454",
"from":"\"5550199 (0050)\" <sip:0050@pbx.example.com>;tag=584406335",
"from-user":"0050","from-display":"5550199 (0050)",
"to":"\"Main Trunk\" <sip:+15555550100@pbx.example.com>",
"to-user":"+15555550100","to-display":"Main Trunk",
"assertedid":"","asserted-user":"","asserted-display":"",
"spamscore":"adrbook","alertinfo":"/img/ringer2.wav",
"diversion":"","group":"400","video":"false"}
0.023 ↑ {"action":"sip-ringing","callid":"7swcwut@pbx","cseq":"5454"}
0.162 ↓ {"candidate":"candidate:... 203.0.113.10 62930 ...","callid":"7swcwut@pbx"}
0.162 ↓ {"action":"user-change","extensions":"501"}
0.162 ↓ {"action":"blf","user":"501","calls":[{"state":"early"}]}
# User clicks Accept (12s of ringback shown to caller)
12.085 ↑ {"action":"sdp-200ok","callid":"7swcwut@pbx","cseq":"5454",
"sdp":{"sdp":"v=0\r\n...","type":"answer"}}
12.086 ↑ {"action":"ice-candidate","callid":"7swcwut@pbx","candidate":{...}}
12.205 ↑ {"action":"ice-candidate","callid":"7swcwut@pbx","candidate":{...}}
14.250 ↓ {"action":"rec-start","callid":"7swcwut@pbx"}
14.420 ↓ {"action":"blf","user":"501","calls":[{"state":"confirmed"}]}
# Call is now connected; conversation continues...
19.200 ↑ {"action":"sip-bye","callid":"7swcwut@pbx"}
19.370 ↓ {"action":"call-state","time":...,"calls":[]}
19.370 ↓ {"action":"missed-calls","count":...,"reload":false}
Notes:
- The full SIP INVITE arrives in a single
invitesdpmessage with rich routing metadata (group,diversion,alertinfo,spamscore). See Field reference: invitesdp. - The
call-stateentry hastype: "acd"because this was a queue call. Both ringing endpoints appear inextension: ["501", "502"]. A direct extension-to-extension call would havetype: "extcall". sip-ringingis sent immediately on receiving theinvitesdpto trigger ringback on the caller side. Delay it and the caller hears silence.sdp-200okaccepts the call. The innersdpfield is an object with{type: "answer", sdp: "..."}— not a bare string. This matches whatRTCPeerConnection.createAnswer()produces.
Message details
Each entry below documents one message type. Schemas describe the JSON payload; examples are real frames from test captures, sanitised.
Client → server messages
own-calls
Subscribe to call-state events for the current user. Sent during bootstrap.
{
"action": "own-calls",
"subscribe": true
}
Fields:
action(string, required):"own-calls".subscribe(boolean, required):trueto subscribe.
orbit-calls
Subscribe to call-park orbit updates. Sent during bootstrap.
{
"action": "orbit-calls",
"subscribe": true
}
Fields:
action(string, required):"orbit-calls".subscribe(boolean, required):trueto subscribe.
domain-calls
Subscribe to tenant-wide call state. This is what gives a monitoring or wallboard client visibility into other extensions' calls. Without this subscription, call-state only carries the current user's own calls.
{
"action": "domain-calls",
"subscribe": true
}
Fields:
action(string, required):"domain-calls".subscribe(boolean, required):trueto subscribe.
ringtones (send)
Request the list of ringtone URLs the user has configured. The server replies with a flat-field ringtones message.
{
"action": "ringtones"
}
Fields:
action(string, required):"ringtones".
sip-register (send)
Register the browser as a SIP endpoint (i.e. start the web softphone). After registration the user can place outbound WebRTC calls and receive inbound calls via the same WebSocket.
{
"action": "sip-register",
"useragent": "Chrome/537.36 Macintosh/70.3.beta"
}
Fields:
action(string, required):"sip-register".useragent(string, required): User-Agent string the PBX should record for this registration.
The server replies with {"action": "sip-register", "add": "wrtc-NNNNN", "expires": 3600}. The client must re-send sip-register before expires seconds elapse to keep the registration alive.
blf (send)
Subscribe or unsubscribe to BLF (busy-lamp-field) updates for one or more extensions. Used to drive BLF lamps, presence indicators, console monitoring grids, and any UI that shows the state of other extensions.
Subscribe form:
{
"action": "blf",
"add": ["500", "502", "503", "504"]
}
Unsubscribe form:
{
"action": "blf",
"sub": ["503", "504"]
}
Fields:
action(string, required):"blf".add(array of strings, optional): Extension usernames to start watching.sub(array of strings, optional): Extension usernames to stop watching.
Exactly one of add or sub should be present per message. After subscribing, the server pushes a blf message for each watched extension immediately (initial state) and again whenever that extension's state changes.
sdp-packet
Send an SDP offer to initiate an outbound WebRTC call. This is the first message in the outbound call flow.
{
"action": "sdp-packet",
"callid": "6f273940@app",
"to": "+15555550199",
"sdp": {
"sdp": "v=0\r\no=- ... ",
"type": "offer"
}
}
Fields:
action(string, required):"sdp-packet".callid(string, required): Client-generated call ID. Convention: 8 hex chars +@app.to(string, required): Destination extension or E.164 phone number.sdp(object, required): SDP offer object as produced byRTCPeerConnection.createOffer()—{type: "offer", sdp: "<sdp-string>"}.
ice-candidate
Send a WebRTC ICE candidate to the server. Trickled — send each candidate as soon as RTCPeerConnection.onicecandidate fires.
{
"action": "ice-candidate",
"callid": "6f273940@app",
"candidate": {
"candidate": "candidate:1566297096 1 udp 2122260223 192.168.1.10 54377 typ host generation 0 ufrag dLkT network-id 1 network-cost 10",
"sdpMid": "0",
"sdpMLineIndex": 0,
"usernameFragment": "dLkT"
}
}
Fields:
action(string, required):"ice-candidate".callid(string, required): Call ID this candidate belongs to.candidate(object, required): StandardRTCIceCandidateInitobject.