Live broadcasting

Publish one live audio/video feed to many viewers without building a peer mesh

A normal OpenRTC media connection is a two-way call between peers. A broadcast is different: each host uploads one copy of each video quality to a managed distribution relay, and many viewers read from that relay. Viewers never join the host's peer mesh and do not receive the host's IP address, Iroh ticket, device ID, or endpoint ID.

The browser package installs the broadcast runtime and reuses OpenRTC's normal WebCodecs/MediaStream edge. openrtc/native exposes the same API as a thin IPC facade: grant verification, provider credentials, publication signing, media queues, and lifecycle stay in the Tauri Rust host rather than a second WASM runtime. broadcasts.open() still fails closed unless your developer app and native host are admitted to the managed preview control plane and relay. It never falls back to a viewer mesh.

App-owned broadcast hosting

openrtc/external-broadcast lets an application backend issue signed, device-bound grants and choose an SFU control endpoint or MoQ relay. The browser still uses OpenRTC's Rust/WASM grant verification, publication signing, session generations, media framing, and carrier adapters. This entrypoint does not enable OpenRTC-managed allocation or its billing and usage enforcement. The application owns issuer-key custody, grant admission, relay access, audience limits, provider spending, and operational monitoring.

The application passes an exact HTTPS serviceOrigin and a stable key namespace to createExternalBroadcastClient. Its resolve(grant, bindingPublicJwk, publicationVerifyingKey) callback calls the application's trusted access endpoint and returns the issuer's 32-byte public key plus short-lived relay access. The SFU control URL must remain on that trusted origin. The app server, not browser code, holds the issuer private key and provider secret. Use externalBroadcastBindingPublicJwk before requesting an installation-bound grant; the client returns the same binding through bindingPublicJwk() after creation. Do not share a grant between viewers.

This path currently targets a small application-owned browser preview, such as OpenRTC Live. Native OBS ingest uses the separate local WHIP bridge and its outbound connection to the app service; the broadcaster does not open an inbound port or configure a tunnel. The app-owned path is not evidence that OpenRTC-managed production hosting is ready.

Build a one-source live page

A Twitch-style page needs a publisher, a distribution path, and an audience page. Keep the video source separate from chat and page state:

camera/microphone or OBS → one publisher → selected broadcast relay → N viewers
                                      app backend → grants, access, chat, page data

The publisher uploads one copy of each selected quality. The relay fans it out; each viewer has its own subscriber grant and receives a decoded MediaStream. This avoids one peer connection and one upload per viewer at the publisher. The relay still delivers roughly one copy to each viewer, so audience size and bitrate both affect delivery and budget. maxSubscribers is a hard admission ceiling, not a promise that a particular relay has been capacity tested for N viewers.

1. Prepare identity and the page

Install the published package with pnpm add openrtc, create an OpenRTC developer app, and use that app's public API key in the browser. Keep its matching server secret and any identity-provider secret on your trusted backend. Normal consumer apps use the managed production OpenRTC service; an app's development or staging lane does not select an internal OpenRTC platform lane. See environments.

Give the page a player, a live/offline label, a description, and an optional chat panel. Your app owns the URL (for example /live/alex), broadcaster account, description, moderation, and chat persistence. OpenRTC owns broadcast grants and media delivery; a broadcast grant is not a chat login. A viewer can watch without signing in if your app allows anonymous grant requests. Require your app's authentication before accepting a chat write.

Only the authenticated broadcaster should be able to start, stop, or invite another publisher. Your backend must enforce that policy and limit anonymous viewer-grant requests per broadcast and caller. Do not put an owner grant, subscriber grant, or invitation in the page HTML, a URL, or a public log. For the SDK's auth option, adapt your identity provider to OpenRTC's AuthProvider; a Clerk browser session alone is not an OpenRTC assertion.

2. Start the single publisher

For a camera or microphone source, the owner's page can prepare a sign-only publisher, create a bounded owner grant, and add capture tracks. The following is the admitted-preview SDK shape; create() fails closed for an app that has not been admitted to managed hosting:

import { OpenRTC } from 'openrtc';

