Skip to main content

WebSocket Protocol

The SFVoPI WebSocket protocol defines the events and commands for bidirectional audio streaming and call transfer: 10 incoming events from Superfone and 5 outgoing commands from your server.

Protocol Overview​

DirectionEvent TypePurpose
Incoming (Superfone → Your Server)startStream established, ready for media
IncomingmediaAudio chunk from caller
IncomingdtmfDTMF key pressed by caller
IncomingclearedAudioAcknowledgment of clearAudio command
IncomingplayedStreamAcknowledgment of checkpoint command
IncomingtransferAcceptedYour transfer command was accepted
IncomingtransferRejectedYour transfer command was refused
IncomingtransferRingingThe agent's phone is ringing
IncomingtransferAnsweredThe agent answered; your stream closes
IncomingtransferFailedThe agent did not take the call; the call is still yours
Outgoing (Your Server → Superfone)playAudioSend audio to caller
OutgoingclearAudioClear audio playback buffer
OutgoingcheckpointMark a named checkpoint in stream
OutgoingtransferTransfer the call to a Superfone app user
OutgoinghangupEnd the call

All messages are JSON-encoded strings sent over the WebSocket connection.


Incoming Events (Superfone → Your Server)​

1. start Event​

Sent when the WebSocket connection is established and the audio stream is ready. This is the first event you'll receive.

When: Immediately after WebSocket connection is established (or after reconnection).

Fields:

FieldTypeDescription
eventstringAlways "start"
streamIdstringUnique identifier for this audio stream. Format: SFV_STRM_{IN|OUT|BI}_<ULID> where the middle segment reflects stream direction (IN inbound, OUT outbound, BI bidirectional). Treat as opaque.
callIdstringUUID of the call

Example:

{
"event": "start",
"streamId": "SFV_STRM_IN_01JJXYZ123ABC456DEF789GHI",
"callId": "550e8400-e29b-41d4-a716-446655440000"
}

What to do:

  • Store streamId and callId for this connection
  • Initialize your audio processor (transcription, AI, recording)
  • Start any timers or background tasks
  • If this is a reconnection, resume processing from where you left off

TypeScript Interface:

interface StartEvent {
event: "start";
streamId: string;
callId: string;
}

2. media Event​

Sent continuously during the call, containing audio chunks from the caller.

When: Every 20ms (typically) while the caller is speaking or silent.

Fields:

FieldTypeDescription
eventstringAlways "media"
streamIdstringStream identifier from start event
media.payloadstringBase64-encoded PCM audio data
media.contentTypestringAudio codec: "audio/PCMA" (recommended), "audio/PCMU", or "audio/x-l16"
media.sampleRatenumberSample rate in Hz: 8000 or 16000

Example:

{
"event": "media",
"streamId": "SFV_STRM_IN_01JJXYZ123ABC456DEF789GHI",
"media": {
"payload": "//7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+...",
"contentType": "audio/PCMA",
"sampleRate": 8000
}
}

What to do:

  • Decode base64 payload to get raw PCM audio bytes
  • Process audio (transcribe, analyze, record)
  • Optionally send audio back using playAudio command

Decoding Audio:

// Decode base64 to Buffer
const audioBuffer = Buffer.from(mediaEvent.media.payload, 'base64');

// audioBuffer is now raw PCM audio (16-bit signed little-endian)
// For 8000 Hz: 160 samples = 320 bytes = 20ms of audio
// For 16000 Hz: 320 samples = 640 bytes = 20ms of audio

// Example: Save to file
fs.appendFileSync('recording.pcm', audioBuffer);

// Example: Send to transcription service
await transcriptionService.processAudio(audioBuffer);

TypeScript Interface:

interface MediaEvent {
event: "media";
streamId: string;
media: {
payload: string;
contentType: "audio/PCMA" | "audio/PCMU" | "audio/x-l16";
sampleRate: 8000 | 16000;
};
}

3. dtmf Event​

Sent when the caller presses a key on their phone's keypad.

When: Immediately after DTMF tone is detected by Superfone.

Fields:

FieldTypeDescription
eventstringAlways "dtmf"
streamIdstringStream identifier from start event
digitstringKey pressed: "0" to "9", "*", "#", "A" to "D"

Example:

{
"event": "dtmf",
"streamId": "SFV_STRM_IN_01JJXYZ123ABC456DEF789GHI",
"digit": "5"
}

What to do:

  • Handle IVR menu navigation ("Press 1 for sales, 2 for support")
  • Collect input (account number, PIN, confirmation)
  • Trigger actions (skip, replay, transfer to an agent)

Example Handler:

function handleDtmfEvent(event) {
console.log(`Caller pressed: ${event.digit}`);

switch (event.digit) {
case '1':
// Transfer to sales
playAudio(salesGreeting);
break;
case '2':
// Transfer to support
playAudio(supportGreeting);
break;
case '*':
// Go back to main menu
playAudio(mainMenu);
break;
case '#':
// Confirm input
processInput();
break;
default:
// Invalid input
playAudio(invalidInputMessage);
}
}

TypeScript Interface:

interface DtmfEvent {
event: "dtmf";
streamId: string;
digit: string;
}

4. clearedAudio Event​

Acknowledgment that the audio playback buffer has been cleared in response to your clearAudio command.

When: Immediately after Superfone processes your clearAudio command.

Fields:

FieldTypeDescription
eventstringAlways "clearedAudio"
streamIdstringStream identifier from start event
sequenceNumbernumberSequence number from your clearAudio command

Example:

{
"event": "clearedAudio",
"streamId": "SFV_STRM_IN_01JJXYZ123ABC456DEF789GHI",
"sequenceNumber": 42
}

What to do:

  • Confirm that audio buffer was cleared
  • Resume sending new audio (if needed)
  • Update UI or logs

Use Case: You sent a long audio message, but the caller interrupted. You send clearAudio to stop playback, then send new audio based on the interruption.

TypeScript Interface:

interface ClearedAudioEvent {
event: "clearedAudio";
streamId: string;
sequenceNumber: number;
}

5. playedStream Event​

Acknowledgment that a checkpoint you marked with the checkpoint command has been reached (all audio before the checkpoint has finished playing).

When: After all audio sent before the checkpoint command has been played to the caller.

Fields:

FieldTypeDescription
eventstringAlways "playedStream"
streamIdstringStream identifier from start event
namestringCheckpoint name from your checkpoint command

Example:

{
"event": "playedStream",
"streamId": "SFV_STRM_IN_01JJXYZ123ABC456DEF789GHI",
"name": "greeting_complete"
}

What to do:

  • Trigger next action (e.g., start listening for response)
  • Update state machine (e.g., move from "greeting" to "listening")
  • Log timing metrics

Use Case: You send a greeting message, then send a checkpoint named "greeting_complete". When you receive playedStream with name: "greeting_complete", you know the greeting finished playing and can now listen for the caller's response.

TypeScript Interface:

interface PlayedStreamEvent {
event: "playedStream";
streamId: string;
name: string;
}

Outgoing Commands (Your Server → Superfone)​

6. playAudio Command​

Send audio to the caller. Audio is queued and played in the order received.

When: Whenever you want to play audio to the caller (TTS, pre-recorded message, AI-generated speech).

Fields:

FieldTypeRequiredDescription
eventstringYesAlways "playAudio"
media.payloadstringYesBase64-encoded PCM audio data
media.contentTypestringYesAudio codec: "audio/PCMA" (recommended), "audio/PCMU", or "audio/x-l16"
media.sampleRatenumberYesSample rate in Hz: 8000 or 16000

Example:

{
"event": "playAudio",
"media": {
"payload": "//7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+...",
"contentType": "audio/PCMA",
"sampleRate": 8000
}
}

Encoding Audio:

// Encode PCM audio to base64
const audioBuffer = fs.readFileSync('greeting.pcm');
const base64Audio = audioBuffer.toString('base64');

// Send playAudio command
const command = {
event: 'playAudio',
media: {
payload: base64Audio,
contentType: "audio/PCMA",
sampleRate: 8000,
},
};

ws.send(JSON.stringify(command));

