WebRTC API - Sanas Developer Hub

WebRTC API Documentation

The WebRTC endpoint is another transport for the Stream API. It uses a WebSocket only for signaling, then carries Stream control messages over a WebRTC data channel and audio over WebRTC media tracks. This page covers the connection layer: WebSocket signaling, SDP offer/answer, trickle ICE, tracks, and connection lifetime. For the application message layer, including init, configure, flush, and server events, see the Stream message layer.

Endpoint

WebSocket: wss://api.sanaslt.com/v3/webrtc

Authenticate with one query parameter:

wss://api.sanaslt.com/v3/webrtc?token=<jwt_token>
wss://api.sanaslt.com/v3/webrtc?api_key=<api_key>

Authentication is checked before the WebSocket upgrade. If authentication fails, the server returns HTTP 401 rather than opening a WebSocket and sending a close frame.

Transport Model

Connection Flow

WebRTC serviceClientWebRTC serviceClientCreate RTCPeerConnection1Create messaging data channel2Add caller-provided audio track3Wait for negotiationneeded4Open WSS /v3/webrtc with auth5createOffer()6setLocalDescription(offer)7Send SDP offer over signaling WSS8Send SDP answer9setRemoteDescription(answer)10Send local ICE candidates11Send remote ICE candidates12Send end-of-candidates13Send end-of-candidates14Receive translated audio track15Data channel opens16Send init on data channel17Send Stream v3 messages on data channel18Send Stream v3 messages on data channel19Send microphone audio on media track20Send translation audio on media track21

The service supports one SDP offer per signaling WebSocket. To reconnect after closing or failing a peer connection, open a new WebSocket and create a new peer connection.

Signaling Messages

Signaling messages are JSON text frames on the WebSocket. The signaling layer has only four message kinds:

The answer includes the generated session_id and service version. The message shapes intentionally mirror browser WebRTC concepts: SDP descriptions contain an sdp string, and ICE candidates use the standard candidate JSON shape returned by browser APIs such as RTCIceCandidate.toJSON(). Do not send Stream v3 messages on the signaling WebSocket. Send them on the WebRTC data channel after it opens.

Trickle ICE

Use trickle ICE. Do not wait for ICE gathering to complete before sending the offer. On the client:

  1. Send the SDP offer as soon as createOffer() and setLocalDescription() complete.
  2. Send each icecandidate event to the service as a candidate signal.
  3. When the browser emits an icecandidate event with a null candidate, send end-of-candidates.
  4. Apply each server candidate with addIceCandidate().
  5. Treat server end-of-candidates as the remote end-of-candidates marker.

The service handles candidate timing as follows:

This means candidates can be sent immediately as they are discovered, even while the offer/answer exchange is still in progress.

Peer Connection Setup

The JS client creates the peer connection with bundlePolicy: "max-bundle", creates a data channel named messaging, and adds the caller-provided audio track before creating the offer:

const pc = new RTCPeerConnection({ bundlePolicy: "max-bundle" });
const controlChannel = pc.createDataChannel("messaging");

const localStream = new MediaStream([audioTrack]);
pc.addTrack(audioTrack, localStream);

const offer = await pc.createOffer({
  offerToReceiveAudio: true,
  offerToReceiveVideo: false,
});
await pc.setLocalDescription(offer);

Use one data channel for Stream v3 JSON messages. Audio should stay on media tracks, not on the data channel.

ICE Servers And Networking

The service gathers IPv4 UDP candidates and is configured with public STUN servers. In deployed environments, it can also use Metered TURN when TURN_USERNAME and TURN_PASSWORD are configured. The JS client currently uses the browser’s default ICE configuration and relies on the service-side ICE configuration. Custom clients may provide their own iceServers configuration. Include TURN for production clients that need to work on mobile networks, corporate networks, VPNs, or any environment where direct UDP may be blocked. Without TURN, peer connections can fail on relay-only networks. The service uses a bounded UDP port range. Locally this defaults to 10000-60000; deployed environments may choose a narrower range. Make sure the selected UDP range is reachable from clients or from the configured TURN relay.

Receiving Server Media

The service adds its outbound translation audio track before answering. Listen for the browser track event and attach the received stream to an audio output:

pc.ontrack = (event) => {
  const [stream] = event.streams;
audioElement.srcObject = stream;
};

The server encodes output audio as Opus over WebRTC. The Stream v3 data channel still emits text and lifecycle events such as transcription, translation, and boundaries; the audio bytes themselves are not sent as Stream v3 binary frames when using WebRTC.

Minimal Browser Signaling Skeleton

This intentionally omits the Stream v3 message contents. After the data channel opens, follow the Stream message layer for the JSON messages to send and receive.

const pc = new RTCPeerConnection({ bundlePolicy: "max-bundle" });
const controlChannel = pc.createDataChannel("messaging");
const localStream = new MediaStream([audioTrack]);

pc.addTrack(audioTrack, localStream);

pc.onnegotiationneeded = () => {
  const ws = new WebSocket("wss://api.sanaslt.com/v3/webrtc?api_key=...");

pc.onicecandidate = (event) => {
    if (ws.readyState !== WebSocket.OPEN) {
      return;
    }

if (event.candidate) {
      ws.send(JSON.stringify({
        type: "candidate",
        candidate: event.candidate.toJSON(),
      }));
    } else {
      ws.send(JSON.stringify({ type: "end-of-candidates" }));
    }
  };

ws.onmessage = async (event) => {
    const signal = JSON.parse(event.data);

if (signal.type === "answer") {
      await pc.setRemoteDescription({ type: "answer", sdp: signal.sdp });
      return;
    }

if (signal.type === "candidate") {
      await pc.addIceCandidate(signal.candidate);
      return;
    }

if (signal.type === "end-of-candidates") {
      await pc.addIceCandidate();
    }
  };

ws.onopen = async () => {
    const offer = await pc.createOffer({
      offerToReceiveAudio: true,
      offerToReceiveVideo: false,
    });
    await pc.setLocalDescription(offer);

ws.send(JSON.stringify({
      type: "offer",
      sdp: pc.localDescription?.sdp ?? offer.sdp,
    }));
  };
};

controlChannel.onopen = () => {
  // The JS client sends init here, then flushes queued Stream v3 messages.
};

Operational Notes