Camera feeds
Live video from an IP camera, an NVR or a webcam, watched in a browser or an app with the delay of a video call. A camera is a room: the people allowed to watch are its members, and the tokens are the ones you already mint for chat and calls.
api.ollacore.com. Camera feeds are built and
tested end to end on our test deployment, and ship to production separately from the rest of the
API; until then the /live/ addresses below do not answer. If you want to use it, write to
support and tell us how many cameras and viewers you expect.
How it fits together #
| You have | In Ollacore |
|---|---|
| A camera | A room. The room id is the stream's address. |
| The device that sends the video | The room's owner. Only an owner may publish. |
| People allowed to watch | Members of the room. The viewer role is enough. |
| Taking someone's access away | Remove them from the room. Their next connection is refused. |
| Retiring a camera | Delete the room. |
There are no camera-specific endpoints to learn: you create the room, add members and mint tokens with the customer server API, exactly as for a chat room.
# 1. One room per camera. Keep the id: it is the stream's address.
curl -s https://api.ollacore.com/v1/server/rooms \
-H "Authorization: Bearer $OLLACORE_KEY" -H 'Content-Type: application/json' \
-d '{"created_by": "gate-cam-1", "external_ref": "site-12/gate"}'
# → {"id": "25964f12-9ae9-4356-80e8-69bc5f749bd1", "e2ee": false, …}
# 2. The camera is the room's owner; people who watch are members.
curl -s https://api.ollacore.com/v1/server/rooms/$ROOM/members \
-H "Authorization: Bearer $OLLACORE_KEY" -H 'Content-Type: application/json' \
-d '{"principal_id": "gate-cam-1", "role": "owner"}'
curl -s https://api.ollacore.com/v1/server/rooms/$ROOM/members \
-H "Authorization: Bearer $OLLACORE_KEY" -H 'Content-Type: application/json' \
-d '{"principal_id": "guard-anna", "role": "viewer"}'
# 3. A session token for each, minted when they connect.
curl -s https://api.ollacore.com/v1/server/session-tokens \
-H "Authorization: Bearer $OLLACORE_KEY" -H 'Content-Type: application/json' \
-d '{"room_id": "'$ROOM'", "principal_id": "guard-anna", "role": "viewer", "ttl_seconds": 600}'
# → {"access_token": "eyJ…", "ice_servers": […], …} Watching a camera #
A viewer opens a WebRTC connection with one HTTP call, the standard WHEP exchange: an SDP offer in, an SDP answer out.
| Request | POST https://api.ollacore.com/live/<room_id>/whep |
|---|---|
| Headers | Authorization: Bearer <member's session token>, Content-Type: application/sdp |
| Success | 201 with the answer SDP, and a Location header naming the session. DELETE it to hang up. |
| Refused | 401, whatever the reason: a missing or invalid token, a token for another room, someone who is not (or no longer) a member, a publish by anyone but the owner, or an end-to-end encrypted room. |
// Watch a camera in the browser (WHEP). `bundle` is the member's session-token
// response: its access_token authorises the viewer, its ice_servers get the
// video through networks that block UDP. Resolves to a stop() function.
async function watchCamera(roomId, bundle, videoEl) {
const pc = new RTCPeerConnection({ iceServers: bundle.ice_servers });
const auth = { Authorization: `Bearer ${bundle.access_token}` };
let session = null;
// End the session before closing: once the connection closes, the server
// drops it on its own and the DELETE finds nothing (404: already ended).
async function stop() {
try {
if (session) await fetch(session, { method: "DELETE", headers: auth });
} finally {
pc.close();
}
}
// Browsers autoplay only muted media: unmute later from a user's click.
videoEl.autoplay = true;
videoEl.playsInline = true;
videoEl.muted = true;
const stream = new MediaStream();
videoEl.srcObject = stream;
pc.ontrack = (e) => stream.addTrack(e.track);
try {
pc.addTransceiver("video", { direction: "recvonly" });
pc.addTransceiver("audio", { direction: "recvonly" });
await pc.setLocalDescription(await pc.createOffer());
await iceGathered(pc); // every candidate goes in the offer: no trickle needed
const res = await fetch(`https://api.ollacore.com/live/${roomId}/whep`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/sdp" },
body: pc.localDescription.sdp,
});
if (res.status !== 201) throw new Error(`watch refused: ${res.status}`); // 401: token, room or membership
session = new URL(res.headers.get("Location"), res.url);
await pc.setRemoteDescription({ type: "answer", sdp: await res.text() });
} catch (err) {
await stop().catch(() => {});
throw err;
}
return stop;
}
// Wait for every candidate, relay ones included: they arrive last, and they are
// the ones a viewer on a UDP-blocked network needs. No trickle, so the offer
// must carry them all.
function iceGathered(pc, timeoutMs = 15000) {
return new Promise((resolve, reject) => {
if (pc.iceGatheringState === "complete") return resolve();
const timer = setTimeout(() => reject(new Error("ICE gathering did not finish")), timeoutMs);
pc.addEventListener("icegatheringstatechange", () => {
if (pc.iceGatheringState === "complete") {
clearTimeout(timer);
resolve();
}
});
});
} <room_id> is the lowercase, hyphenated UUID the API returned; any other spelling
is refused. Media flows over UDP to the server's port 8189. Viewers on networks that block UDP
reach it through the TURN servers in ice_servers — pass them exactly as given.
Sending video #
The owner sends over WHIP: the same exchange at
/live/<room_id>/whip, with the owner's token. There are two common sources.
A webcam or a phone camera
A browser can be the camera. This uses the same helper as the viewer above:
// Send a webcam as the camera (WHIP). `bundle` is the OWNER's session-token
// response. H.264 first, so every viewer's browser can decode it. Resolves to
// { pc, stop }; stop() ends the session and turns the camera off.
async function publishWebcam(roomId, bundle) {
const media = await navigator.mediaDevices
.getUserMedia({ video: true, audio: true })
.catch(() => navigator.mediaDevices.getUserMedia({ video: true })); // no microphone: video only
const pc = new RTCPeerConnection({ iceServers: bundle.ice_servers });
const auth = { Authorization: `Bearer ${bundle.access_token}` };
let session = null;
async function stop() {
try {
if (session) await fetch(session, { method: "DELETE", headers: auth });
} finally {
pc.close();
media.getTracks().forEach((t) => t.stop()); // camera light off
}
}
try {
for (const track of media.getTracks()) {
const t = pc.addTransceiver(track, { direction: "sendonly", streams: [media] });
if (track.kind === "video") {
const codecs = RTCRtpSender.getCapabilities("video").codecs;
t.setCodecPreferences([
...codecs.filter((c) => c.mimeType === "video/H264"),
...codecs.filter((c) => c.mimeType !== "video/H264"),
]);
}
}
await pc.setLocalDescription(await pc.createOffer());
await iceGathered(pc);
const res = await fetch(`https://api.ollacore.com/live/${roomId}/whip`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/sdp" },
body: pc.localDescription.sdp,
});
if (res.status !== 201) throw new Error(`publish refused: ${res.status}`); // 401 unless the room's owner
session = new URL(res.headers.get("Location"), res.url);
await pc.setRemoteDescription({ type: "answer", sdp: await res.text() });
} catch (err) {
await stop().catch(() => {}); // any failure releases the camera
throw err;
}
return { pc, stop };
} An IP camera or an NVR
Cameras speak RTSP and sit behind your router, so run a small forwarder next to them. MediaMTX works well for this: it pulls each camera on your network and forwards it to Ollacore over WHIP.
# mediamtx.yml on your NVR or a small gateway next to the cameras
# (MediaMTX 1.21 or later, MIT licensed). It pulls each camera over RTSP on
# your LAN and forwards it over WHIP: no transcoding, and the camera's own
# password never leaves your network.
paths:
gate:
source: rtsp://user:[email protected]:554/Streaming/Channels/101
forward:
- dest: whips://api.ollacore.com/live/25964f12-9ae9-4356-80e8-69bc5f749bd1/whip
whipBearerToken: <owner session token, refreshed by your server>
A session token lives at most an hour. A running forward is not cut when its token expires, but
reconnecting needs a valid one, so have your server mint a fresh owner token about every
50 minutes and write it into whipBearerToken; MediaMTX picks up the change without a
restart. The gateway runs MediaMTX's own servers too (RTSP, HLS, WebRTC): keep its ports off the
internet, or switch them off in its configuration.
Codecs #
Video passes through untouched — nothing is transcoded — so send what your viewers can decode: H.264 video, with Opus audio or none. Most IP cameras can be switched from H.265 to H.264 in their settings. Camera audio is often G.711; drop it or convert it on your side.
Access and security #
- Every connection is decided by the API. The media server asks Ollacore about each publish and each view: a valid token, for this room, from a current member — and, to publish, an owner.
- Access is checked when a connection starts. Removing a member or letting a token expire does not end a stream they are already watching; it stops them reconnecting. Keep token lifetimes short where that matters.
- End-to-end encrypted rooms are refused. The media server handles the
video itself, which an end-to-end encrypted room must never allow. Create camera rooms
without
e2ee. - Encrypted in transit. Signalling runs over HTTPS; media is DTLS-SRTP, as in any WebRTC call.
- Your camera credentials stay with you. The forwarder pulls the camera on your network; Ollacore only ever sees the forwarded stream.
Limits in this preview #
| Viewers | Up to 20 at once per camera. |
|---|---|
| Publishers | One per camera. A camera that reconnects replaces its own earlier session. |
| Protocols | WebRTC only: WHIP to send, WHEP to watch. RTMP, SRT and HLS are not offered. |
| Recording | Not stored by Ollacore. Record on your NVR. |
| Events | No camera online/offline webhooks yet. |