Notes:

  • Audio is queued and played in FIFO order
  • Use the same codec and sample rate as specified in your Stream JSON response
  • Audio chunks are typically 20ms (160 samples at 8000 Hz, 320 samples at 16000 Hz)
  • You can send multiple playAudio commands in rapid succession (they will be queued)
Payload Size Limit

Each playAudio frame's base64 payload must be at most ~1.5 MB (1,500,000 characters). Frames exceeding this limit are silently dropped. In practice this is far larger than any real-time voice chunk — if you're approaching it, you're almost certainly buffering too much audio per frame. Send audio in small chunks (20–200 ms) instead of large blocks.

TypeScript Interface:

interface PlayAudioCommand {
event: "playAudio";
media: {
payload: string;
contentType: "audio/PCMA" | "audio/PCMU" | "audio/x-l16";
sampleRate: 8000 | 16000;
};
}

7. clearAudio Command​

Clear the audio playback buffer. All queued audio is discarded immediately.

When: When you need to interrupt playback (e.g., caller interrupted, error occurred, new context).

Fields:

FieldTypeRequiredDescription
eventstringYesAlways "clearAudio"
sequenceNumbernumberYesUnique sequence number for tracking acknowledgment

Example:

{
"event": "clearAudio",
"sequenceNumber": 42
}

What happens:

  1. Superfone immediately stops playing audio to the caller
  2. All queued playAudio commands are discarded
  3. Superfone sends clearedAudio event with the same sequenceNumber

Example:

let clearSequence = 0;

function clearAudioBuffer(ws) {
clearSequence++;

const command = {
event: 'clearAudio',
sequenceNumber: clearSequence,
};

ws.send(JSON.stringify(command));
console.log(`Sent clearAudio with sequence ${clearSequence}`);
}

// Handle acknowledgment
function handleClearedAudioEvent(event) {
console.log(`Audio cleared, sequence ${event.sequenceNumber}`);
// Now safe to send new audio
}

Use Case: Voice AI bot is speaking a long response, but the caller interrupts. You send clearAudio to stop playback, then send new audio based on the interruption.

TypeScript Interface:

interface ClearAudioCommand {
event: "clearAudio";
sequenceNumber: number;
}

8. checkpoint Command​

Mark a named checkpoint in the audio stream. When all audio sent before this checkpoint has finished playing, Superfone sends a playedStream event.

When: When you need to know when specific audio has finished playing (e.g., greeting complete, question asked).

Fields:

FieldTypeRequiredDescription
eventstringYesAlways "checkpoint"
streamIdstringYesStream identifier from start event
namestringYesUnique name for this checkpoint

Example:

{
"event": "checkpoint",
"streamId": "SFV_STRM_IN_01JJXYZ123ABC456DEF789GHI",
"name": "greeting_complete"
}

What happens:

  1. Superfone marks the current position in the audio queue
  2. When all audio before this checkpoint finishes playing, Superfone sends playedStream event with the same name
Pending Checkpoint Queue Limit

Superfone holds up to 100 pending checkpoints per stream. If you queue a 101st without receiving playedStream acknowledgments for earlier ones, the new checkpoint is silently dropped. Consume playedStream events as they arrive to keep the queue drained.

Example:

// Send greeting audio
sendAudioToCallee(greetingAudio, ws);

// Mark checkpoint
const checkpoint = {
event: 'checkpoint',
streamId: currentStreamId,
name: 'greeting_complete',
};
ws.send(JSON.stringify(checkpoint));

// Later, when you receive playedStream event:
function handlePlayedStreamEvent(event) {
if (event.name === 'greeting_complete') {
console.log('Greeting finished playing, now listening for response');
startListening();
}
}

Use Cases:

  • Turn-taking: Know when to stop speaking and start listening
  • State machine: Transition between states (greeting → listening → responding)
  • Timing metrics: Measure how long audio takes to play
  • Synchronization: Coordinate audio playback with other actions

TypeScript Interface:

interface CheckpointCommand {
event: "checkpoint";
streamId: string;
name: string;
}

9. transfer Command (call transfer to Superfone app)​

