Our setup
The client does WebRTC. Our Node server sits in the middle of the SDP exchange and then opens a sideband WebSocket — the server-side control connection described in the Webhooks and server-side controls guide (wss://api.openai.com/v1/realtime?call_id=...) — to handle function calls on the server. The client never receives any OpenAI key; it only gets the answer SDP.
The two keys in the diagram are what this post is about:
| Variant | KEY A (creates the call in step 2) | KEY B (sideband in step 5) | How step 2 is sent |
|---|---|---|---|
| A — what we ran until now | ephemeral key from POST /v1/realtime/client_secrets |
the same ephemeral key | Content-Type: application/sdp, body = SDP |
| B — as shown in the guide | ephemeral key | standard API key | same as A |
| C — unified interface | standard API key | standard API key | multipart/form-data with fields sdp and session |
What happened
Variant A had been running unchanged in production since 2025-10. On 2026-09-09, with no change on our side, step 5 started failing: the WebSocket handshake took about 5.5 seconds and returned
HTTP/1.1 404 Not Found
{
"error": {
"message": "No session found for the provided call_id",
"type": "invalid_request_error",
"code": "call_id_not_found",
"param": ""
}
}
Step 2 kept returning 201 with a valid answer SDP, and the client’s WebRTC leg connected normally (we received session.created on the data channel). Only the sideband could not find the call.
Test results on 2026-09-09, 03:20–03:50 UTC (while failing)
| Variant | Extra condition | Sideband result |
|---|---|---|
| A (ephemeral → ephemeral) | — | 404 call_id_not_found (~5.5 s) |
| B (ephemeral → API key) | — | 404 call_id_not_found (~5.5 s) |
| B | connect only after the client peer connection is connected; retry every ~6 s for 30 s |
404 every time |
| A | models gpt-realtime / gpt-realtime-mini; minimal session {type, model} |
404 |
| A / B | extra OpenAI-Beta: realtime=v1 header, or &model= in the URL |
404 |
| C (API key → API key) | — | connected; events and function calls work |
Same tests on 2026-09-09, 05:36 UTC (about two hours later)
| Variant | Sideband result (4 runs each) |
|---|---|
| A (ephemeral → ephemeral) | connected 4/4 (~0.4 s) |
| B (ephemeral → API key) | 404 call_id_not_found 4/4 (~5.5 s) |
| C (API key → API key) | connected |
So in the steady state the sideband connects only with the same key that created the call (A and C work, B does not), and for about two hours on Sep 9 even variant A failed. We switched production to variant C, which worked throughout.
Environment
- Node.js 22,
ws8.18.3, built-infetch/FormData - Model
gpt-realtime-1.5 - Requests from Japan (AWS ap-northeast-1 and a local machine); call IDs are prefixed
rtc_u7_
Minimal reproduction (Node 22)
Runs the three variants in order. The SDP is a syntactically valid but non-connecting offer; the sideband result does not depend on media being connected (see the 30-second retry row above).
// node --input-type=module - < repro.mjs (OPENAI_API_KEY must be set)
import WebSocket from 'ws'
const apiKey = process.env.OPENAI_API_KEY
const session = { type: 'realtime', model: 'gpt-realtime-1.5' }
const fp = Array.from({ length: 32 }, (_, i) => ((i * 7) % 256).toString(16).padStart(2, '0').toUpperCase()).join(':')
const sdp = [
'v=0', 'o=- 4611731400430051336 2 IN IP4 127.0.0.1', 's=-', 't=0 0',
'a=group:BUNDLE 0', 'a=msid-semantic: WMS',
'm=audio 9 UDP/TLS/RTP/SAVPF 111', 'c=IN IP4 0.0.0.0', 'a=rtcp:9 IN IP4 0.0.0.0',
'a=ice-ufrag:abcd', 'a=ice-pwd:abcdefghijklmnopqrstuvwx', 'a=ice-options:trickle',
`a=fingerprint:sha-256 ${fp}`, 'a=setup:actpass', 'a=mid:0', 'a=recvonly', 'a=rtcp-mux',
'a=rtpmap:111 opus/48000/2', 'a=fmtp:111 minptime=10;useinbandfec=1', '',
].join('\r\n')
const callIdFrom = r => r.headers.get('Location')?.split('/').pop()
function sideband(callId, key) {
return new Promise(resolve => {
const ws = new WebSocket(`wss://api.openai.com/v1/realtime?call_id=${callId}`, {
headers: { Authorization: `Bearer ${key}` },
})
ws.once('open', () => { resolve('OPEN'); ws.close() })
ws.once('unexpected-response', (_req, res) => {
let body = ''
res.on('data', d => (body += d))
res.on('end', () => resolve(`HTTP ${res.statusCode} ${body}`))
})
ws.once('error', e => resolve(`ERROR ${e.message}`))
})
}
async function createWithEphemeralKey() {
const secret = await fetch('https://api.openai.com/v1/realtime/client_secrets', {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ session }),
}).then(r => r.json())
const res = await fetch('https://api.openai.com/v1/realtime/calls', {
method: 'POST',
headers: { Authorization: `Bearer ${secret.value}`, 'Content-Type': 'application/sdp' },
body: sdp,
})
return { callId: callIdFrom(res), ephemeralKey: secret.value, status: res.status }
}
// A) ephemeral-key call, sideband with the same ephemeral key
const a = await createWithEphemeralKey()
console.log('A) ephemeral-key call', a.status, a.callId, '-> sideband(ephemeral):', await sideband(a.callId, a.ephemeralKey))
// B) ephemeral-key call, sideband with the API key (new call: closing a sideband ends the call)
const b = await createWithEphemeralKey()
console.log('B) ephemeral-key call', b.status, b.callId, '-> sideband(API key): ', await sideband(b.callId, apiKey))
// C) unified interface: API key creates the call, API key joins the sideband
const form = new FormData()
form.append('sdp', sdp)
form.append('session', JSON.stringify(session))
const c = await fetch('https://api.openai.com/v1/realtime/calls', {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}` },
body: form,
})
console.log('C) API-key call ', c.status, callIdFrom(c), '-> sideband(API key): ', await sideband(callIdFrom(c), apiKey))
process.exit(0) // keep-alive sockets otherwise hold the process open for a while
Output while failing (Sep 9, ~03:30 UTC): A = 404 call_id_not_found, B = 404, C = OPEN.
Output two hours later (Sep 9, 05:36 UTC): A = OPEN, B = 404 call_id_not_found, C = OPEN.
Questions
- For this setup — our server relays the SDP and the client never holds a key — is variant C (API key creates the call, API key joins the sideband) the recommended pattern? We see no reason to keep the ephemeral-key step, but want to confirm before settling on it.
- Was the sideband 404 for ephemeral-key calls on Sep 9 a temporary instability on the platform side? Nothing appeared on the status page or in the changelog. A SIP report with the same ~5 s delay and the same error body is here; support said it “does not appear expected”.
- Variant B is what the guide shows (
Bearer ${EPHEMERAL_KEY}for/calls,Bearer OPENAI_API_KEYfor the sideband), but it returns 404 for us both while failing and after. If “the sideband must use the same key that created the call” is the intended rule, could the guide say so?
