Build it

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.

Preview — not yet on 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 haveIn Ollacore
A cameraA room. The room id is the stream's address.
The device that sends the videoThe room's owner. Only an owner may publish.
People allowed to watchMembers of the room. The viewer role is enough.
Taking someone's access awayRemove them from the room. Their next connection is refused.
Retiring a cameraDelete 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.

RequestPOST https://api.ollacore.com/live/<room_id>/whep
HeadersAuthorization: Bearer <member's session token>, Content-Type: application/sdp
Success201 with the answer SDP, and a Location header naming the session. DELETE it to hang up.
Refused401, 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 #

ViewersUp to 20 at once per camera.
PublishersOne per camera. A camera that reconnects replaces its own earlier session.
ProtocolsWebRTC only: WHIP to send, WHEP to watch. RTMP, SRT and HLS are not offered.
RecordingNot stored by Ollacore. Record on your NVR.
EventsNo camera online/offline webhooks yet.