OpenAI Realtime API Transport (WebSocket vs WebRTC vs SIP) Errors
The Realtime API exposes three transports — WebSocket for server-to-server, WebRTC for browsers/mobile, SIP for telephony. Picking the wrong one is the architecture decision most voice-agent teams end up redoing. Here's the transport map, the specific errors each produces during setup, and how to migrate between them.
By Sana K. · Last updated Aug 14, 2026 · OpenAI · Page #144
gpt-realtime-family models; the event protocol is identical, only the transport differs.Real error messages you'll see
websockets.exceptions.InvalidStatusCode: server rejected WebSocket connection: HTTP 401
at ws.connect("wss://api.openai.com/v1/realtime?model=gpt-realtime")
# Missing or malformed Authorization header. Realtime WebSocket needs Bearer $OPENAI_API_KEY AND OpenAI-Beta: realtime=v1 (or v2 depending on version).
# WebRTC connection stuck at "connecting" or fails ICE gathering.
# Common cause: browser tried to connect directly with a long-lived API key (BAD SECURITY),
# or the ephemeral client_secret has expired.
# Fix: mint client_secrets server-side, TTL 60s, one per session.
# Old preview endpoint: /openai/realtimeapi/sessions?api-version=2025-04-01-preview
# GA endpoint: /openai/v1/realtime/client_secrets
# Using preview endpoint returns 404 or deprecated warning.
Transport comparison — pick before you code
| Transport | Best for | Latency | Auth pattern |
|---|---|---|---|
| WebRTC | Browser voice UIs, mobile apps | ~800ms voice-to-voice | Ephemeral client_secret (60s TTL) |
| WebSocket | Server-to-server orchestration, backend recording, compliance middleware | +50-200ms vs WebRTC | Bearer $OPENAI_API_KEY server-side |
| SIP | Telephony (contact centers, IVR, PSTN) | Depends on carrier | SIP trunk config + API key |
| WebSocket in browser | Don't — exposes API key OR requires custom proxy | — | anti-pattern |
Root causes (ranked by frequency)
Based on OpenAI developer reports; percentages sum to 100%.
- 22%Using WebSocket from the browser. Exposes long-lived API key. The correct pattern is WebRTC from the browser with ephemeral client_secret.
- 18%Missing
OpenAI-Betaheader on WebSocket. Some Realtime versions still require the beta header. Include it as a safety default. - 14%Ephemeral client_secret expired before use. Client secrets have short TTL (typically 60s). Mint fresh per session, not at app boot.
- 12%Preview endpoint URL used post-GA. Old
/openai/realtimeapi/sessionsshape returns 404. GA is/openai/v1/realtime/client_secrets. - 10%Model name outdated. Older
gpt-4o-realtime-previewwas superseded by GAgpt-realtimefamily. Preview names return 404 or route to a deprecated model. - 9%ICE / STUN / TURN blocked. Corporate firewalls block WebRTC; connection stalls at ICE gathering. Deploy TURN relays or fall back to WebSocket.
- 8%SIP trunk missing OpenAI IP allowlist. Carrier rejects SIP invites. Configure trunk to accept OpenAI's SIP endpoint IPs.
- 7%Session parameter shape drift. Older field names (
modalities, top-levelvoice) vs newer (output_modalities,audio.output.voice). Docs from 2024-early 2025 use old shapes.
How to fix it
WebRTC in the browser — server mints ephemeral client_secret, browser connects
The correct pattern for browser voice UIs.
Never put an OpenAI API key in the browser. The Realtime WebRTC flow is: (1) browser calls your backend to mint a short-lived client_secret via /v1/realtime/client_secrets, (2) backend returns the secret to browser, (3) browser uses it to establish a peer connection directly with OpenAI. The secret expires in ~60 seconds; get one per session, not one at app load.
# Backend — mints ephemeral client secret and returns to browser
from fastapi import FastAPI, HTTPException
import os
import httpx
api = FastAPI()
OPENAI_API_KEY = os.environ["OPENAI_API_KEY"]
@api.post("/realtime/session")
async def create_realtime_session():
"""Mint an ephemeral client_secret for a browser WebRTC session."""
async with httpx.AsyncClient(timeout=30) as http:
resp = await http.post(
"https://api.openai.com/v1/realtime/client_secrets",
headers={
"Authorization": f"Bearer {OPENAI_API_KEY}",
"Content-Type": "application/json",
},
json={
"session": {
"type": "realtime",
"model": "gpt-realtime", # current GA model family
"instructions": (
"You are a helpful voice assistant. Speak concisely."
),
"audio": {
"output": {"voice": "cedar"},
"input": {
"transcription": {"model": "whisper-1", "language": "en"},
},
},
"turn_detection": {
"type": "server_vad",
"threshold": 0.5,
"prefix_padding_ms": 300,
"silence_duration_ms": 200,
},
},
},
)
if resp.status_code != 200:
raise HTTPException(500, f"mint failed: {resp.text}")
data = resp.json()
# data.value is the client_secret string (short-lived)
# data.expires_at is a Unix timestamp
return {
"client_secret": data["value"],
"expires_at": data["expires_at"],
"model": "gpt-realtime",
}
<!-- Browser — WebRTC connection using the ephemeral secret -->
<script>
async function connectVoice() {
// 1. Get ephemeral secret from your backend
const sessionResp = await fetch("/realtime/session", { method: "POST" });
const { client_secret, model } = await sessionResp.json();
// 2. Set up local peer connection
const pc = new RTCPeerConnection();
// 3. Handle remote audio (model speech)
pc.ontrack = (event) => {
const audioEl = document.getElementById("assistantAudio");
audioEl.srcObject = event.streams[0];
audioEl.play();
};
// 4. Add local mic to the connection
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream.getTracks().forEach(t => pc.addTrack(t, stream));
// 5. Data channel for events (session updates, function calls)
const dc = pc.createDataChannel("oai-events");
dc.onmessage = (e) => {
const event = JSON.parse(e.data);
console.log("event:", event.type, event);
};
// 6. Create SDP offer and exchange with OpenAI
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const sdpResp = await fetch(
`https://api.openai.com/v1/realtime?model=${model}`,
{
method: "POST",
body: offer.sdp,
headers: {
"Authorization": `Bearer ${client_secret}`,
"Content-Type": "application/sdp",
},
}
);
const answer = { type: "answer", sdp: await sdpResp.text() };
await pc.setRemoteDescription(answer);
}
document.getElementById("startBtn").addEventListener("click", connectVoice);
</script>
<audio id="assistantAudio" autoplay></audio>
<button id="startBtn">Start voice</button>
WebSocket from a trusted server — for backend orchestration and recording
When your backend mediates audio between the user and the model.
Use WebSocket when your server sits in the audio path — for compliance recording, custom moderation, tool orchestration that shouldn't be exposed to the client, or when audio comes from a non-browser source (phone, embedded device via your server). The extra hop adds latency but gives full control.
import asyncio
import json
import os
import base64
import websockets
OPENAI_API_KEY = os.environ["OPENAI_API_KEY"]
async def connect_realtime_ws():
"""Server-to-server WebSocket connection to Realtime API."""
url = "wss://api.openai.com/v1/realtime?model=gpt-realtime"
async with websockets.connect(
url,
additional_headers={
"Authorization": f"Bearer {OPENAI_API_KEY}",
"OpenAI-Beta": "realtime=v1", # some versions still expect this
},
max_size=None, # audio frames can be large
) as ws:
# 1. Configure the session
await ws.send(json.dumps({
"type": "session.update",
"session": {
"instructions": "You are a helpful voice assistant. Be concise.",
"audio": {
"input": {"format": "pcm16"}, # 24kHz PCM16 mono
"output": {"format": "pcm16", "voice": "cedar"},
},
"turn_detection": {
"type": "server_vad",
"threshold": 0.5,
"silence_duration_ms": 200,
},
"tools": [], # add function tools here
},
}))
# 2. Send audio input (from your source — phone, mic-capture proxy, etc.)
async def send_audio(audio_bytes: bytes):
"""audio_bytes is raw PCM16 24kHz mono."""
await ws.send(json.dumps({
"type": "input_audio_buffer.append",
"audio": base64.b64encode(audio_bytes).decode("ascii"),
}))
# 3. Listen for server events
async for message in ws:
event = json.loads(message)
t = event["type"]
if t == "session.created":
print(f"session {event['session']['id']} created")
elif t == "session.updated":
print("session configured")
elif t == "input_audio_buffer.speech_started":
print("user started speaking")
elif t == "input_audio_buffer.speech_stopped":
print("user stopped speaking")
elif t == "response.output_audio.delta":
# base64 audio chunk to send to the user's speakers
audio = base64.b64decode(event["delta"])
await your_playback_sink(audio)
elif t == "response.output_audio_transcript.delta":
# Model's spoken transcript — good for captions/logging
print(f"[model says] {event['delta']}", end="", flush=True)
elif t == "response.function_call_arguments.done":
# Full tool call args
args = json.loads(event["arguments"])
result = await handle_tool_call(event["name"], args)
# Return the result
await ws.send(json.dumps({
"type": "conversation.item.create",
"item": {
"type": "function_call_output",
"call_id": event["call_id"],
"output": json.dumps(result),
},
}))
await ws.send(json.dumps({"type": "response.create"}))
elif t == "error":
print(f"ERROR: {event['error']['message']}")
# ✅ Full-duplex loop — feed mic, play speaker
async def full_duplex():
ws_task = asyncio.create_task(connect_realtime_ws())
# ... your audio-capture task feeds bytes via send_audio ...
await ws_task
# ❌ ANTI-PATTERN — running this from browser code
# Long-lived API key would be exposed to every user.
# Use WebRTC from browser instead (see Fix #1).
Session config — use current GA field names, not preview shapes
Fixes session.update accepted-but-ignored bugs from stale doc examples.
The GA Realtime session schema uses audio.input, audio.output, output_modalities, and specific event names like response.output_audio.delta. Older docs and tutorials still reference modalities, top-level voice, and response.audio.delta. These older shapes may be silently ignored or rejected depending on version — use the GA shapes.
# ✅ CURRENT GA session shape
GA_SESSION_CONFIG = {
"type": "session.update",
"session": {
"type": "realtime",
"model": "gpt-realtime", # or gpt-realtime-2 depending on rollout
"instructions": "You are a voice concierge for a hotel. Be warm and concise.",
"audio": {
"input": {
"format": "pcm16", # 24kHz mono little-endian
"transcription": {
"model": "whisper-1",
"language": "en",
},
"noise_reduction": {"type": "near_field"},
},
"output": {
"format": "pcm16", # or "g711_ulaw", "g711_alaw" for telephony
"voice": "cedar", # cedar, marin, alloy, coral, echo, etc.
"speed": 1.0, # 0.25 - 2.0
},
},
"output_modalities": ["audio", "text"], # what the model can respond with
"turn_detection": {
"type": "server_vad", # or "semantic_vad" or None (manual)
"threshold": 0.5,
"prefix_padding_ms": 300,
"silence_duration_ms": 200,
"create_response": True, # auto-respond after user stops
"interrupt_response": True, # allow user to interrupt model
},
"tools": [
{
"type": "function",
"name": "check_availability",
"description": "Check room availability for given dates",
"parameters": {
"type": "object",
"properties": {
"check_in": {"type": "string", "format": "date"},
"check_out": {"type": "string", "format": "date"},
},
"required": ["check_in", "check_out"],
},
},
],
"tool_choice": "auto",
"temperature": 0.7, # Realtime models still accept temp
},
}
# ❌ OLD (preview) shape — will be ignored or rejected
OLD_SESSION_CONFIG = {
"type": "session.update",
"session": {
"modalities": ["audio", "text"], # old — now output_modalities
"voice": "alloy", # old — now audio.output.voice
"input_audio_format": "pcm16", # old — now audio.input.format
"output_audio_format": "pcm16", # old — now audio.output.format
"instructions": "...",
},
}
# ✅ Event names — GA prefixes with output_ for audio outputs
NEW_EVENTS = {
"response.output_audio.delta", # was response.audio.delta
"response.output_audio_transcript.delta", # was response.audio_transcript.delta
"response.output_text.delta", # was response.text.delta
"response.function_call_arguments.delta", # unchanged
"input_audio_buffer.speech_started",
"input_audio_buffer.speech_stopped",
}
# When consuming events, handle both shapes during migration if needed
def normalize_event_type(event_type: str) -> str:
mapping = {
"response.audio.delta": "response.output_audio.delta",
"response.audio_transcript.delta": "response.output_audio_transcript.delta",
"response.text.delta": "response.output_text.delta",
}
return mapping.get(event_type, event_type)
# ✅ Compatibility helper — accept old or new keys, normalize to new
def normalize_session_config(cfg: dict) -> dict:
session = cfg.get("session", {})
audio = session.setdefault("audio", {"input": {}, "output": {}})
if "voice" in session:
audio["output"].setdefault("voice", session.pop("voice"))
if "input_audio_format" in session:
audio["input"].setdefault("format", session.pop("input_audio_format"))
if "output_audio_format" in session:
audio["output"].setdefault("format", session.pop("output_audio_format"))
if "modalities" in session:
session.setdefault("output_modalities", session.pop("modalities"))
return cfg
response.audio.delta → response.output_audio.delta) is the most common silent-failure trap.Prevention checklist
- Never use WebSocket from a browser — WebRTC with ephemeral client_secret is the browser pattern.
- Mint client_secrets per-session server-side. TTL ~60s; do not cache and reuse.
- Use GA endpoint
/openai/v1/realtime/client_secrets— preview/openai/realtimeapi/sessions?api-version=...is deprecated. - Use current model names (
gpt-realtime,gpt-realtime-2) — previewgpt-4o-realtime-previewis superseded. - Session config uses
audio.input,audio.output,output_modalities; oldmodalities, top-levelvoiceshapes are silently ignored. - Event names use
response.output_audio.delta, notresponse.audio.delta. Filter on the new names. - For corporate networks blocking WebRTC, provide a WebSocket fallback path from your server.
Frequently asked questions
WebRTC for anything that talks to a browser or mobile app — best latency, browser handles the audio stack. WebSocket for server-to-server, phone integrations without SIP, and any case where your backend must sit in the audio path. SIP for direct telephony integration. If you're unsure, start with WebRTC — it's the recommended default and easiest to migrate away from later.
Realtime sessions have a maximum duration (documented per-model, historically 30-60 minutes) and end when the connection closes. Long conversations drift up in latency as the context grows — best practice is to rotate sessions periodically, or trim history aggressively via manual session state management. Under sustained load, connections may also be reset for capacity reasons.
Not directly — WebRTC audio flows peer-to-peer between browser and OpenAI. To record, either (a) use WebSocket so your server sees the audio, or (b) use WebRTC with a media relay (SFU/MCU) in your infrastructure. Most production teams that need compliance recording use WebSocket for that reason, even accepting the latency penalty.
Availability varies by year and by modality. The text APIs on OpenAI are typically covered under BAA; audio modality has lagged. If HIPAA is required, verify current BAA scope with OpenAI before designing. Common workaround: chain HIPAA-eligible STT/TTS providers with OpenAI text models rather than using Realtime end-to-end for regulated audio.
The voice roster expands over time — as of GA rollouts in 2025-2026, voices like cedar, marin, alloy, coral, echo, and shimmer have appeared. Voice list is model-dependent. Check the current API reference for your model. Voice is set at session config; changing it mid-session isn't supported (fields "voice" and "model" are marked immutable after the first output).