Skip to main content

Browser WebRTC transcription

Browser microphone? This is the clean path. The browser sends an audio track over WebRTC and receives the same realtime transcript events on an oai-events data channel.

The permanent KittenML API key stays on your backend. The browser receives only a short-lived ek_... token immediately before it starts a call.

In other words: let the browser meow, but keep the permanent key safely on your server.

Connection flow

  1. Your authenticated browser asks your backend for a client token.
  2. Your backend calls POST /v1/realtime/client_secrets with its permanent KittenML API key.
  3. The browser adds its microphone track and an oai-events data channel to an RTCPeerConnection.
  4. The browser posts its SDP offer to POST /v1/realtime/calls using the short-lived token.
  5. The browser applies the SDP answer, sends microphone media, and reads transcript events from oai-events.
  6. When recording stops, the browser sends {"type":"session.close"} on the data channel before closing its peer connection.

The client-token and SDP requests use HTTPS. The established microphone and data-channel traffic uses WebRTC over the negotiated direct ICE or TURN path.

1. Mint a client token on your backend

Never put a permanent sk_kitten_live_... key in browser JavaScript. The following Express handler runs on your backend:

app.post("/api/realtime-token", async (request, response) => {
// Authenticate your own user before issuing this token.
const upstream = await fetch(
"https://api.kittenml.com/v1/realtime/client_secrets",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.KITTENML_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
expires_after: {anchor: "created_at", seconds: 300},
session: {
type: "transcription",
model: "emokittenasr-realtime",
audio: {
input: {
transcription: {
model: "emokittenasr-realtime",
delay: "medium",
},
turn_detection: null,
},
},
},
}),
},
);

const body = await upstream.json();
response.status(upstream.status).json(body);
});

The response contains value, the short-lived token, and expires_at, its Unix expiration time. Expiration limits when a new connection may be opened; it is not a maximum call duration. Mint a new token for every Start or reconnect action.

Protect your token endpoint with your normal user authentication and rate limits. Do not log or persist the returned token.

2. Connect from the browser

This abridged browser example captures the microphone, performs SDP signaling, and listens for transcript events:

const tokenResponse = await fetch("/api/realtime-token", {method: "POST"});
if (!tokenResponse.ok) throw new Error("Could not create a realtime token");
const token = await tokenResponse.json();

const microphone = await navigator.mediaDevices.getUserMedia({audio: true});
const peer = new RTCPeerConnection({
iceServers: [{urls: "stun:stun.cloudflare.com:3478"}],
});

for (const track of microphone.getTracks()) {
peer.addTrack(track, microphone);
}

const events = peer.createDataChannel("oai-events");
events.addEventListener("message", ({data}) => {
const event = JSON.parse(data);

if (event.type === "conversation.item.input_audio_transcription.delta") {
console.log("partial", event.clean_text || event.delta);
}

if (event.type === "conversation.item.input_audio_transcription.completed") {
console.log("final", event.transcript);
}

if (event.type === "error") {
console.error(event.error);
}
});

await peer.setLocalDescription(await peer.createOffer());

if (peer.iceGatheringState !== "complete") {
await new Promise((resolve) => {
peer.addEventListener("icegatheringstatechange", function ready() {
if (peer.iceGatheringState === "complete") {
peer.removeEventListener("icegatheringstatechange", ready);
resolve();
}
});
});
}

const answer = await fetch("https://api.kittenml.com/v1/realtime/calls", {
method: "POST",
headers: {
Authorization: `Bearer ${token.value}`,
"Content-Type": "application/sdp",
},
body: peer.localDescription.sdp,
});

if (!answer.ok) throw new Error(`WebRTC signaling failed: ${answer.status}`);

await peer.setRemoteDescription({
type: "answer",
sdp: await answer.text(),
});

When the user selects Stop, finish the turn before closing the connection:

microphone.getTracks().forEach((track) => track.stop());

if (events.readyState === "open") {
events.send(JSON.stringify({type: "session.close"}));
}

Wait for conversation.item.input_audio_transcription.completed before calling peer.close() if your application needs the authoritative final transcript.

Events and limits

WebRTC returns the same partial, completed, error, and session events documented in Realtime events. Replace cumulative clean_text or enriched_text snapshots rather than concatenating them.

WebSocket and WebRTC share the same five active ASR slots per organization. There is no fixed WebRTC call-duration limit. A rejected SDP admission returns HTTP 429; retry with bounded exponential backoff and jitter.

See the complete runnable browser example for the small token server and user interface together.