Voisnap Docs
Real-Time APIs

WebRTC

Embed browser-based voice calls using WebRTC — create a session, exchange SDP via WebRtcHub, and stream audio with RTCPeerConnection.

WebRTC

WebRTC allows users to make voice calls to a Voisnap agent directly from their browser using their microphone — no phone number required. Voisnap acts as the WebRTC peer and handles the agent's audio pipeline.


Flow overview

1. POST /api/v1/webrtc/sessions → sessionToken + iceServers
2. Connect WebRtcHub (SignalR)
3. Create RTCPeerConnection with iceServers
4. Create SDP offer → SendOfferAsync
5. Receive SDP answer → setRemoteDescription
6. Exchange ICE candidates → SendIceCandidateAsync / IceCandidateReceived
7. Audio flows bidirectionally
8. EndSessionAsync to hang up

Step 1: Create a WebRTC session

POST /api/v1/webrtc/sessions
curl -X POST https://api.voisnap.ai/api/v1/webrtc/sessions \
  -H "Authorization: Bearer eyJhbGci..." \
  -H "Content-Type: application/json" \
  -d '{
    "agentId": "agt_01HXK8Z3MNPQRS",
    "metadata": { "userId": "user_12345" }
  }'

Response:

{
  "sessionId": "wrtc_01HXMN8ZABC",
  "sessionToken": "wrtc_tok_eyJhbGci...",
  "expiresAt": "2025-06-16T14:35:00Z",
  "iceServers": [
    {
      "urls": ["stun:stun.voisnap.ai:3478"]
    },
    {
      "urls": [
        "turn:turn.voisnap.ai:3478?transport=udp",
        "turn:turn.voisnap.ai:3478?transport=tcp"
      ],
      "username": "session_abc",
      "credential": "turn_credential_xyz"
    }
  ]
}

Step 2: Connect WebRtcHub

wss://api.voisnap.ai/hubs/webrtc?sessionToken={sessionToken}&access_token={jwt}

Client → Server methods

SendOfferAsync

Send your RTCPeerConnection SDP offer.

await connection.invoke("SendOfferAsync", {
  sdp: offer.sdp,
  type: offer.type
});

SendIceCandidateAsync

Send a locally discovered ICE candidate.

await connection.invoke("SendIceCandidateAsync", {
  candidate: candidate.candidate,
  sdpMid: candidate.sdpMid,
  sdpMLineIndex: candidate.sdpMLineIndex
});

EndSessionAsync

await connection.invoke("EndSessionAsync", "user_ended");

Server → Client events

SdpAnswerReceived

Voisnap's SDP answer — call setRemoteDescription with this.

connection.on("SdpAnswerReceived", (data) => {
  // data: { sdp: string, type: "answer" }
  peerConnection.setRemoteDescription(data);
});

IceCandidateReceived

connection.on("IceCandidateReceived", async (data) => {
  await peerConnection.addIceCandidate(data);
});

SessionStarted / SessionEnded / Error

Same structure as VoiceHub (see VoiceHub events).


Complete browser example

<!DOCTYPE html>
<html>
<head>
  <title>Voisnap WebRTC Demo</title>
  <script src="https://cdn.jsdelivr.net/npm/@microsoft/signalr@latest/dist/browser/signalr.min.js"></script>
</head>
<body>
  <button id="callBtn">Start Voice Call</button>
  <button id="hangupBtn" disabled>Hang Up</button>
  <div id="status">Ready</div>
  <div id="transcript"></div>
 
  <script>
    const AGENT_ID = 'agt_01HXK8Z3MNPQRS';
    const ACCESS_TOKEN = 'eyJhbGci...'; // your JWT
 
    let peerConnection;
    let connection;
    let localStream;
 
    async function startCall() {
      document.getElementById('status').textContent = 'Creating session...';
 
      // 1. Create WebRTC session
      const sessionRes = await fetch('https://api.voisnap.ai/api/v1/webrtc/sessions', {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${ACCESS_TOKEN}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ agentId: AGENT_ID }),
      });
      const session = await sessionRes.json();
 
      // 2. Connect WebRtcHub
      connection = new signalR.HubConnectionBuilder()
        .withUrl(
          `wss://api.voisnap.ai/hubs/webrtc?sessionToken=${session.sessionToken}&access_token=${ACCESS_TOKEN}`
        )
        .build();
 
      // Register events before starting
      connection.on('SdpAnswerReceived', async (answer) => {
        document.getElementById('status').textContent = 'Setting remote description...';
        await peerConnection.setRemoteDescription(new RTCSessionDescription(answer));
      });
 
      connection.on('IceCandidateReceived', async (candidate) => {
        try {
          await peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
        } catch (e) {
          console.warn('ICE candidate error:', e);
        }
      });
 
      connection.on('SessionStarted', (data) => {
        document.getElementById('status').textContent = `Connected — ${data.conversationId}`;
        document.getElementById('callBtn').disabled = true;
        document.getElementById('hangupBtn').disabled = false;
      });
 
      connection.on('SessionEnded', () => {
        document.getElementById('status').textContent = 'Call ended';
        cleanup();
      });
 
      connection.on('Error', (err) => {
        document.getElementById('status').textContent = `Error: ${err.message}`;
        if (!err.recoverable) cleanup();
      });
 
      await connection.start();
      document.getElementById('status').textContent = 'Requesting microphone...';
 
      // 3. Get microphone
      localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
 
      // 4. Create RTCPeerConnection
      peerConnection = new RTCPeerConnection({ iceServers: session.iceServers });
      localStream.getAudioTracks().forEach(track => peerConnection.addTrack(track, localStream));
 
      // Receive remote audio (agent voice)
      const remoteAudio = document.createElement('audio');
      remoteAudio.autoplay = true;
      peerConnection.ontrack = (event) => {
        remoteAudio.srcObject = event.streams[0];
      };
 
      // Send ICE candidates as they're discovered
      peerConnection.onicecandidate = async (event) => {
        if (event.candidate) {
          await connection.invoke('SendIceCandidateAsync', {
            candidate: event.candidate.candidate,
            sdpMid: event.candidate.sdpMid,
            sdpMLineIndex: event.candidate.sdpMLineIndex,
          });
        }
      };
 
      peerConnection.oniceconnectionstatechange = () => {
        document.getElementById('status').textContent =
          `ICE: ${peerConnection.iceConnectionState}`;
      };
 
      // 5. Create and send SDP offer
      document.getElementById('status').textContent = 'Creating offer...';
      const offer = await peerConnection.createOffer({
        offerToReceiveAudio: true,
        offerToReceiveVideo: false,
      });
      await peerConnection.setLocalDescription(offer);
 
      await connection.invoke('SendOfferAsync', {
        sdp: offer.sdp,
        type: offer.type,
      });
    }
 
    async function hangup() {
      if (connection) {
        await connection.invoke('EndSessionAsync', 'user_ended').catch(() => {});
      }
      cleanup();
    }
 
    function cleanup() {
      if (peerConnection) { peerConnection.close(); peerConnection = null; }
      if (connection) { connection.stop(); connection = null; }
      if (localStream) { localStream.getTracks().forEach(t => t.stop()); localStream = null; }
      document.getElementById('callBtn').disabled = false;
      document.getElementById('hangupBtn').disabled = true;
    }
 
    document.getElementById('callBtn').addEventListener('click', startCall);
    document.getElementById('hangupBtn').addEventListener('click', hangup);
  </script>
</body>
</html>

TURN server notes

Voisnap includes TURN server credentials in the session response. You must pass these as iceServers to RTCPeerConnection — do not hardcode STUN-only configurations, as TURN is required when users are behind symmetric NAT (common in enterprise networks).

TURN credentials are session-scoped and expire when the session ends.