# Build a local browser voice conversation with OpenAI Realtime

> Build a local browser microphone conversation with OpenAI Realtime over WebRTC. The full server and page code keep the API key on the server and show how to check playback, stop the microphone and handle failures.

By BIG CHANGE Editorial

Published: 2026-10-19T19:20:34.863Z
Updated: 2026-10-19T19:20:34.863Z
Canonical: https://bigchange.ai/blog/openai-realtime-browser-voice-webrtc-guide

![Conceptual charcoal illustration of a desktop microphone beside a blank browser on a monitor and a small speaker.](https://bigchange.ai/api/media/file/realtime-browser-voice-hero-v1.png)
AI-generated conceptual illustration by BIG CHANGE; no actual OpenAI interface, browser test or heard reply is depicted.

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](https://developers.openai.com/api/docs/guides/voice-webrtc) documents the trusted-server exchange; [the model page](https://developers.openai.com/api/docs/models/gpt-realtime-2.1) 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.

1. Create an empty directory for the demo. From that directory, install Express:

```bash
npm init -y
npm install express
```

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

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

```javascript
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.

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

```html
<!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.

4. Start the server from the directory containing both files, with `OPENAI_API_KEY` already set in that server process’s environment:

```bash
node server.mjs
```

Open `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](https://developers.openai.com/api/docs/guides/realtime-conversations) 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](https://developers.openai.com/api/docs/models/gpt-realtime-2.1) and your own project usage before use; the guide cannot estimate a session bill from elapsed time alone. The [Realtime conversations guide](https://developers.openai.com/api/docs/guides/realtime-conversations) 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](https://developers.openai.com/api/docs/guides/voice-webrtc): the unified Realtime server exchange, browser audio path and the separate GPT-Live protocol.
- [OpenAI Realtime conversations guide](https://developers.openai.com/api/docs/guides/realtime-conversations): session events, turn behavior, response status and duration limit.
- [OpenAI `gpt-realtime-2.1` model page](https://developers.openai.com/api/docs/models/gpt-realtime-2.1): model access, token prices and rate limits. Prices and access should be rechecked when you run the example.

## Sources

- [OpenAI WebRTC guide](https://developers.openai.com/api/docs/guides/voice-webrtc) — Realtime unified calls interface, trusted key, SDP, browser audio and separate GPT-Live protocol.
- [OpenAI Realtime conversations](https://developers.openai.com/api/docs/guides/realtime-conversations) — Session events, VAD, response status and 60-minute duration.
- [OpenAI gpt-realtime-2.1 model](https://developers.openai.com/api/docs/models/gpt-realtime-2.1) — Model-specific token pricing and access.
- [OpenAI voice cost optimization](https://developers.openai.com/api/docs/guides/voice-latency-cost) — Context and per-response usage.
- [OpenAI API and ChatGPT billing](https://help.openai.com/en/articles/9039756-managing-billing-for-chatgpt-and-the-api-platform) — Separate billing arrangements.
