Concepts
Streams, keys, seats and tokens, the relay and the control plane, the viewer cap, controllers, metering and the fallback.
The few ideas the API, the player and the publishers are built on. Each section ends with where it is specified, for readers who want the detail.
encoder or device --SRT/RTMP or SDK--> ingest --MoQ--> relays --MoQ--> players (seats)
|
your backend --secret key--> control API --tokens-------- +
your page --publishable key--^ (seats, line, usage)The pieces
| Piece | What it does | Who runs it |
|---|---|---|
| Publisher | sends one stream: an encoder over SRT, RTMP or WHIP into our ingest gateway, or your device with the SDK straight to a relay | you |
| Ingest gateway | takes SRT, RTMP and WHIP from encoders and publishes them over MoQ; also makes the LL-HLS copy | us (or you, self-hosted) |
| Relay | fans one stream out to many viewers over MoQ, and admits only sessions with a valid token | us |
| Player | plays the stream: <nearglass-player> in a page, @nearglass/react, or the C library in a native app | your viewers |
| Control API | the control plane: streams, keys, seats and tokens, usage, spending caps, webhooks, the fleet | us; your backend and pages call it |
The live MoQ stream never passes through the control API (it only hands a seated viewer the LL-HLS fallback's files), and the control API never connects to a relay: the relays only check the tokens it signs, and an agent beside each relay reports to it (see Fleet and regions).
Streams
A stream is one live source and everyone watching it. You create it with
POST /v1/streams and get back its id (st_ and 24 hex digits) and where to
send video. Its settings:
| Setting | Default | What it does |
|---|---|---|
name | (required) | yours, 1 to 100 characters |
mode | broadcast | interactive adds controllers |
ingest | ["rtmp", "srt"] | how video arrives: rtmp, rtmps, srt, whip (from an encoder, with a stream key) or sdk (your device publishes with the SDK and a publisher token) |
viewer_cap | 100 | seats: viewers watching at once (the cap) |
queue_max | twice the cap | places in the waiting line |
admission | server | who hands out seats: your backend (server) or any page with your publishable key (public) |
namespace | live/<id> | the stream's name on the relays; set it when a device already uses one |
max_kbps | 0 (none) | a bitrate limit, recorded with the stream but not enforced yet: ingest accepts a higher bitrate |
A stream has no "start" call: it is live while video arrives. Deleting it stops admitting at once and closes every live session it has, viewers and publisher alike.
Changing a stream. PATCH /v1/streams/{id} changes name,
viewer_cap, queue_max and admission; the fields you leave out stay.
Nobody already watching is disturbed:
- A cap lowered below the viewers seated keeps them all in (their tokens go on renewing) and seats nobody new until fewer are left than the new cap.
- A raised cap seats the people in line, first in line first, as their pages next ask (every 2 s).
- A new
admissionchanges only how new viewers come in; seats already held keep working.
mode, ingest, namespace and max_kbps are fixed when the stream is
created: to change one of them, create a new stream.
Reference: POST /v1/streams,
PATCH /v1/streams/{id}.
Keys
| Key | Prefix | Who holds it | What it can do |
|---|---|---|---|
| Secret key | sk_ | your backend | everything for your account: streams, tokens, usage, settings |
| Publishable key | pk_ | your web pages | ask for a seat on a public stream, nothing else |
| Stream key | stk_ | your encoder | send video to one stream (it is in the SRT and RTMP addresses) |
Secret keys are stored by us as hashes only; a lost one cannot be shown
again, only replaced. A stream key is shown once, when the stream is created
or its key rotated (POST /v1/streams/{id}/stream-key, which also ends the
live ingest session). Your account is a tenant: its own keys, its own
signing key for tokens, and its own space on the relays, so a token made for
your streams is refused for anyone else's, whatever the stream is called.
Seats and tokens
A viewer watches through a seat. A seat is what the viewer cap counts, what the waiting line leads to, and what metering bills by the viewer-hour.
The relay lets a viewer in with a viewer token: a short signed token (an ES256 Common Access Token) for one stream, minted by the control API for one seat. Facts that shape how you use them:
- A token is renewed while the viewer watches. It lives 120 s by
default (
ttl, at most an hour). The relay keeps a joined viewer's session past the token's expiry for a watch grace, and the player moves to a fresh token with its seat before the grace runs out, by itself (POST /v1/play/{id}/token, or yourtoken-urlwith server admission). See Token renewal. - One token, one session. The relay allows one live session per token, so a token copied to a second browser is refused there.
- A seat is kept by a heartbeat. The player calls
POST /v1/play/{id}/hbevery 5 s. A seat with no heartbeat for 20 s (60 s for a hidden tab) is freed, and the relay closes its sessions. Closing the page gives the seat up at once (/leave). - Limits per seat: at most 6 tokens a minute, and a seat holds its two newest tokens (a third closes the oldest's session).
- Kicking a viewer (
DELETE /v1/streams/{id}/viewers/{seat}) frees the seat and closes its sessions at once.
Publishers have tokens too: the ingest gateway gets one per stream from the
API by itself; a device that publishes with the SDK (ingest: ["sdk"]) gets
a publisher token from your backend
(POST /v1/streams/{id}/publisher-tokens, an hour by default, up to 7
days).
Reference: viewer-tokens, publisher-tokens.
The viewer cap and the waiting room
Each stream has a cap on seats (viewer_cap). When it is full, a new viewer
gets a place in line instead of a seat:
| State | Means | The player shows |
|---|---|---|
in | seated: a token and the relays to use | the video |
wait | in line, at position (from 1) | "In line, number 37..." |
offer | a seat is free for this viewer for 10 s | a Join button (or joins by itself with auto-join) |
full | the line is full too | "The stream is full. Waiting for a seat..." and retries |
The offer exists so a tab nobody is looking at does not take a seat from someone who is. The API hands out seats up to the cap, and the relay lets in only tokens the API minted for a seat, one session each, so a page that skips the API cannot get in at all. The relay also caps your account's live sessions as a whole (Admission and caps).
Two ways to hand out seats, chosen per stream with admission:
server(the default): your backend callsPOST /v1/streams/{id}/viewer-tokenswith your secret key, after its own login, and passes the answer to the player. You decide who watches. See Mint viewer tokens on your server.public: the player itself callsPOST /v1/play/{id}/admitwith your publishable key. Anyone with the page can take a seat (rate limited per address: a burst of 20, then 2 a second). For open events and the Quickstart.
Controllers
An interactive stream adds a channel back to the source: a driver
sends commands (a joystick's state, 50 times a second by default) that reach
the device through the same relays, while everyone else watches. This is the
machine-control shape: one device, one driver at a time, many watchers.
- Your backend runs the queue of drivers. For each turn it mints a
controller token (
POST /v1/streams/{id}/controller-tokenswith aturn_id), which lets that driver watch and send commands for that turn only, oncontrol/<namespace>/<turn_id>. - The device decides when a turn ends (its own deadline), and stops by itself if commands stop arriving: the driver holds a dead-man, and the player's controller sends one stop and then silence when the tab is hidden, the window loses focus or the video stalls for 500 ms.
- Your backend ends a turn early with
DELETE /v1/streams/{id}/controller-tokens/{turn_id}, which closes the driver's control session at the relay.
See Controller seats.
Metering
What we bill and you can check:
- Viewer-hours: the time each viewer session lasts, summed per stream per day. A session under 10 s is not counted as time (a join that never saw a picture), but its bytes are.
- GiB: the bytes the relays sent, recorded beside viewer-hours, because egress is most of the cost and the bill must be checkable against it.
The relays write one record per closed session; LL-HLS viewers are recorded
by our HLS edge the same way, per seat. Days are UTC, and a session counts on
the day it ended, so a viewer watching now appears once their session
closes (a page moves to a new session about every 11 minutes). GET /v1/usage gives days, streams and the month so far, with MoQ and LL-HLS
shown apart. A spending cap stops new viewers, never the ones already
watching. See Usage and spending caps.
Fallback
The player plays over MoQ (Media over QUIC: WebTransport and WebCodecs in the browser) wherever it can, and falls back to LL-HLS (Low-Latency HLS, about 1 s behind) where it cannot:
| Browser or network | How it plays |
|---|---|
| Chrome, Edge (desktop) | MoQ |
| Chrome for Android, Samsung Internet | MoQ (H.264 decode depends on the device) |
| Firefox (desktop) | MoQ |
| Safari on Mac, iPhone and iPad (every iOS browser) | LL-HLS |
| Firefox for Android | LL-HLS |
| A network that blocks UDP | LL-HLS |
The switch is automatic: a browser without WebTransport or WebCodecs goes to LL-HLS at once; otherwise the player gives MoQ 3 s to connect and 3 s more for a first frame, plays LL-HLS meanwhile if that runs out, and switches back to MoQ as soon as a frame arrives. The bar under the video says which: "Low latency" or "Compatibility mode, about 1 s behind". A fallback viewer takes a seat and is metered like any other.
Limits today:
- LL-HLS exists only for streams sent from an encoder (SRT, RTMP, WHIP): a stream a device publishes with the SDK has no LL-HLS.
- Safari and most browsers play LL-HLS natively. Where a browser does not,
give the player hls.js (
el.hls = Hls); it is not bundled. - The fallback's delay has not been measured on real phones and networks yet, and LL-HLS has not been played on an iPhone through our service yet; treat "about 1 s" as the design figure.
Reference: GET /v1/play/{id}/hls/{file}.
Fleet and regions
A region is one origin relay and its edge relays. Your publisher connects to the origin of the stream's home region; the other regions' origins pull the stream from it, and each edge pulls from its own region's origin. Viewers connect to edges only.
The API lists, the player measures. Every token answer carries a short list of relays, the least loaded edges that are up in each region. The player opens a connection to each of them at once and plays from the first to answer, which is the nearest by round trip. If that edge goes away, the player moves to the next one on the list, in another region if it has to; the relay still checks the token, so the list is a hint, never access.
Each relay host runs a small agent beside the relay. It reports the relay's health and load to the control API every few seconds and takes back what the relay needs: your account's token scope, the routes to the origin, and the sessions to close when a seat is lost or a viewer kicked. A kick or a lost seat therefore reaches every relay in every region within a heartbeat.
See Fleet and regions and Relay selection.
A React app with low-latency video
A Next.js app with sign-in on your backend, streams created from your server, every player state handled, cleanup on unmount and error handling.
What works with what
Which ways of publishing play in which players, with sound or without, and where the LL-HLS fallback exists.