Transfer the call to a Superfone app user, for example to hand a caller from your voice bot to a human agent. The agent's Superfone app rings for about 20 seconds.

This reaches only users of the Superfone app in your own Superfone organisation. To send a call to any other number, see Transferring to any other number.

The transfer works only when the agent's Superfone number is in the same number series as the number the call came in on. If it is in a different series, the transfer is rejected with TARGET_DIFFERENT_SERIES and the agent's app does not ring.

Not available on INBOUND streams

A stream opened with direction: "INBOUND" is listen-only: Superfone never reads commands sent on it, so it cannot send transfer. Open the stream with BOTH or OUTBOUND if your bot may need to transfer a call.

Fields:

FieldTypeRequiredDescription
eventstringYesAlways "transfer"
tostringYesPhone number of a Superfone user in the same Superfone organisation as your SFVoPI app, in E.164 format with a leading +
reasonstringNoFree text shown to the agent in the app when the call rings. Longer than 256 bytes is truncated
holdbooleanNoDefaults to true. See below

Example:

{
"event": "transfer",
"to": "+91XXXXXXXXXX",
"reason": "Caller wants to cancel their order",
"hold": true
}

Hold behaviour:

  • hold: true (default): the caller hears hold music while the agent's phone rings. Any audio you queued is discarded, and playAudio commands are ignored until the transfer ends. If the transfer fails, playAudio works again.
  • hold: false: your bot keeps talking to the caller while the agent's phone rings.

In both cases you keep receiving media and dtmf events from the caller until the agent answers.

Rules:

  • This is a cold transfer. When the agent answers, Superfone closes your stream with a normal close (code 1000) and the caller talks to the agent. You cannot rejoin the call.
  • The agent's Superfone number must be in the same number series as the number the call came in on. If not, the transfer is rejected with TARGET_DIFFERENT_SERIES.
  • Only one transfer can be pending at a time. A second transfer while one is pending is rejected with ALREADY_TRANSFERRING.
  • The transfer command is available on streams whose direction is OUTBOUND or BOTH. A stream opened with INBOUND (listen-only) cannot send transfer or any other command.
  • If the transfer does not succeed, the call stays with your bot. You decide what to do next: try another number, keep talking, or send hangup.

What happens next:

  1. Superfone replies with transferAccepted or transferRejected.
  2. After an accepted transfer, transferRinging arrives once the agent's phone is ringing.
  3. The attempt ends with transferAnswered or transferFailed.

10. hangup Command​

End the call.

Fields:

FieldTypeRequiredDescription
eventstringYesAlways "hangup"

Example:

{
"event": "hangup"
}

If a transfer is still ringing when you send hangup, the ring is cancelled and the call ends.


Transferring to any other number​

Superfone does not transfer a call to an arbitrary phone number. A transfer to a number that is not a Superfone app user in your organisation is rejected with NOT_ORG_USER. If you need that, build it yourself with the bridge method:

  1. Keep the first call and its stream open.
  2. Place a second call to the target number with the Initiate Call API, with its own audio stream to your server.
  3. Relay audio between the two streams in your own application: send what you receive in media events on one stream as playAudio on the other, in both directions.

Each leg is a separate call and is billed separately. You are responsible for ending both legs when the conversation is over.


Transfer Status Events (Superfone → Your Server)​

Superfone sends these events in response to a transfer command. They all share the fields below.

FieldTypeDescription
eventstringThe event name
streamIdstringStream identifier from start event
transferIdstringIdentifier of this transfer attempt. Absent on transferRejected. Opaque
tostringThe number you asked to transfer to. Absent on a transferRejected caused by a malformed command
codestringPresent only on transferRejected and transferFailed. See Transfer codes

transferAccepted​

The command passed validation and the transfer has started.

{
"event": "transferAccepted",
"streamId": "SFV_STRM_BI_01JJXYZ123ABC456DEF789GHI",
"transferId": "trf_abc123",
"to": "+91XXXXXXXXXX"
}

transferRejected​

The command was refused and nothing was started. The agent's app does not ring. The call is unchanged and you can send another transfer.

