# Viewer tokens

> Tell JustOnAir who your logged-in viewers are. Their real name in chat, bans that follow the person, and moderators from your own staff.

## What this is for

Without viewer tokens, everyone who watches through our player is anonymous. In chat, anyone can type any nickname, so a troll can call himself "Teacher". A ban sticks to one browser, so the banned person opens another browser or their phone and is back.

If your viewers already log in to **your** site (a course platform, a members' area, a company portal), you know who they are. A viewer token passes that on to us. Your server writes a short note for each viewer, *"this is user 123, Ayşe Yılmaz, valid for 10 minutes"*, and signs it with a secret key you get from our dashboard. Your page opens our player with the note attached. We check the signature, and from then on chat knows who that viewer is.

It works like a wristband at a concert: the door checks the ticket once and gives a stamped wristband, and staff inside trust the stamp.

With a viewer token:

- **Their real name in chat.** Ayşe Yılmaz posts as Ayşe Yılmaz, with a check mark. Anyone can still type that nickname, but only she gets the check mark.
- **Bans follow the person**, on every device, because you ban user 123, not a browser. Others behind the same office or venue network are not banned with them.
- **Moderators from your own staff.** A token can say `"role": "moderator"`: that person gets the Moderator badge and the moderation tools (delete, ban, slow mode, pause, pin), without a [moderator link](/chat#moderators).
- **You see who said what.** Your [chat feed](/chat#moderate) shows your own id for each viewer (`viewer_id`).
- **Members-only streams on our player.** Set `player.require_viewer_token` and the stream plays only for viewers who come with a token. Nobody can pass the link around. Remove a viewer and their chat stops at once, their video within five minutes. See [Members-only streams](#members-only-streams).
- **Attendance.** A list of who watched and for how long: Ayşe 47 minutes, Mehmet 12. Course and webinar platforms need it for certificates and reports. See [Attendance](#attendance).

Anonymous viewers keep working as before on the same stream. Viewer tokens are optional and per page load: you decide who gets one.

## Set it up

### 1. Make a viewer key

In the dashboard, open **API keys**, then **Create a viewer key** under **Viewer keys**. You get a key id (`vk_…`) and a secret (`vks_…`). The secret is shown once: put it in your server's configuration, next to your API key. Never put it in a web page or an app.

Keys are made and deleted in the dashboard only, like API keys: a leaked API key cannot make one. Deleting a key refuses every token it signed within a few seconds; chat sessions already open keep working (up to 24 hours), so ban anyone you need to stop at once. An account can have 10 keys, so you can rotate: make the new one, deploy it, delete the old one.

### 2. Sign a token for each viewer

A viewer token is a standard JWT signed with HS256. Make one on your server each time a logged-in viewer opens the page with the player:

| | | |
|---|---|---|
| Header `kid` | required | Your viewer key's id, `vk_…`. |
| `sub` | required | Your id for this viewer, 1 to 64 characters. It never changes for the same person. |
| `name` | optional | The name chat shows, 1 to 40 characters. Without it, the viewer picks a nickname as usual (no check mark), but bans still follow them. |
| `role` | optional | `viewer` (default) or `moderator`. |
| `stream` | optional | A stream id (`str_…`) or channel id (`chn_…`): the token works only there. Leave it out for any stream of your account. |
| `exp` | required | When the token stops being accepted (Unix seconds), at most 24 hours away. On an ordinary stream ten minutes is plenty: the token is only used when the page opens. On a [members-only stream](#members-only-streams) it is also when the viewer's video stops, so set it to the end of the access you give (the end of the event, say). |

Node.js, no library needed:

```js
import crypto from 'node:crypto';

const KEY_ID = process.env.JOA_VIEWER_KEY_ID;         // vk_…
const KEY_SECRET = process.env.JOA_VIEWER_KEY_SECRET; // vks_…

function viewerToken(user, streamId) {
  const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
  const head = b64({ alg: 'HS256', typ: 'JWT', kid: KEY_ID });
  const body = b64({
    sub: String(user.id),
    name: user.fullName,
    role: user.isStaff ? 'moderator' : 'viewer',
    stream: streamId,
    exp: Math.floor(Date.now() / 1000) + 600,
  });
  const sig = crypto.createHmac('sha256', KEY_SECRET).update(`${head}.${body}`).digest('base64url');
  return `${head}.${body}.${sig}`;
}
```

Python, with PyJWT:

```python
import time, jwt  # pip install pyjwt

def viewer_token(user, stream_id):
    return jwt.encode(
        {"sub": str(user.id), "name": user.full_name, "stream": stream_id, "exp": int(time.time()) + 600},
        KEY_SECRET, algorithm="HS256", headers={"kid": KEY_ID},
    )
```

Any JWT library works the same way: HS256, the secret as the key, the key id as `kid`.

### 3. Put it in the player's address

Add the token after `#vt=` on the hosted player's link, or on the [chat page](/chat#the-hosted-chat-page)'s:

```html
<iframe src="https://play.joacdn.com/str_e1bzk3dxw9z9allei6n7#vt=eyJhbGciOi…"
        allow="autoplay; fullscreen; picture-in-picture" allowfullscreen
        style="width:100%;aspect-ratio:16/9;border:0"></iframe>
```

After `#`, the token never reaches a server: not ours, not the CDN's logs, not your analytics. The player reads it, opens a session with it, and removes it from the address. Make the iframe's link on your server, per viewer, when the page is requested.

### Your own chat UI

Send the token when you open the viewer session, then use the session as usual ([Chat and reactions](/chat#build-your-own-chat-ui)):

```js
const res = await fetch(`https://api.justonair.com/v1/embed/${streamId}/session`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ viewer_token: tokenFromYourServer }),
});
const session = await res.json();
// session.viewer: { id: 'user-123', name: 'Ayşe Yılmaz', role: 'viewer' }
// session.moderator: their moderator session when role is "moderator", else null
```

Posts from this session use the token's `name`, whatever `nickname` you send, and come back with `verified: true`: show a check mark next to those names. A refused token answers `401 invalid_viewer_token`, and the message says why (signature, expiry, key, stream).

## Bans

Ban a viewer by your own id, even before they say anything:

```bash
curl -X POST https://api.justonair.com/v1/streams/str_e1bzk3dxw9z9allei6n7/chat/bans \
  -H "Authorization: Bearer $JOA_API_KEY" -H "Content-Type: application/json" \
  -d '{"viewer_id": "user-123"}'
```

Banning one of their messages (`message_id`) bans them the same way: by who they are, not by their address. Bans are shadow bans, as for anyone: their messages look sent to them and nobody else sees them. To keep a banned person out for good, also stop signing tokens for them.

## Moderators

A token with `"role": "moderator"` makes that viewer a moderator of the stream (or of its [moderation group](/chat#moderation-groups)): the session answer carries their moderator session, and the hosted chat shows them the tools. They appear in your list of moderators with their `viewer_id`, and stay one for a day after each sign-in. Remove them there as usual: a removed moderator is not made a moderator again by later tokens.

## Members-only streams

Today a stream's link works for anyone who has it: the hosted player's status read is public, and so is the playback address in it. For a paid course or a members' event, make the stream members only:

```bash
curl -X POST https://api.justonair.com/v1/streams \
  -H "Authorization: Bearer $JOA_API_KEY" -H "Content-Type: application/json" \
  -d '{"name": "Module 3, live", "player": {"require_viewer_token": true}, "replay": true}'
```

(Or `PATCH` an existing stream, or put it in a [channel](/channels)'s `defaults.player`.)

Then:

- **The public read has no playback address** (`access: "viewer_token"`, `playback_url: null`), and neither does its replay. Sharing the link gets nobody in.
- **A viewer token opens the video.** The player sends it, and the answer gives that viewer their own playback address. It works for five minutes, and the player renews it in the background until the token's `exp`. So set `exp` to how long the viewer may watch: the end of the event, at most 24 hours.
- **Without a token**, the player shows "Members only" and asks the viewer to open the stream from your site. No anonymous chat either.
- **Remove a viewer** with a ban by their id (`POST …/chat/bans` with `viewer_id`). Chat stops at once; the video stops when their current address runs out, within five minutes. When `exp` passes, the video stops the same way.
- **Replays** are members only too: each member gets their own replay address.

Your own player can do the same with the session's `playback` block: play `playback.url`, and before `playback.expires_at` call `POST /v1/embed/{id}/playback` with the session token for a new one. A refusal says `viewer_removed` or `viewer_access_ended`.

What members-only does not hide: the stream's title, its poster when you made thumbnails public, and the chat messages, which anyone with the stream id can still read. Posting needs a token.

## Attendance

For viewers who come with a token, the hosted player reports every 30 seconds whether they are watching, and we add up their time. Paused and waiting time doesn't count. You get the list:

```bash
curl https://api.justonair.com/v1/streams/str_e1bzk3dxw9z9allei6n7/attendance \
  -H "Authorization: Bearer $JOA_API_KEY"
```

```json
{
  "object": "list",
  "data": [
    { "object": "attendance", "viewer_id": "user-123", "name": "Ayşe Yılmaz", "watch_seconds": 2820,
      "first_seen_at": "2026-10-09T18:00:12.000Z", "last_seen_at": "2026-10-09T18:47:40.000Z" }
  ],
  "has_more": false,
  "next_after": null,
  "total": 1
}
```

It is ordered by `viewer_id`; pages of up to 1000 (`limit`, then `after=` the previous `next_after`). While the stream runs it is up to a minute behind. The dashboard shows it on the stream's page, with a CSV download. Anonymous viewers are counted ([viewer counts](/player#viewer-counts)) but never listed.

## Security notes

- The secret signs tokens; keep it on your server. Anyone with it can sign in as any of your viewers on your streams, including as a moderator.
- Keep `exp` short. A token in a link can be copied; a short one can't be reused for long.
- `stream` limits a token to one stream or channel; use it when you sell access per event.
- The session a token opens lasts up to 24 hours, so a viewer who leaves the page open keeps chatting. To stop someone during that time, ban them. On a members-only stream their video stops at the token's `exp` anyway.
