Viewer tokens
Mint viewer tokens on your server, so your own sign-in decides who watches.
With server admission (a stream's default, "admission":"server"), our
API gives seats only to your backend, called with your secret key. Your
backend decides who may watch (your own login, a ticket, a subscription),
and the page gets a token only through it. Public admission
(Quickstart) is the same flow with the decision left out.
page --POST /api/watch/st_... (cookie, seat, join)--> your backend
your backend --POST /v1/streams/st_.../viewer-tokens (sk_, seat, join, viewer_ref)--> our API
our API --201 descriptor, or 409 in line--> your backend --> page --> relay
page --POST {play_url}/hb (seat) every 5 s--> our APIThe page is <nearglass-player token-url="/api/watch/st_...">: the player
makes the calls on the page's side (the seat passed back, the line, the
heartbeat, giving the seat up), so your backend has one route to write.
The call
curl -s $API/v1/streams/$ID/viewer-tokens -H "Authorization: Bearer $SK" \
-d '{"viewer_ref":"user-1234"}'The body is JSON (any content type is read as JSON, and an empty body is
{}). A field the call does not know is refused with 400, so a typo does not
pass silently.
| Field | Default | What |
|---|---|---|
seat | a new seat | the seat this viewer already has: pass it back on every later call. It must be an id this API returned (se_ and 32 hex digits), else 400 |
viewer_ref | none | your id for the viewer; it shows in the relay's records as viewer:<ref> (cut to 64 characters), or seat:<seat> without it |
join | true | take a seat offered to this viewer at once (false: answer offer and wait for another call; the player sends false until the viewer presses Join) |
ttl | 120 | the token's life in seconds, 1 to 3600; the player renews it while the viewer watches (Token renewal) |
201, seated. Hand this to the player whole:
{
"state": "in",
"seat": "se_5d1c0a...",
"heartbeat_ms": 5000,
"token": "0oRIogEm...",
"token_id": "s-9f0c...",
"expires_at": "2026-10-27T18:02:00Z",
"exp": 1793124120,
"relays": [{ "url": "https://relay.nearglass.com/tn_9a1..." }],
"namespace": "live/st_8f2c...",
"catalog": "catalog",
"hls": "https://api.nearglass.com/v1/play/st_8f2c.../hls/index.m3u8",
"play_url": "https://api.nearglass.com/v1/play/st_8f2c..."
}play_url is where the player keeps the seat (POST {play_url}/hb every
heartbeat_ms) and gives it up ({play_url}/leave), with the seat id
alone; a 409 carries it too, so a viewer who closes the page leaves the line
at once. hls is there for streams an encoder sends (SRT, RTMP, WHIP), not
for streams a device publishes with the SDK. Where the relay has a
self-signed certificate the answer also carries relay_cert_sha256.
409, not seated yet. The stream is full and this viewer is in line. Call
again with the seat after retry_after_ms.
state | Body (besides seat and retry_after_ms) | Means |
|---|---|---|
wait | position (from 1) | in line; retry_after_ms is 2000 |
offer | offer_s | a seat is held for this viewer for that many seconds (only with join: false); retry_after_ms is 2000 |
full | retry_s | the line is full too; retry_after_ms is 30000 |
A 409 whose body is an error, tenant_viewer_cap, means every seat of your
account is taken (Admission and caps).
Errors
Every error is { "error": { "code": ..., "message": ... } }.
| Status | code | When |
|---|---|---|
| 400 | invalid_request | a bad seat, a ttl outside 1 to 3600, an unknown field, a body that is not JSON |
| 401 | unauthorized | no key, an unknown key, or a publishable key (pk_...) on a call that needs sk_... |
| 402 | spend_cap_reached | your spending cap is reached: no new seats, but a viewer already seated still gets tokens |
| 404 | not_found | no such stream, or not yours |
| 409 | tenant_viewer_cap | every seat of your account is taken |
| 429 | rate_limited | more than 6 tokens for one seat in a minute |
| 503 | clock_unsynchronized | the API's clock is not synchronized, so it mints nothing (Time sync) |
A real refusal, from a test stack:
$ curl -s -w ' [%{http_code}]\n' $API/v1/streams/$ID/viewer-tokens -H "Authorization: Bearer $SK" -d '{"seat":"abc"}'
{"error":{"code":"invalid_request","message":"seat: an id this API returned (se_ and 32 hex digits)"}} [400]Your route (the token-url contract)
The player POSTs your route a JSON body, with the page's cookies
(credentials: same-origin):
| Field | What |
|---|---|
seat | the seat the last answer gave this page; absent on the first call |
join | true when the viewer pressed Join on an offer (or the player has auto-join), else false |
The route must:
- check the user (your session, your rules);
- pass
seatandjointoviewer-tokens(the seat is what makes a reconnect keep the viewer's seat instead of taking a second one; a route that dropsjoinstill works, and seats a viewer the moment one is offered); - answer the API's status and body unchanged:
201with the descriptor,409in line (the player shows the line and asks again), any other status as an error.
The player calls the route again whenever it needs a fresh token for the seat it holds: at a reconnect, and a little before each token expires (Token renewal). Keep the route cheap and let it answer the same way every time.
Plain Node 20 or later, no framework (fetch and Response are built in):
// POST /api/watch/:stream body: { seat?, join? }
export async function watch(request, streamId, user) {
if (!user) return new Response('sign in first', { status: 401 });
const { seat, join } = await request.json().catch(() => ({}));
const r = await fetch(`${process.env.NEARGLASS_API}/v1/streams/${streamId}/viewer-tokens`, {
method: 'POST',
headers: { authorization: `Bearer ${process.env.NEARGLASS_SECRET_KEY}`, 'content-type': 'application/json' },
body: JSON.stringify({
viewer_ref: user.id,
...(typeof seat === 'string' ? { seat } : {}),
...(typeof join === 'boolean' ? { join } : {}),
}),
});
return new Response(await r.text(), { status: r.status, headers: { 'content-type': 'application/json' } });
}This ran against a test stack on Node 20: no user gives 401, a first call
gives 201 with a new seat, and a second call with that seat and
join: false gives 201 with the same seat and a new token.
The react-next sample has this as a Next.js route handler, with the page that calls it; the page side is in Embed the player.
Keep the secret key on the server: in an environment variable or a secrets manager, never in the page, never in git.
Seats over time
- The player heartbeats the seat itself (
POST {play_url}/hb, that is/v1/play/{id}/hb, no key needed: the seat id is the credential). A seat with no heartbeat for 20 s is freed and its relay sessions closed. A hidden tab says so in its heartbeat and keeps its seat for 60 s, because browsers slow a hidden tab's timers. (Run against a test stack: a seat that stopped beating answered 410seat_lostat 25 s; a hidden one still held at 50 s and was gone at 62 s.) - Asking for a token (
viewer-tokensorPOST /v1/play/{id}/token) for a held seat also counts as a heartbeat. - Closing the page gives the seat up (
/leave). So does your backend, withDELETE /v1/streams/{id}/viewers/{seat}(a kick: 204, the relay session is closed at once; 404not_foundfor a seat the stream does not hold). - A seat holds its two newest tokens; a third closes the oldest's session. So one seat cannot be shared across several browsers.
Your own seat server
If you already run admission yourself (your own line, your own caps), call
viewer-tokens once per connect with your own seat id as viewer_ref,
heartbeat our seat from your server (POST /v1/play/{id}/hb with
{"seat": ...} every 5 s while your seat lives), and kick it when your seat
goes. Size the stream's viewer_cap at least to your own cap.
Self-hosted relays
Without our API you mint tokens yourself with nearglass token mint --role viewer
and your own signing key; see the self-host sample.
Reference: POST /v1/streams/{id}/viewer-tokens,
DELETE /v1/streams/{id}/viewers/{seat}.