const ownerRtc = OpenRTC({
  apiKey: import.meta.env.VITE_OPENRTC_API_KEY,
  auth: broadcasterAuthProvider, // your app's authenticated owner session
});
const publisher = await ownerRtc.broadcasts.preparePublisher();
const ownerGrant = await ownerRtc.broadcasts.create({
  publisher,
  sourceSlot: 'main',
  maxPublishers: 1,
  maxSubscribers: 100,
  maxBitrateBps: 2_000_000,
  maxDurationSeconds: 3_600,
  maxEgressBytes: 90_000_000_000,
  publisherBitrateBps: 2_000_000,
  reservedEgressBytes: 90_000_000_000,
});
const session = await publisher.open(ownerGrant);
const capture = await navigator.mediaDevices.getUserMedia({
  audio: true,
  video: true,
});
for (const track of capture.getTracks()) await session.media.addTrack(track);

The values above illustrate a ceiling, not a recommended capacity or a reservation guaranteed to be affordable. Choose caps from the duration, bitrate, and audience you actually intend to support. Keep ownerGrant only in the owner's installation and let the app backend keep the broadcast page's public status. If capture or publication fails, close the session and stop the captured tracks. Use session.getStats() and session.state to show actual delivery and failure states, rather than marking the page live just because a button was clicked.

3. Admit viewers and render the stream

A viewer needs a grant for that viewer's installation. The owner creates a single-use subscriber invitation; the viewer accepts it with its own OpenRTC client. Your app's trusted endpoint should authorize the request, enforce the viewer cap, and deliver each invitation only to its intended viewer. Anonymous watching means the app does not require a user account; it does not mean a shared grant or an unlimited invitation endpoint.

One simple invitation flow is: (1) the viewer asks your backend for access to the broadcast page; (2) the backend checks the page policy and remaining capacity; (3) the online owner session creates one subscriber invitation; (4) the backend delivers it to that waiting viewer; (5) the viewer accepts it. The owner session must stay available to issue invitations in this sketch. If your application uses a trusted service to issue grants instead, that service must enforce the same role, installation binding, and hard limits.

// Owner-side flow, called once per admitted viewer request.
const invitation = await ownerRtc.broadcasts.invite(ownerGrant, {
  role: 'subscriber',
  bitrateBps: 2_000_000,
  durationSeconds: 3_600,
  reservedEgressBytes: 900_000_000,
});
// Deliver invitation through your app's private, bounded response.
// Viewer-side flow. `invitation` is a one-use value supplied by your app.
const viewerRtc = OpenRTC({ apiKey: import.meta.env.VITE_OPENRTC_API_KEY });
const viewerGrant = await viewerRtc.broadcasts.accept(invitation);
const viewerSession = await viewerRtc.broadcasts.subscribe(viewerGrant);
const playback = new MediaStream();
const video = document.querySelector('video[data-live-player]') as HTMLVideoElement;
video.srcObject = playback;
video.playsInline = true;
const stopListening = viewerSession.media.onTrack(({ track }) => {
  playback.addTrack(track); // keep both audio and video tracks in one stream
  void video.play().catch(() => showPlayButton(video));
});

showPlayButton is your UI's user-gesture fallback for autoplay restrictions. When the viewer leaves, unsubscribe with stopListening(), close viewerSession, and call viewerRtc.broadcasts.leave(viewerGrant) if that role should be retired. A late viewer repeats the same invitation and subscribe flow; it must receive the current source generation rather than a cached media URL. Show an offline or retrying state when no tracks arrive. Do not substitute a looping local preview for proof that a viewer received live media.

4. Add chat and creator page content

Treat chat as an app service with its own authentication, rate limits, storage, and moderation. A simple design allows anonymous GET/event-stream reads and requires a signed-in user for POST; neither operation needs a publisher grant. If you let broadcasters upload compiled page bundles, serve them from a separate, cookie-free origin and bridge only the player and permitted chat actions into the page. Do not give custom page scripts owner grants, identity provider sessions, or relay credentials. A fixed starter page should work even when no custom bundle has been published.

