Messaging & channels
Browser calling (WebRTC)
Add a working talk-to-an-assistant button to a website, with server-side session creation and complete browser audio handling.
A visitor clicks a button, allows microphone access, and speaks to the same assistant that answers your OnCore phone number. The call runs in the page over WebRTC; the visitor does not dial or install anything.
The integration has two security boundaries:
- Your backend calls
POST /webrtc-call/connectwith an OnCore API key. - Your browser receives only the short-lived LiveKit
tokenandurl, then joins withlivekit-client.
Keep the API key server-side
Never call /webrtc-call/connect from browser JavaScript. Anyone can read credentials bundled into frontend code or sent by the browser. Only the short-lived LiveKit connection details belong in the client.
Before you start#
You need an OnCore phone number assigned to an assistant. The number selects which assistant answers the browser call.
Install the current LiveKit JavaScript client in your web application:
npm install livekit-clientThe example below uses Next.js App Router. The same split applies in any framework: put session creation on your server and the Room connection in browser code.
Create the server route#
Set these variables in the server environment, not in a NEXT_PUBLIC_ variable:
ONCORE_API_KEY=your_tenant_api_key
ONCORE_PHONE_NUMBER=+15555550123Create a route that exchanges the server-side API key for one browser-call session:
export const dynamic = "force-dynamic";
type WebRtcConnection = {
token: string;
url: string;
room: string;
};
export async function POST() {
const apiKey = process.env.ONCORE_API_KEY;
const phoneNumber = process.env.ONCORE_PHONE_NUMBER;
if (!apiKey || !phoneNumber) {
return Response.json({ error: "WebRTC is not configured" }, { status: 500 });
}
const upstream = await fetch("https://core-api.heysadie.ai/webrtc-call/connect", {
method: "POST",
headers: {
Authorization: `ApiKey ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ phoneNumber }),
cache: "no-store",
});
if (!upstream.ok) {
return Response.json(
{ error: "Could not start the browser call" },
{ status: upstream.status },
);
}
const connection = (await upstream.json()) as WebRtcConnection;
return Response.json(connection, {
headers: { "Cache-Control": "no-store" },
});
}phoneNumber must use E.164 format and belong to the authenticated tenant. You can also send an optional firstMessage of up to 500 characters to override the assistant's saved greeting for this call only.
Organization keys
A reseller using an organization key must derive the customer tenant on the server and add X-Tenant-Id to the upstream request. Never trust an unverified tenant ID supplied by the browser. See the Reseller quickstart.
Build the call button#
Create a client component. This implementation attaches the assistant's remote audio, exposes reconnecting and blocked-audio states, stops the microphone on disconnect, and prevents two simultaneous calls from one button.
"use client";
import { useEffect, useRef, useState } from "react";
import { Room, RoomEvent, Track } from "livekit-client";
type CallState = "idle" | "connecting" | "connected" | "reconnecting";
type WebRtcConnection = { token: string; url: string; room: string };
export function OnCoreCallButton() {
const roomRef = useRef<Room | null>(null);
const audioRootRef = useRef<HTMLDivElement>(null);
const [state, setState] = useState<CallState>("idle");
const [error, setError] = useState<string | null>(null);
const [audioBlocked, setAudioBlocked] = useState(false);
function removeAudioElements() {
audioRootRef.current?.replaceChildren();
}
async function endCall() {
const room = roomRef.current;
roomRef.current = null;
if (room) await room.disconnect(true);
removeAudioElements();
setAudioBlocked(false);
setState("idle");
}
async function startCall() {
if (roomRef.current) return;
setError(null);
setState("connecting");
const room = new Room();
roomRef.current = room;
room.on(RoomEvent.TrackSubscribed, (track) => {
if (track.kind !== Track.Kind.Audio || !audioRootRef.current) return;
const element = track.attach();
element.autoplay = true;
audioRootRef.current.appendChild(element);
});
room.on(RoomEvent.TrackUnsubscribed, (track) => {
for (const element of track.detach()) element.remove();
});
room.on(RoomEvent.AudioPlaybackStatusChanged, (playing) => {
setAudioBlocked(!playing);
});
room.on(RoomEvent.Reconnecting, () => setState("reconnecting"));
room.on(RoomEvent.Reconnected, () => setState("connected"));
room.on(RoomEvent.Disconnected, () => {
if (roomRef.current === room) roomRef.current = null;
removeAudioElements();
setAudioBlocked(false);
setState("idle");
});
try {
const response = await fetch("/api/oncore-session", { method: "POST" });
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error ?? `Could not start call (${response.status})`);
}
const { url, token } = (await response.json()) as WebRtcConnection;
await room.connect(url, token);
await room.localParticipant.setMicrophoneEnabled(true);
setAudioBlocked(!room.canPlaybackAudio);
setState("connected");
} catch (cause) {
await room.disconnect(true);
if (roomRef.current === room) roomRef.current = null;
removeAudioElements();
setState("idle");
const micDenied = cause instanceof DOMException && cause.name === "NotAllowedError";
setError(
micDenied
? "Microphone access was denied. Allow it in browser settings and try again."
: cause instanceof Error
? cause.message
: "Could not start the call.",
);
}
}
async function resumeAudio() {
const room = roomRef.current;
if (!room) return;
await room.startAudio();
setAudioBlocked(!room.canPlaybackAudio);
}
useEffect(() => {
return () => {
void roomRef.current?.disconnect(true);
roomRef.current = null;
};
}, []);
const active = state !== "idle";
return (
<div>
<button onClick={active ? endCall : startCall} disabled={state === "connecting"}>
{state === "idle" && "Talk to us"}
{state === "connecting" && "Connecting..."}
{state === "connected" && "End call"}
{state === "reconnecting" && "Reconnecting..."}
</button>
{audioBlocked && <button onClick={resumeAudio}>Play call audio</button>}
<p aria-live="polite">{error}</p>
<div ref={audioRootRef} aria-hidden="true" />
</div>
);
}Render <OnCoreCallButton /> on any page. Browser microphone access requires HTTPS in production; http://localhost is allowed during local development.
The LiveKit JavaScript SDK reference documents Room, its events, and audio-device behavior. OnCore creates the room and token; your application does not need a LiveKit server credential.
Request and response contract#
POST /webrtc-call/connect
| Body field | Required | Description |
|---|---|---|
phoneNumber | Yes | E.164 number owned by the authenticated tenant and assigned to an assistant |
firstMessage | No | Greeting override for this call, maximum 500 characters |
{
"token": "eyJhbGciOi...",
"url": "wss://example.livekit.cloud",
"room": "webrtc-<uuid>"
}The token is valid for 10 minutes before joining. That is a join window, not a maximum call duration. Do not cache or persist it; request a new session for each call attempt.
Errors and recovery#
| Where | Symptom | Action |
|---|---|---|
OnCore 400 | Number missing or not E.164 | Fix the configured number |
OnCore 401 | API key missing or invalid | Check the server-side key and ApiKey scheme |
OnCore 403 | Key cannot access the tenant | Check org-key tenant selection |
OnCore 404 | Number unknown, deleted, cross-tenant, or unassigned | Confirm ownership and assistant assignment |
OnCore 500 | Session creation failed transiently | Let the visitor retry once with a fresh session |
Browser NotAllowedError | Microphone permission denied | Explain how to enable the microphone, then start a new call |
audioBlocked is true | Browser blocked remote playback | Show a user gesture that calls room.startAudio() |
Reconnecting event | Temporary network interruption | Keep the call UI visible and show a reconnecting state |
The 404 body is deliberately identical for unknown and cross-tenant numbers, so the endpoint cannot enumerate numbers.
Reporting and limits#
WebRTC calls appear in call history with transcripts and summaries. They fire the same assistant-request and end-of-call report events as phone calls; the start event identifies the source as webrtc.
- Every button press creates a private call between one browser visitor and the assistant.
- The assistant is selected through an owned phone number; there is no public direct-to-assistant WebRTC request.
- The browser client handles audio only in this example. No camera permission is required.
- The Core API supplies the connection; OnCore does not provide a drop-in call widget, and there is none for any other channel either. The one browser package OnCore documents wraps monitoring a call in progress rather than placing one, and it is a library to build a panel with, not a panel.
Next steps#
- WebRTC API - inspect the generated endpoint schema.
- Call activity - find browser calls alongside phone calls.
- End-of-call report - receive transcript, summary, and outcome.