Nearglass
Secure

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 API

The 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.

FieldDefaultWhat
seata new seatthe 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_refnoneyour id for the viewer; it shows in the relay's records as viewer:<ref> (cut to 64 characters), or seat:<seat> without it
jointruetake 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)
ttl120the 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.

stateBody (besides seat and retry_after_ms)Means
waitposition (from 1)in line; retry_after_ms is 2000
offeroffer_sa seat is held for this viewer for that many seconds (only with join: false); retry_after_ms is 2000
fullretry_sthe 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": ... } }.

StatuscodeWhen
400invalid_requesta bad seat, a ttl outside 1 to 3600, an unknown field, a body that is not JSON
401unauthorizedno key, an unknown key, or a publishable key (pk_...) on a call that needs sk_...
402spend_cap_reachedyour spending cap is reached: no new seats, but a viewer already seated still gets tokens
404not_foundno such stream, or not yours
409tenant_viewer_capevery seat of your account is taken
429rate_limitedmore than 6 tokens for one seat in a minute
503clock_unsynchronizedthe 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):

FieldWhat
seatthe seat the last answer gave this page; absent on the first call
jointrue when the viewer pressed Join on an offer (or the player has auto-join), else false

The route must:

  1. check the user (your session, your rules);
  2. pass seat and join to viewer-tokens (the seat is what makes a reconnect keep the viewer's seat instead of taking a second one; a route that drops join still works, and seats a viewer the moment one is offered);
  3. answer the API's status and body unchanged: 201 with the descriptor, 409 in 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 410 seat_lost at 25 s; a hidden one still held at 50 s and was gone at 62 s.)
  • Asking for a token (viewer-tokens or POST /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, with DELETE /v1/streams/{id}/viewers/{seat} (a kick: 204, the relay session is closed at once; 404 not_found for 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}.

On this page