The referenced OpenRTC Live prototype demonstrates this shape with a Clerk owner, anonymous viewers, authenticated chat writes, an optional compiled TypeScript page, and one source slot named main. Those page, auth, and bundle features belong to the application, not to BroadcastSession.

OBS as the source

OBS does not call the browser getUserMedia() API. The Live prototype uses a local native WHIP bridge: OBS sends H.264/Opus to a loopback WHIP endpoint; the authenticated owner pairs the bridge once; the bridge publishes signed OpenRTC media objects into the same main broadcast. Its tested local demo used 720p30, about 2 Mb/s, and frequent keyframes so late viewers could start decoding. That bridge and pairing UI are example application code, not a public hosted OBS ingest endpoint or a feature provided by pnpm add openrtc. Do not expose a loopback bridge directly to the Internet.

The prototype exercised a local MoQ relay and a Cloudflare SFU carrier in separate short runs, including three initial viewers and a late fourth. The SFU run used signed OpenRTC objects over a DataChannel. Those runs demonstrate the architecture at small scale; they do not establish hosted availability, sustained reliability, or a tested N-viewer capacity for the public service.

Size the first broadcast

For one 2 Mb/s source, the rough delivered-media estimate is 2,000,000 / 8 × viewer-seconds. At 100 viewers watching for one hour, that is about 90 decimal GB before transport overhead and retries. Choose maxSubscribers, maxDurationSeconds, maxBitrateBps, and maxEgressBytes as independent bounds; monitor actual delivered bytes and close the broadcast when the event ends. Start with a small audience and measure a late join, refresh, reconnect, and the selected relay's egress before raising the cap. The pricing page describes the public customer credit quantity; it is not a capacity or availability guarantee.

Grant and session API

The signed grant decides whether a client is an owner, publisher, or subscriber; browser options cannot promote a viewer. OpenRTC verifies the source signature, grant generation, publication generation, replay window, and egress budget before an encoded object reaches the browser decoder. See the Broadcasts API for invitation, renewal, revocation, leave, and close methods. Public production hosting remains disabled while the preview release gates are incomplete.

Co-hosting

Give each co-host a separate publisher grant and source slot. Revoking one grant closes that source generation without closing the other hosts. Never share the owner's grant. Your app may show display names, but OpenRTC's media envelope uses anonymous source slots.

What OpenRTC owns

The shared Rust core owns role checks, grant and publication generations, source signatures, replay rejection, queue limits, one bounded retry, close, and hard usage limits on native and browser/WASM. Browser code and Tauri IPC only capture/render media and execute private relay commands.

Publisher grants contain only the public verification key. The private media signing key remains in an opaque native Rust or Rust/WASM signer. A copied grant cannot publish without that bound runtime signer. The relay allocation ID in a grant is a non-secret lookup label; it is not a relay URL or credential and is unusable without the verified grant and private host resolution.

The existing moq transport option still means the custom Iroh packet carrier for a peer connection. Broadcast distribution is separate and has no public provider, WebTransport, MoQ draft, SDP, ICE, or relay-token API.

Privacy and credit limits

The broadcast path hides host network and durable OpenRTC device details from viewers. It does not hide network metadata from OpenRTC, the relay operator, your identity provider, billing systems, or lawful operator access. A separate direct chat or backstage connection can reveal more network information.

The main customer quantity is delivered data: selected video/audio bitrate times viewer time, plus measured protocol overhead. OpenRTC shows customer credits and remaining hard budget. The starting public value is $0.10 of customer credit per decimal GB delivered to viewers. Set limits for viewers, bitrate, publishers, end time, and delivered data when your backend creates the broadcast.

You can estimate the public delivery credit before creating a grant. units is the projected delivered data in decimal GB and may be fractional:

const projectedDeliveredBytes = 750_000_000;
const estimate = rtc.usage.estimate({
  operation: 'broadcast.delivery',
  units: projectedDeliveredBytes / 1_000_000_000,
});

console.log(estimate.creditsUsd); // 0.075

The server ledger and your portal remain authoritative; this local estimate is for budgets and UI previews.

Peer media

Use two-way logical peer media for calls and small groups.