> ## Documentation Index
> Fetch the complete documentation index at: https://developer.sanas.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# WebRTC API

> Browser-native transport for the Stream API using WebRTC data channels and media tracks

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](/Docs/Language-Translation/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

* The signaling WebSocket is used for SDP and ICE only.
* Keep the signaling WebSocket open for the lifetime of the WebRTC session. If it closes, the service closes the associated peer connection.
* The client creates the WebRTC data channel. The service accepts the incoming data channel and uses it for Stream v3 JSON messages.
* The JS client uses a data channel named `messaging`; the service accepts any label.
* Client microphone audio is sent as a WebRTC audio media track.
* Server translation audio is returned as a WebRTC audio media track.
* The WebRTC audio codec is Opus.

## Connection Flow

```mermaid theme={null}
sequenceDiagram
    autonumber
    participant Client
    participant Service as WebRTC service

    Client->>Client: Create RTCPeerConnection
    Client->>Client: Create messaging data channel
    Client->>Client: Add caller-provided audio track
    Client->>Client: Wait for negotiationneeded
    Client->>Service: Open WSS /v3/webrtc with auth
    Client->>Client: createOffer()
    Client->>Client: setLocalDescription(offer)
    Client->>Service: Send SDP offer over signaling WSS
    Service-->>Client: Send SDP answer
    Client->>Client: setRemoteDescription(answer)
    Client->>Service: Send local ICE candidates
    Service-->>Client: Send remote ICE candidates
    Client->>Service: Send end-of-candidates
    Service-->>Client: Send end-of-candidates
    Client->>Client: Receive translated audio track
    Client->>Client: Data channel opens
    Client->>Service: Send init on data channel
    Client->>Service: Send Stream v3 messages on data channel
    Service-->>Client: Send Stream v3 messages on data channel
    Client->>Service: Send microphone audio on media track
    Service-->>Client: Send translation audio on media track
```

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:

* Client to server: `offer`
* Server to client: `answer`
* Either direction: `candidate`
* Either direction: `end-of-candidates`

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:

* Remote ICE candidates received before the service has accepted the offer are queued and applied after the offer is processed.
* Local ICE candidates gathered by the service before the answer is sent are queued and flushed immediately after the answer.
* When service-side ICE gathering completes, the service sends `end-of-candidates`.

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:

```javascript theme={null}
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:

```javascript theme={null}
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](/Docs/Language-Translation/Stream-Message-Layer) for the JSON messages to send and receive.

```javascript theme={null}
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

* Non-text WebSocket frames are ignored, except close frames, which end the session.
* If the peer connection enters `disconnected` or `failed`, the service allows a reconnect grace period before closing the session.
* Idle sessions are closed after the configured inactivity timeout.
* Renegotiation is not supported on an existing signaling WebSocket. Start a new session instead.
