OpenAI’s Realtime API lets a browser send microphone audio and play a spoken answer over WebRTC. This guide builds one local conversation with gpt-realtime-2.1. The API key stays in a Node server on your computer. You need an OpenAI API project with access to the model and billing; a ChatGPT subscription does not pay for API usage. BIG CHANGE checked the current documentation and the sample code, but has not opened a paid session or heard a reply.
The big change
- What changed: OpenAI documents a browser WebRTC path for a live Realtime conversation, with a local server exchanging the browser’s connection offer for an answer while holding the project key.
- Why it matters: A developer can check the complete microphone-to-spoken-reply path in one local page before designing a larger voice product. The project owner controls the key and pays for any API use.
- What to watch: A working local exchange does not establish production readiness. A shared version needs caller authentication and spending controls, while a cancelled or timed-out request may already have reached OpenAI.
Before you start
Install Node.js 22.6 or later and use a current browser with a working microphone and speaker. Browser microphone access requires a secure context; http://localhost qualifies for this local example. Have an OpenAI API project key with gpt-realtime-2.1 access and billing enabled. Check your project’s rate and spend limits first. The model page lists the Free tier as unsupported. Keep the key in the server process environment through your normal secret manager or local shell setup. Do not put it in index.html, commit it in a .env file, or paste it into a transcript. OpenAI’s WebRTC guide documents the trusted-server exchange; the model page lists current access and prices.
This is the Realtime API’s unified /v1/realtime/calls route. The WebRTC guide also describes GPT-Live through /v1/live/sessions, which uses a different session protocol. Keep the route and payload below together. The sample is bound to your own computer; its local Host and Origin checks do not authenticate users of a shared service.
- Create an empty directory for the demo. From that directory, install Express:
npm init -y
npm install expressThe installation creates package.json and adds the web-server dependency. Save the next two files in this same directory. The code uses Node’s built-in fetch, FormData and AbortSignal.timeout; the reviewed setup requires Node.js 22.6 or later.
- Save the following as
server.mjs. It receives the browser’s SDP offer (a description of the proposed WebRTC connection) and sends it with the Realtime session settings to OpenAI. The standard API key is read only on this server.
import express from "express";
const app = express();
// This demo accepts only the local page, including protection against DNS rebinding.
app.use((req, res, next) => {
const hosts = ["localhost:3000", "127.0.0.1:3000"];
if (!hosts.includes(req.get("host"))) return res.status(403).send("Local host required");
if (req.method === "POST" && req.get("origin") !== `http://${req.get("host")}`) {
return res.status(403).send("Same-origin local page required");
}
next();
});
app.use(express.text({ type: "application/sdp", limit: "64kb" }));
app.get("/", (_req, res) => res.sendFile("index.html", { root: process.cwd() }));
app.post("/session", async (req, res) => {
if (!process.env.OPENAI_API_KEY) return res.status(500).send("Server API key missing");
if (typeof req.body !== "string" || !req.body.startsWith("v=")) {
return res.status(400).send("Expected an SDP offer");
}
const form = new FormData();
form.set("sdp", req.body);
form.set("session", JSON.stringify({
type: "realtime",
model: "gpt-realtime-2.1",
output_modalities: ["audio"],
audio: { output: { voice: "marin" } },
instructions: "Be a concise, helpful voice assistant. Do not claim access to external tools."
}));
try {
const upstream = await fetch("https://api.openai.com/v1/realtime/calls", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
body: form,
signal: AbortSignal.timeout(25000)
});
const answer = await upstream.text();
if (!upstream.ok) {
console.error("Realtime session rejected", upstream.status);
return res.status(502).send(`OpenAI session request failed (${upstream.status})`);
}
res.type("application/sdp").send(answer);
} catch (error) {
console.error("Realtime session network failure", error.name);
res.status(502).send("Could not reach the Realtime API");
}
});
app.listen(3000, "127.0.0.1", () => console.log("Open http://localhost:3000"));The server binds to 127.0.0.1:3000, accepts only the local page’s host and POST origin, and returns the SDP answer to the browser. Its 25-second upstream request limit is a choice in this example. A timeout or lost browser connection cannot prove that OpenAI did not accept the request, so inspect usage before retrying an uncertain start.
- Save the following as
index.html. The page requests microphone permission after Start voice, sends the offer to your local server, and plays the incoming audio. Stop closes the connection and stops the microphone tracks, including when setup is still in progress. The 30-second setup timer is another example choice, not an API guarantee.
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Local Realtime voice check</title>
<button id="start">Start voice</button>
<button id="stop" disabled>Stop</button>
<p id="status" role="status">Idle</p>
<audio id="reply" autoplay controls></audio>
<script type="module">
const start = document.querySelector("#start");
const stop = document.querySelector("#stop");
const status = document.querySelector("#status");
const reply = document.querySelector("#reply");
let current;
function closeSession(session = current, message = "Stopped") {
if (!session) return;
session.closed = true;
clearTimeout(session.timer);
session.abort.abort();
session.mic?.getTracks().forEach(track => track.stop());
if (session.pc) session.pc.onconnectionstatechange = null;
session.dc?.close();
session.pc?.close();
if (current === session) {
current = null;
reply.srcObject = null;
start.disabled = false;
stop.disabled = true;
status.textContent = message;
}
}
start.onclick = async () => {
const session = { abort: new AbortController(), closed: false };
current = session;
start.disabled = true;
stop.disabled = false;
status.textContent = "Requesting microphone";
session.timer = setTimeout(() => closeSession(session, "Setup timed out; microphone stopped"), 30000);
try {
const mic = await navigator.mediaDevices.getUserMedia({ audio: true });
if (session.closed) { mic.getTracks().forEach(track => track.stop()); return; }
session.mic = mic;
const pc = session.pc = new RTCPeerConnection();
pc.ontrack = event => { if (!session.closed) reply.srcObject = event.streams[0]; };
pc.onconnectionstatechange = () => {
if (session.closed) return;
status.textContent = `Connection: ${pc.connectionState}`;
if (pc.connectionState === "connected") clearTimeout(session.timer);
if (["failed", "closed", "disconnected"].includes(pc.connectionState)) {
closeSession(session, "Connection ended; microphone stopped. Start a new session.");
}
};
pc.addTrack(mic.getAudioTracks()[0], mic);
const dc = session.dc = pc.createDataChannel("oai-events");
dc.onmessage = message => {
if (session.closed) return;
try {
const event = JSON.parse(message.data);
if (event.type === "session.created") status.textContent = "Session ready: speak when connected";
if (event.type === "response.done") {
status.textContent = `Response status: ${event.response?.status || "unknown"}; verify audible playback`;
}
if (event.type === "error") closeSession(session, `Realtime error: ${event.error?.message || "unknown"}; stopped`);
} catch { closeSession(session, "Invalid event; stopped"); }
};
dc.onclose = () => { if (!session.closed) closeSession(session, "Event channel closed; stopped"); };
dc.onerror = () => closeSession(session, "Event channel failed; stopped");
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
if (session.closed) return;
// The official WebRTC example submits the generated offer directly.
const response = await fetch("/session", {
method: "POST",
headers: { "Content-Type": "application/sdp" },
body: offer.sdp,
signal: session.abort.signal
});
if (!response.ok) throw new Error(await response.text());
const answer = await response.text();
if (session.closed) return;
await pc.setRemoteDescription({ type: "answer", sdp: answer });
if (!session.closed && pc.connectionState !== "connected") status.textContent = "Signaling complete; waiting for connection";
} catch (error) {
if (!session.closed) closeSession(session, `Start failed: ${error.message}`);
}
};
stop.onclick = () => closeSession();
window.addEventListener("pagehide", () => closeSession());
</script>
</html>The browser also closes the session on a peer or event-channel failure and when the page is left. Microphone permission can arrive after a stop or timeout; the late track is stopped. The status line displays response.done with its reported status. That event alone does not show that the answer was audible or that a cancelled or failed response succeeded.
- Start the server from the directory containing both files, with
OPENAI_API_KEYalready set in that server process’s environment:
node server.mjsOpen http://localhost:3000. You should see Start voice, Stop, an Idle status and an audio player. Click Start voice, allow microphone access and speak a short harmless question. OpenAI’s Realtime conversations guide says the default voice activity detection can turn a pause after speech into a response. For a successful reader run, check that the browser shows a live microphone, the connection reaches connected, a session.created event appears, you hear speech, and response.done reports a completed response. Click Stop and check that the browser microphone indicator turns off. These are checks for you to perform; BIG CHANGE has not run this paid browser session.
If the check fails
If microphone permission is denied or no input device appears, check browser permission and the device, then use the local localhost URL or HTTPS. If the page reports a /session failure, look at the server-side HTTP status and verify key presence, project model access, billing, rate limits and the endpoint. The server deliberately does not print the key or OpenAI’s response body. A 502 can also mean a network failure or the sample’s 25-second deadline; it does not tell you whether an uncertain upstream request was accepted.
If signaling succeeds but the connection fails, check the page’s connection status, network restrictions and the returned SDP answer. If the connection is live but silent, check microphone input and the selected output device. Browser autoplay may be blocked: click Play on the audio control, then stop and start a new session if needed. On any failure, use Stop and confirm the microphone indicator is off. Check project usage before repeated starts when a request’s outcome is uncertain.
Cost, privacy and limits
OpenAI lists gpt-realtime-2.1 noncached text input/output at $4/$24 per million tokens and audio input/output at $32/$64 per million tokens, checked 19 October 2026. Charges depend on the tokens used, including later turns that can carry prior conversation context. Check the model pricing and limits and your own project usage before use; the guide cannot estimate a session bill from elapsed time alone. The Realtime conversations guide states a 60-minute maximum session.
Your microphone audio goes to OpenAI during a session. Use speech you have permission to transmit, and avoid private or third-party audio in this check. This local page offers no sign-in or per-user budget control. A shared service would need authenticated, rate-limited /session access and a server-derived safety identifier; the local Host and Origin checks are insufficient for that setting. The example does not call outside tools or make phone calls. No latency, reliability, speech quality, savings or production deployment result has been measured.
Sources & further reading
- OpenAI WebRTC guide: the unified Realtime server exchange, browser audio path and the separate GPT-Live protocol.
- OpenAI Realtime conversations guide: session events, turn behavior, response status and duration limit.
- OpenAI
gpt-realtime-2.1model page: model access, token prices and rate limits. Prices and access should be rechecked when you run the example.



