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.