OpenAI Realtime API Transport (WebSocket vs WebRTC vs SIP) Errors — Fix Guide (2026)
Realtime API · Transport Severity: High

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.

TL;DRUse WebRTC for browsers and mobile — lowest latency (~800ms voice-to-voice possible), needs ephemeral client secrets minted server-side. Use WebSocket for server-to-server orchestration or when your backend mediates the audio (compliance, recording). Use SIP for telephony (Twilio, direct SIP trunks). All three connect to gpt-realtime-family models; the event protocol is identical, only the transport differs.

Real error messages you'll see

401 on WebSocket handshake
401 on WebSocket handshake
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 SDP negotiation failure
WebRTC SDP negotiation failure
# 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.
Wrong endpoint format for GA
Wrong endpoint format for GA
# 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

TransportBest forLatencyAuth pattern
WebRTCBrowser voice UIs, mobile apps~800ms voice-to-voiceEphemeral client_secret (60s TTL)
WebSocketServer-to-server orchestration, backend recording, compliance middleware+50-200ms vs WebRTCBearer $OPENAI_API_KEY server-side
SIPTelephony (contact centers, IVR, PSTN)Depends on carrierSIP trunk config + API key
WebSocket in browserDon't — exposes API key OR requires custom proxyanti-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-Beta header 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/sessions shape returns 404. GA is /openai/v1/realtime/client_secrets.
  • 10%
    Model name outdated. Older gpt-4o-realtime-preview was superseded by GA gpt-realtime family. 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-level voice) vs newer (output_modalities, audio.output.voice). Docs from 2024-early 2025 use old shapes.

How to fix it

Fix #1

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_mint_secret.pypython
# 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.htmlhtml
<!-- 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>
Note: The ephemeral client_secret authorizes ONE WebRTC session. If the user starts a second session, mint another secret. Don't cache and reuse — expired secrets fail SDP negotiation with confusing errors.
Fix #2

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.

websocket_server.pypython
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).
Note: WebSocket requires you to handle audio format conversion, interruption signals, and turn detection yourself in code. WebRTC has more of this "for free" via the browser's media stack. Only use WebSocket in the browser if you're building through a proxy that keeps the API key hidden.
Fix #3

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.

ga_session_config.pypython
# ✅ 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
Note: When following a Realtime tutorial or code sample, check the publish date. Anything from before mid-2025 likely uses preview field names that either error or silently no-op on GA. The event-name change (response.audio.deltaresponse.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) — preview gpt-4o-realtime-preview is superseded.
  • Session config uses audio.input, audio.output, output_modalities; old modalities, top-level voice shapes are silently ignored.
  • Event names use response.output_audio.delta, not response.audio.delta. Filter on the new names.
  • For corporate networks blocking WebRTC, provide a WebSocket fallback path from your server.

Frequently asked questions

Which transport should I pick for a new voice agent?

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.

How long does a Realtime session live?

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.

Can I record the audio server-side with WebRTC?

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.

Is Realtime API HIPAA-eligible?

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.

What are the current voice options?

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).

Related errors