Realtime WebRTC returns empty 400 when hitting /v1/realtime/calls?model=…

Environment

Symptoms

  • Network tab shows status 400 with no response body.

  • Same payload succeeds instantly if the query string is removed.

Diagnosis

  • GA docs (e.g., [Link removed by moderator]) describe /v1/realtime/calls with multipart sdp as the canonical endpoint; the ?model= suffix was only ever needed on early beta / Azure samples.

  • Known issue entry (process-modeler/docs/known-issues/chatkit_index.md:5-14) confirms GA now rejects /calls?model=… with an empty 400.

  • The ephemeral session already encodes the model/voice, so duplicating it in the URL or multipart body just re-triggers the failure.

Working request (GA)

js

await waitForIceComplete(peer); // ensure iceGatheringState === 'complete'

const fd = new FormData();
fd.set('sdp', peer.localDescription.sdp);

const res = await fetch('https://api.openai.com/v1/realtime/calls', {
method: 'POST',
headers: {
Authorization: `Bearer ${ephemeralToken}`, // ek_… < 60 s old
'OpenAI-Beta': 'realtime=v1', // required on some stacks, harmless on GA
},
body: fd, // let fetch set the multipart boundary
});

const answer = await res.text();
if (!res.ok) throw new Error(answer || `HTTP ${res.status}`);

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

Key takeaways

  • Endpoint: POST https://api.openai.com/v1/realtime/calls (no query params).

  • Body: FormData with a single sdp part; don’t send JSON or session unless GA requires it again.

  • Headers: Only Authorization: Bearer ek_… plus optional OpenAI-Beta: realtime=v1.

  • Timing: Mint the ephemeral token immediately before the POST; TTL is ≈1 minute.

  • ICE: Wait for icegatheringstatechange to hit complete before posting.

Ask
Could the public docs / error text call out that GA rejects /v1/realtime/calls?model=…? Right now the server just returns an empty 400 while many legacy samples (and Azure’s realtimertc flow) still show the query string, so it’s an easy trap.

How about: the API reference doesn’t say a single thing about query strings for the endpoint:


You want others to find not to send query parameters? Maybe this topic will come up in search now:

Status 400 error with query string parameters on “Create Call” Realtime API

  • Issue: user error, obsolete incorrect documentation from third-parties

No query-string parameters
This endpoint doesn’t accept anything on the URL. Everything the server needs travels in the HTTPS request body, sent as multipart/form-data.


How to call POST /v1/realtime/calls

Part Content-Type Required Value
sdp application/sdp Yes Your WebRTC SDP offer (raw text).
session application/json No (defaults will be used) A JSON object that configures the realtime session (see table below).

URL

https://api.openai.com/v1/realtime/calls

Method

POST

Mandatory header

Authorization: Bearer YOUR_OPENAI_API_KEY

multipart/form-data is set automatically by the HTTP library when you attach a FormData/files payload.

Typical workflow

  1. Generate an SDP offer in your WebRTC client.

  2. Build a multipart body containing that offer (sdp) and, optionally, a JSON session object.

  3. POST the request.

  4. The server replies 201 Created

    • Response body: an SDP answer to feed back into your WebRTC peer connection.
    • Location header: relative URL holding the new call ID for later control requests (e.g., hang-up).

Minimal code examples

JavaScript (fetch / ECMAScript 2023)

// 2-space indents per style guide
import fetch from "node-fetch";   // or global fetch in browsers

async function createRealtimeCall(sdpOffer) {
  const form = new FormData();
  form.append("sdp", new Blob([sdpOffer], { type: "application/sdp" }));
  form.append(
    "session",
    new Blob(
      [JSON.stringify({ type: "realtime", model: "gpt-realtime" })],
      { type: "application/json" }
    )
  );

  const res = await fetch("https://api.openai.com/v1/realtime/calls", {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
    body: form,
  });

  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const sdpAnswer = await res.text();          // 'application/sdp'
  const callIdUrl = res.headers.get("Location");
  return { sdpAnswer, callIdUrl };
}

Python 3.11 (requests)

import requests, json, os

def create_realtime_call(sdp_offer: str) -> tuple[str, str]:
    files = {
        "sdp": ("offer.sdp", sdp_offer, "application/sdp"),
        "session": (
            None,
            json.dumps({"type": "realtime", "model": "gpt-realtime"}),
            "application/json",
        ),
    }

    r = requests.post(
        "https://api.openai.com/v1/realtime/calls",
        headers={"Authorization": f"Bearer {os.environ['OPENAI_API_KEY']}"},
        files=files,
    )
    r.raise_for_status()
    return r.text, r.headers["Location"]      # (SDP answer, call-ID URL)

session JSON quick reference

Field Required Type Purpose / notes
type Yes "realtime" Must be the literal string realtime.
output_modalities No ["audio"] | ["text"] Choose how the model responds. Cannot mix audio & text.
model No string Realtime-capable model name, e.g. gpt-realtime.
instructions No string Default system message for the session (tone, style, etc.).
audio No object Configure input/output audio formats, voice, speed, noise-reduction, VAD, etc.
include No array<string> Extra server fields to stream back (e.g. transcription logprobs).
tracing No "auto" | null | object Enable traces in the Traces Dashboard.
tools No array<object> Declare Function or MCP tools the model may call.
tool_choice No `“none” “auto” “required”
max_output_tokens No int 1-4096 | "inf" Hard cap on the assistant’s response length.
truncation No "auto" | "disabled" | {retention_ratio} How to prune long conversations.
prompt No object | null Reusable prompt template reference with variable substitution.

(All other nested properties, such as detailed audio formats or turn-detection options, follow the same shape shown in the full spec and remain optional unless stated.)


Wrap-up

To start a realtime WebRTC session with OpenAI you POST to /v1/realtime/calls with a multipart form containing your SDP offer and (optionally) a JSON session object. No query parameters are involved; proper construction of those two form parts plus an Authorization header is all that’s required to obtain the SDP answer and the call ID for subsequent control.

There’s no need for a model URL parameter when using an ephemeral token. The model is selected when creating the token.

yep this worked together with a few more fixes for the SDP Post, thank you :slight_smile: