Skip to main content
The Opper Realtime API lets you build voice-to-voice applications. Your customer speaks, the model responds with audio in real time, and tool calls flow back and forth without you holding the connection together yourself. The same protocol works across OpenAI, xAI, and Gemini, so you switch upstream models by changing a string. It also drives live speech-to-text — audio in, streaming transcript out — for transcription-only models like Mistral’s EU-hosted Voxtral (see Live transcription).

How it works

You open a WebSocket to wss://api.opper.ai/v3/realtime, send a session.start frame that picks the model and configures the agent, then stream microphone audio in and play assistant audio out as it arrives. Opper resolves the provider from the model id, handles the upstream handshake (including provider quirks like xAI’s client-secret exchange), meters usage as you go, and gives you one event vocabulary so your client code stays the same no matter which model you target.

Just trying it out?

The Realtime quickstart is the fastest way to a working session. It has one runnable code block per environment (server or browser), and you’ll be live in under five minutes. For a production-shaped example with browser UI, microphone capture, audio playback, tool calls, and provider switching, see the brainstorm-time cookbook.

Authentication

There are two patterns, depending on whether your client runs server-side or in a browser.

Server-side: bearer token

Server-to-server clients (Node, Python, Go, anything that runs in your backend) connect directly with a project-scoped runtime API key in the Authorization header. The quickstart uses this pattern.
Management keys (opmak-…) are rejected. Only project-scoped runtime keys can open realtime sessions.

Browser: ephemeral tickets

Browsers can’t set an Authorization header on the native WebSocket constructor. To open a realtime session directly from a browser, your backend mints a single-use ticket and the browser redeems it on the upgrade request. Step 1: server-side mint. Your backend hits POST /v3/realtime-sessions with its normal API key. The response carries a short-lived client_secret and an expires_at timestamp.
Step 2: browser redeem. The browser opens the WebSocket carrying the ticket in the Sec-WebSocket-Protocol subprotocol header. Prefer this form. Credentials in the URL query string end up in access logs, browser history, and Referer headers, while the subprotocol header is request-only and stays out of those places.
A query-parameter fallback is also accepted for environments that can’t set a subprotocol (uncommon: most clients support the second WebSocket constructor argument):
Ticket properties:
  • Single-use. Replays return 401.
  • Default TTL 60 seconds, max 5 minutes.

Pre-binding for security

Any field you populate in config at mint time is locked for that ticket. The browser can’t override it on session.start, and it can’t change it later with session.update either. This is the main safety guarantee. A leaked ticket can only open the session your backend authorized. It can’t pivot to a more expensive model or a different system prompt, and it can’t unlock the field by updating it after start. At minimum, bind model. Tighter setups also bind instructions, tools, and voice. Fields left zero stay open for the browser to set in session.start. To force a boolean off (for example, to stop the browser from enabling output transcription), list the field in locked_fields. Without it, a zero value in config doesn’t lock anything, so the browser could still flip the bool on. A ticket with locked_fields but no config is also valid: the listed fields are force-set to their zero value regardless of what the browser sends. Unknown field names are rejected at mint time with 400 Bad Request.

Preflight rejections

Before the WebSocket upgrade, Opper checks balance, plan, and concurrency caps. Failures come back as HTTP status codes rather than opaque WS closes, so you can react to them:

Session lifecycle

A session goes through four phases. Most of the time you only care about the middle two. 1. Connect. WebSocket upgrade. A successful response is 101 Switching Protocols. After this the connection is open, but the agent isn’t running yet. 2. Configure (session.start). Send this once as the first message after upgrade. The config selects the model, voice, system instructions, tool list, VAD parameters, and optional transcription toggles. Opper validates the config against the resolved model’s capabilities and rejects anything unsupported before dialing the upstream. The same validation runs on every later session.update, so unsupported modalities, voice, or reasoning_effort values return an error mid-stream. 3. Interact. Stream audio in (audio.append + audio.commit if not using server VAD), send text turns (text.input), receive audio.delta / text.delta frames, handle tool.call events, return tool.result. 4. Close. Either side can close the WebSocket. On a client-initiated close, Opper performs a final billing flush and tears down the upstream connection. On a server-initiated close (caps, idle timeout, balance exhaustion), you’ll see a closing event (session.terminating) with a structured reason before the connection ends.

Configuring the session

Everything goes in config on the first session.start frame:

Event vocabulary

All events are JSON over text WebSocket frames.

Client → server

Server → client

Termination reasons on session.terminating.error.code: session_timeout, idle_timeout, balance_exhausted, project_spend_cap_hit, org_spend_cap_hit, billing_not_supported.

Live transcription (speech-to-text)

The same WebSocket also powers transcription-only models — audio in, streaming text out, no audio response. This is the low-latency, EU-hosted path for live captions or dictation when you don’t need the model to talk back. Pick a transcription realtime model and request text output:
Then stream microphone audio with audio.append exactly as you would for a voice session. The transcript arrives incrementally on text.delta and is finalized on transcript.committed at each segment boundary:
There is no audio.delta — these models don’t speak — and voice, tools, and the audio-output fields don’t apply. Requesting modalities: ["audio"] returns an error. Diarization isn’t available on the realtime path; for speaker labels use batch transcription with diarize: true. mistral/voxtral-mini-transcribe-realtime-2602 is EU-hosted (GDPR-resident) and billed per minute of input audio.

Tool calls

Tools work the same way as in the Calls API: declare them in config.tools with a JSON Schema for parameters. When the model decides to call one, you get a tool.call event. Reply with tool.result and the model continues the conversation. Opper handles the provider wire format internally, so your handler is the same across upstreams.
While a tool is executing, the upstream model is paused. You’ll hear a silence in the audio stream proportional to your tool’s latency. For tools that take more than a second or two, plan the UX around it with something like a “looking that up…” cue.

Per-provider notes

The protocol is the same across providers, but each upstream has quirks worth knowing.
Models: openai/gpt-realtime-2, openai/gpt-realtime, openai/gpt-realtime.Voices: alloy, ash, ballad, coral, echo, sage, shimmer, verse, marin, cedar (latest models).Sample rate: 24 kHz symmetric (in/out).Reasoning effort: gpt-realtime-2 accepts reasoning_effort and bills reasoning tokens. Other models ignore it.Transcription: input transcription runs in a parallel pipeline upstream, so the user transcript can arrive after the assistant has started responding. Enabling input_transcription adds a separate per-minute bill line (input_transcription_ms) at the rate of the transcription model.Transcription is set-at-start. input_transcription and input_transcription_model cannot be toggled via session.update on OpenAI. Mid-session changes return an error. Request transcription at session.start or not at all. xAI and Gemini fold transcription into their per-minute audio rate and don’t have this restriction.

Session limits

Billing

Realtime sessions bill incrementally as the conversation runs. Per-model rates are published on GET /v3/models. Look for audio_input, audio_output, audio_input_per_minute, audio_output_per_minute, and input_transcription_per_minute on the relevant model.

Tracing

Each session emits generation records visible in the Trace explorer, filterable by model or session_id.

See also