{
"event": "transferRejected",
"streamId": "SFV_STRM_BI_01JJXYZ123ABC456DEF789GHI",
"to": "+91XXXXXXXXXX",
"code": "NOT_ORG_USER"
}

transferRinging​

The agent's Superfone app has been notified and is ringing.

{
"event": "transferRinging",
"streamId": "SFV_STRM_BI_01JJXYZ123ABC456DEF789GHI",
"transferId": "trf_abc123",
"to": "+91XXXXXXXXXX"
}

transferAnswered​

The agent answered. This is the last event on the stream: Superfone then closes the WebSocket with a normal close (code 1000), and the caller is connected to the agent. Stop sending audio and release your session for this call.

{
"event": "transferAnswered",
"streamId": "SFV_STRM_BI_01JJXYZ123ABC456DEF789GHI",
"transferId": "trf_abc123",
"to": "+91XXXXXXXXXX"
}

transferFailed​

The agent did not take the call. The caller is back with your bot, hold music stops, and playAudio works again. You can send a new transfer, keep talking, or send hangup.

{
"event": "transferFailed",
"streamId": "SFV_STRM_BI_01JJXYZ123ABC456DEF789GHI",
"transferId": "trf_abc123",
"to": "+91XXXXXXXXXX",
"code": "NO_ANSWER"
}

A transfer to a number can fail before transferRinging is sent, for example with NOT_DELIVERED. If the caller hangs up while the transfer is pending, the call ends and no further transfer event is sent.

If your WebSocket closes while the agent's phone is ringing, for example because of stream_timeout or a network drop, the caller keeps waiting for the agent. If the agent answers, they are connected as usual. If the transfer fails, the call ends, since your bot is no longer on the call to take it back.

Example handler:

function escalate(ws, number, reason) {
ws.send(JSON.stringify({ event: 'transfer', to: number, reason }));
}

function handleTransferEvent(ws, event) {
switch (event.event) {
case 'transferRejected':
case 'transferFailed':
if (event.code === 'NO_ANSWER' || event.code === 'DECLINED') {
escalate(ws, backupAgentNumber, 'Caller still waiting');
} else {
sayAgentsUnavailable();
}
break;
case 'transferAnswered':
cleanupSession();
break;
}
}

Complete Example​

Here's a complete WebSocket handler that processes the audio event types:

const WebSocket = require('ws');

const wss = new WebSocket.Server({ port: 3000 });

wss.on('connection', (ws) => {
let streamId = null;
let callId = null;
let clearSequence = 0;

ws.on('message', (data) => {
const event = JSON.parse(data.toString());

switch (event.event) {
case 'start':
streamId = event.streamId;
callId = event.callId;
console.log(`Stream started: ${streamId}`);
break;

case 'media':
// Decode and process audio
const audioBuffer = Buffer.from(event.media.payload, 'base64');
processAudio(audioBuffer);
break;

case 'dtmf':
console.log(`DTMF pressed: ${event.digit}`);
handleDtmfInput(event.digit);
break;

case 'clearedAudio':
console.log(`Audio cleared: sequence ${event.sequenceNumber}`);
break;

case 'playedStream':
console.log(`Checkpoint reached: ${event.name}`);
handleCheckpoint(event.name);
break;
}
});

// Send audio to caller
function sendAudio(audioBuffer) {
const command = {
event: 'playAudio',
media: {
payload: audioBuffer.toString('base64'),
contentType: "audio/PCMA",
sampleRate: 8000,
},
};
ws.send(JSON.stringify(command));
}

// Clear audio buffer
function clearAudio() {
clearSequence++;
const command = {
event: 'clearAudio',
sequenceNumber: clearSequence,
};
ws.send(JSON.stringify(command));
}

// Mark checkpoint
function markCheckpoint(name) {
const command = {
event: 'checkpoint',
streamId,
name,
};
ws.send(JSON.stringify(command));
}
});

Next Steps​

  • Audio Processing — Build audio processors with examples
  • Overview — Learn about codecs, sample rates, and architecture
  • FAQ — Duplex streaming, barge-in, buffer clearing, and more
  • Answer Webhook — Configure Stream JSON response
  • Examples — See complete working examples