# Chat and reactions

> Live viewer chat and emoji reactions for a stream, with moderation. Use the API for your own chat UI next to any player. Beta.

Viewers pick a nickname (no account) and chat. Everyone sees new messages within 2–3 seconds, and emoji reactions float up as bursts. You moderate through the API: delete messages, ban viewers, slow mode, pause, pin, clear, a word filter, and posts with a **Host** or **Moderator** badge.

**Beta, free for now.** Chat and reactions aren't billed. We record message and reaction counts per stream to set a price later, and we'll tell you before that changes.

Chat belongs to a stream but doesn't need our player. You can use it three ways:

| Where | What | Status |
|---|---|---|
| The hosted player | Chat beside the video on `embed_url`, reactions over it | Available |
| The hosted chat page | `https://play.joacdn.com/{id}/chat`: chat only, to put in an iframe next to any player | Available |
| Your own UI | The public chat endpoints below, next to any player (yours, YouTube's, or none), and [reactions over your own video](#reactions-over-your-own-player) | Available |

All three follow the same rules: the server enforces limits, bans and the word filter, never the browser.

## Turn it on

Chat is off by default. Turn it on when you create the stream, or later with `PATCH`:

```bash
curl https://api.justonair.com/v1/streams \
  -H "Authorization: Bearer $JOA_API_KEY" -H "Content-Type: application/json" \
  -d '{"name": "Friday show", "chat": {"enabled": true, "reactions": true}}'
```

| `chat.` | Default | |
|---|---|---|
| `enabled` | `false` | Viewer chat. Always off while `player.enabled` is `false`. |
| `reactions` | `false` | Emoji reactions. |
| `moderation_group` | `null` | Bans and your word list apply to every stream of your account with the same key. See [Moderation groups](#moderation-groups). |
| `host_name` | `null` | The name on your own posts when a post gives none ("Host"). |
| `visible_after_end` | `false` | Keep the chat visible, read-only, after the stream ends. |
| `builtin_word_filter` | `true` | The built-in Turkish and English word filter. |

In the [dashboard](https://app.justonair.com), the stream's settings have the same switches, and the stream page has a live moderation card.

## On the hosted player

Nothing to add to your page: the player behind `embed_url` shows chat as soon as the stream has it.

- **Opened as a page** (the player link): chat beside the video on wide screens, below it on phones.
- **In your iframe:** a **Chat** button at the top right opens chat over the video. `?chat=1` opens it at once; `?chat=0` hides chat completely.
- **Reactions:** a bar of six emoji over the bottom of the video, in the waiting room too. In full screen it shows with the controls. `?reactions=0` hides them.
- Viewers pick a nickname the first time they post; the browser remembers it.
- `?lang=tr` or `?lang=en` sets the chat's language (default: the viewer's browser).

## The hosted chat page

Chat on its own, no video, for when the video plays somewhere else (your own player, YouTube, a venue screen):

```html
<iframe src="https://play.joacdn.com/str_e1bzk3dxw9z9allei6n7/chat" style="width:100%;height:520px;border:0"></iframe>
```

It has the reaction bar at the bottom, and the same options as the player: `?lang=tr`, `?accent=ff7a3d`, and `?reactions=0` for no reaction bar (when you draw reactions over your own video: see [Reactions over your own player](#reactions-over-your-own-player)). The dashboard shows this snippet as **Chat only (embed)** when chat is on.

## When chat is open

The public read and the embed read's `chat` block carry a `state`:

| `state` | Meaning |
|---|---|
| `open` | Viewers post and react. From `pending` (a waiting room before the show) until 10 minutes after the end. |
| `paused` | You paused it. Messages stay visible; only you can post. |
| `read_only` | After the end, kept visible because `visible_after_end` is on. |
| `closed` | Not open (yet, or any more). Nothing is shown. By default chat closes 10 minutes after the end; you still have everything in your feed. |
| `off` | Chat is off for this stream, the hosted player is off, or the stream expired or was cancelled. |

New accounts get no waiting-room chat: their chat opens when the stream goes live. Trusted and partner accounts get it from `pending`.

## Build your own chat UI

Everything a viewer's browser needs is public: no API key, no cookies, CORS open to any site.

### 1. Get a viewer session

Once per browser and stream. Keep it (for example in `localStorage`) until `expires_at`.

```js
const API = 'https://api.justonair.com';
const CDN = 'https://play.joacdn.com/api';
const id = 'str_e1bzk3dxw9z9allei6n7';

const session = await fetch(`${API}/v1/embed/${id}/session`, { method: 'POST' }).then((r) => r.json());
// { object: 'viewer_session', token: 'vs1.…', session_id: 'vsn_…', u: 0.4182, expires_at: '…' }
```

The session is signed by JustOnAir, so it can't be forged or swapped for free, and bans stick to it. Its `u` is a random number drawn by the server, used for [reaction sampling](#3-send-reactions).

### 2. Read and post

Poll the read through the CDN every `poll_after_seconds` (2 s while open). Every viewer gets the same cached answer, so a big audience costs almost nothing:

```js
const shown = new Map(); // id -> message
async function poll() {
  const chat = await fetch(`${CDN}/embed/${id}/chat`).then((r) => r.json());
  for (const m of chat.messages) shown.set(m.id, m);
  for (const gone of chat.deleted_ids) shown.delete(gone);
  if (chat.cleared_at) for (const [k, m] of shown) if (m.created_at <= chat.cleared_at) shown.delete(k);
  render(chat, [...shown.values()]); // chat.state, chat.pinned, chat.slow_mode_seconds, chat.reactions
  setTimeout(poll, chat.poll_after_seconds * 1000);
}
poll();
```

Post straight to the API, never through the CDN:

```js
const res = await fetch(`${API}/v1/embed/${id}/chat/messages`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ token: session.token, nickname: 'Ayşe', text: 'Merhaba!' }),
});
const body = await res.json();
if (!res.ok) showError(body.error); // see the table below
```

Show the viewer's own message at once, and mark it confirmed when its `id` turns up in the read.

**Always render text as text.** Set `textContent`, never `innerHTML`. Messages can contain anything a person can type.

Show a badge only when `role` is `host` or `moderator`. Only your API key can set it. A viewer who calls themselves "Host" gets no badge.

| Error code | Status | Show the viewer |
|---|---|---|
| `chat_slow_mode` | 429 | "Slow mode: wait N s" (`details.retry_after_seconds`) |
| `chat_rate_limited` | 429 | "Too many messages from your network, wait a minute" |
| `chat_busy` | 429 | "Chat is busy, try again" |
| `chat_paused` | 409 | "Chat is paused" |
| `chat_closed` | 409 | Chat is not open (`details.state`) |
| `chat_message_not_allowed` | 422 | "Links are not allowed" |
| `invalid_nickname` | 400 | The message says why |
| `invalid_session` | 401 | Get a new session and try again |

A banned viewer, or a message caught by the word filter, gets a normal `201`. The message is stored for you and appears only in its sender's own view. This is a shadow ban: trolls don't know to switch browsers.

### 3. Send reactions

One request per click would be far too many at a big event, so count clicks in the browser and send them in batches:

```js
const counts = {}; // heart, clap, laugh, fire, party, wow
function react(emoji) { counts[emoji] = (counts[emoji] || 0) + 1; floatLocally(emoji); }

setInterval(() => {
  const s = lastChat.reaction_sampling; // from the read; null while reactions are off
  const r = Object.entries(counts).map(([k, n]) => `${k}:${Math.min(n, 10)}`).join(',');
  for (const k in counts) delete counts[k];
  if (!s || !r || session.u >= s.rate) return; // not sampled: your clicks still float for you
  navigator.sendBeacon(`${API}/v1/embed/${id}/chat/reactions?t=${encodeURIComponent(session.token)}&r=${r}`);
}, 3000);
```

Under load only a share of viewers send (`rate` falls) and the server scales their counts up, so totals stay right while requests stay flat. Everyone sees the result in the read's `reactions`, which counts reactions since the previous read. Play them as a burst, one animation per few reactions, and cap animations per second.

Emoji keys: `heart` ❤️, `clap` 👏, `laugh` 😂, `fire` 🔥, `party` 🎉, `wow` 😮.

## Reactions over your own player

Reactions belong to the stream, not to our player, so you can draw them over any video: your own `<video>` with hls.js, Video.js, Shaka, Plyr, or a YouTube iframe. You need two things:

1. **A wrapper.** An element around the video with `position: relative`. Reactions float in a layer inside it, and the layer lets clicks through (`pointer-events: none`), so the player's own controls keep working.
2. **The script below.** It reads everyone's reactions from the cached chat read, floats them as bursts, and sends the viewer's clicks in batches.

### The wrapper

```html
<div class="stage" id="stage">
  <video id="video" playsinline controls></video>
</div>
```

```css
.stage { position: relative; }

.joa-floats { position: absolute; inset: 0; overflow: hidden; pointer-events: none; z-index: 2; }
.joa-float {
  position: absolute; bottom: 64px; left: var(--x); font-size: 28px; line-height: 1;
  animation: joa-rise var(--dur) ease-out forwards;
}
@keyframes joa-rise {
  0%   { transform: translate(0, 0) scale(.6); opacity: 0; }
  15%  { transform: translate(0, -20px) scale(1); opacity: 1; }
  100% { transform: translate(var(--drift), calc(-1 * var(--rise))); opacity: 0; }
}

.joa-bar { position: absolute; right: 12px; bottom: 64px; z-index: 3; display: flex; gap: 4px; }
.joa-bar button {
  font-size: 22px; line-height: 1; padding: 6px 8px; border: 0; border-radius: 999px;
  background: rgb(0 0 0 / .45); cursor: pointer;
}
.joa-bar[hidden] { display: none; }
```

`bottom: 64px` keeps the bar and the emoji above a typical control bar. Move them wherever suits your player.

### The script

No dependencies, no API key. Copy it as it is:

```js
const API = 'https://api.justonair.com';
const CDN = 'https://play.joacdn.com/api';
const EMOJI = { heart: '❤️', clap: '👏', laugh: '😂', fire: '🔥', party: '🎉', wow: '😮' };

/**
 * Reactions over any video. `target` is the element around it (position: relative),
 * or a selector for it such as '.video-wrapper'. Options: bar: false to wire your own
 * buttons to react(); draw(key) to return your own node (an <img>, an SVG) instead
 * of the emoji character.
 */
export function joaReactions(target, id, { bar = true, draw = (key) => document.createTextNode(EMOJI[key]) } = {}) {
  const wrapper = typeof target === 'string' ? document.querySelector(target) : target;
  if (!wrapper) throw new Error(`joaReactions: nothing matches ${target}`);
  const layer = Object.assign(document.createElement('div'), { className: 'joa-floats' });
  wrapper.append(layer);
  const reduced = matchMedia('(prefers-reduced-motion: reduce)');
  const counts = {};
  let read = null;
  let session = null;
  let stopped = false;
  let pollTimer, sendTimer;

  function float(key) {
    if (reduced.matches || layer.childElementCount >= 30) return;
    const el = Object.assign(document.createElement('span'), { className: 'joa-float' });
    el.append(draw(key));
    el.style.setProperty('--x', `${Math.round(Math.random() * 85)}%`);
    el.style.setProperty('--drift', `${Math.round((Math.random() - 0.5) * 60)}px`);
    el.style.setProperty('--rise', `${Math.round(layer.clientHeight * 0.6)}px`);
    el.style.setProperty('--dur', `${(2.2 + Math.random()).toFixed(2)}s`);
    el.addEventListener('animationend', () => el.remove());
    layer.append(el);
  }

  // The viewer's own click: floats at once, sent with the next batch.
  function react(key) {
    if (!EMOJI[key] || !read?.reactions_enabled) return;
    counts[key] = (counts[key] || 0) + 1;
    float(key);
    void getSession();
  }

  let barEl = null;
  if (bar) {
    barEl = Object.assign(document.createElement('div'), { className: 'joa-bar', hidden: true });
    for (const key of Object.keys(EMOJI)) {
      const b = Object.assign(document.createElement('button'), { type: 'button' });
      b.setAttribute('aria-label', `React with ${key}`);
      b.append(draw(key));
      b.addEventListener('click', () => react(key));
      barEl.append(b);
    }
    wrapper.append(barEl);
  }

  // One session per browser and stream, asked for on the first click only.
  async function getSession() {
    if (session && Date.parse(session.expires_at) > Date.now() + 60_000) return session;
    const key = `joa_session_${id}`;
    try { session = JSON.parse(localStorage.getItem(key)); } catch { session = null; }
    if (session && Date.parse(session.expires_at) > Date.now() + 60_000) return session;
    session = await fetch(`${API}/v1/embed/${id}/session`, { method: 'POST' }).then((r) => (r.ok ? r.json() : null));
    try { if (session) localStorage.setItem(key, JSON.stringify(session)); } catch {}
    return session;
  }

  // Everyone's reactions: each read carries what arrived since the previous one.
  // Play them as a burst, one animation per few reactions, spread over the interval.
  async function poll() {
    let wait = 5;
    try {
      read = await fetch(`${CDN}/embed/${id}/chat`).then((r) => r.json());
      wait = read.poll_after_seconds || 5;
      if (barEl) barEl.hidden = !read.reactions_enabled;
      const entries = Object.entries(read.reactions || {}).filter(([k, n]) => EMOJI[k] && n > 0);
      const total = entries.reduce((sum, [, n]) => sum + n, 0);
      const shown = Math.min(12, total);
      for (let i = 0; i < shown; i++) {
        let pick = Math.random() * total;
        const [key] = entries.find(([, n]) => (pick -= n) < 0) || entries[0];
        setTimeout(() => float(key), (i * wait * 1000) / shown);
      }
    } catch { /* try again shortly */ }
    if (!stopped) pollTimer = setTimeout(poll, wait * 1000);
  }

  // The viewer's clicks, in batches. Under load only a sampled share of viewers
  // send, and the server scales their counts up.
  function send() {
    const sampling = read?.reaction_sampling;
    if (session && sampling) { // until both are here, clicks wait for the next batch
      const r = Object.entries(counts).map(([k, n]) => `${k}:${Math.min(n, 10)}`).join(',');
      for (const k in counts) delete counts[k];
      if (r && session.u < sampling.rate) {
        navigator.sendBeacon(`${API}/v1/embed/${id}/chat/reactions?t=${encodeURIComponent(session.token)}&r=${r}`);
      }
    }
    if (!stopped) sendTimer = setTimeout(send, (sampling?.interval_seconds || 3) * 1000);
  }

  poll();
  send();
  return {
    react,
    destroy() {
      stopped = true;
      clearTimeout(pollTimer);
      clearTimeout(sendTimer);
      layer.remove();
      barEl?.remove();
    },
  };
}
```

Then point it at your wrapper, by element or by selector:

```js
joaReactions('.stage', 'str_e1bzk3dxw9z9allei6n7');
// or: joaReactions(document.getElementById('stage'), 'str_e1bzk3dxw9z9allei6n7');
```

Any element works: the one your player already has, or a `<div>` you add around it. If yours isn't positioned, give it `position: relative` in your CSS.

The bar shows only while the stream takes reactions (`reactions_enabled` in the read), so it appears and disappears by itself when you switch reactions on or off.

### With your player

Use the element your player takes into full screen as the wrapper. Then reactions stay visible in full screen too.

| Player | Wrapper | Full screen |
|---|---|---|
| `<video>`, hls.js, dash.js | Your own `<div>` around the `<video>` | Put your own full-screen button on the wrapper: `stage.requestFullscreen()`. The browser's built-in button takes only the `<video>`, and the reactions stay behind it. |
| Video.js | `player.el()` | Works: Video.js takes `player.el()` into full screen. |
| Shaka Player (UI) | The container you give `new shaka.ui.Overlay(player, container, video)` | Works: Shaka takes that container into full screen. |
| Plyr | `player.elements.container` | Works: Plyr takes its container into full screen. |
| YouTube or another iframe | Your own `<div>` around the `<iframe>` | Reactions show on the page. In the iframe's own full screen they don't, since that's the other site's page. |

```js
// Video.js
const player = videojs('my-video');
player.ready(() => joaReactions(player.el(), 'str_e1bzk3dxw9z9allei6n7'));
```

```jsx
// React
function Reactions({ wrapperRef, streamId }) {
  useEffect(() => {
    const r = joaReactions(wrapperRef.current, streamId);
    return () => r.destroy();
  }, [wrapperRef, streamId]);
  return null;
}
```

**iPhone:** Safari on iPhone plays full-screen video in its own native view, and nothing on the page can be drawn over it. Inline playback (`playsinline`) shows reactions as usual. iPad and other browsers take your wrapper into full screen.

### Your own look

- **Your buttons.** Pass `bar: false` and call `react('heart')` from your own buttons. The keys are fixed (`heart`, `clap`, `laugh`, `fire`, `party`, `wow`); how they look is up to you.
- **Your images.** Pass `draw: (key) => Object.assign(new Image(28, 28), { src: `/icons/${key}.svg`, alt: '' })` to float your own icons instead of emoji.
- **Placement, size and motion** are all in the CSS above. Keep the layer's `pointer-events: none` so your player stays clickable.
- **Reduced motion.** Viewers who ask their system for reduced motion see no floating emoji. Their clicks still count.
- **Not too much.** At most 12 animations per read and 30 on screen at once, however big the audience. A bigger crowd shows as a steady stream, not as more emoji at once.

### Reactions without chat

Reactions use the chat's viewer session and state, so turn on both `chat.enabled` and `chat.reactions`. If you want reactions but no chat, don't show a chat UI, and pause chat so nobody can post through the API either:

```bash
curl -X PATCH https://api.justonair.com/v1/streams/str_e1bzk3dxw9z9allei6n7/chat \
  -H "Authorization: Bearer $JOA_API_KEY" -H "Content-Type: application/json" \
  -d '{"paused": true}'
```

Reactions keep working while chat is paused.

### With the hosted chat page

To keep our chat next to your player but draw reactions on your video, use the chat page without its reaction bar, `?reactions=0`, and the script above over your video:

```html
<iframe src="https://play.joacdn.com/str_e1bzk3dxw9z9allei6n7/chat?reactions=0" style="width:100%;height:520px;border:0"></iframe>
```

Both work from the same stream: reactions clicked on your video show up for everyone, whichever page they're watching.

## Moderate

Your side uses your API key (or the dashboard). Every action reaches viewers within about 2 seconds.

### The feed

Poll it from your backend every 1–2 s while live, passing the previous `next_after`:

```bash
curl "https://api.justonair.com/v1/streams/str_e1bzk3dxw9z9allei6n7/chat?after=0" \
  -H "Authorization: Bearer $JOA_API_KEY"
```

It has every message, including deleted, filtered and shadow-banned ones, and what we know about each sender:

| Field | |
|---|---|
| `session_id` | The viewer session (ban by it) |
| `fingerprint` | 6 characters from their address: the same fingerprint under a new nickname is probably the same person |
| `network` | Their network, never their address: `85.105.12.0/24` (IPv6: /48) |
| `country`, `asn` | Country and network operator (home ISP, mobile carrier, VPN or hosting), best effort, null when unknown |
| `filtered`, `shadow`, `cleared`, `deleted_at`, `visible` | What viewers see and why not |

You never see a viewer's full IP address, and nor does anyone else. We don't store it.

The feed also carries the live state, `reactions.recent` and `reactions.total` (per emoji), and `totals` (messages posted and shown).

### Actions

| Action | Call |
|---|---|
| Post with a badge | `POST /v1/streams/{id}/chat/messages` `{"text": "…", "role": "host" \| "moderator", "display_name": "Ayşe"}`. Links allowed; works while paused. |
| Delete a message | `DELETE /v1/streams/{id}/chat/messages/{message_id}` |
| Ban a viewer | `POST /v1/streams/{id}/chat/bans` `{"message_id": "msg_…", "delete_messages": true}` (or `session_id`) |
| Lift a ban | `DELETE /v1/streams/{id}/chat/bans/{ban_id}`; list them with `GET …/chat/bans` |
| Slow mode, pause, pin | `PATCH /v1/streams/{id}/chat` `{"slow_mode_seconds": 10, "paused": true, "pinned_message_id": "msg_…"}` (`null` unpins) |
| Clear | `POST /v1/streams/{id}/chat/clear`: hides everything so far, and the pin |
| Word list | `PUT /v1/streams/{id}/chat/words` `{"words": ["spoiler", "kötü söz"]}` |
| Chat or reactions off | `PATCH /v1/streams/{id}` `{"chat": {"enabled": false}}` |

A ban covers the viewer's session **and** their address, so a new browser on the same network stays banned. A school or venue behind one address shares that ban, so check `fingerprint` and `network` before banning someone who might be one of many.

### The word filter

Messages with a listed word are stored and shown only to you (and their sender). Matching is by whole word, in any case, and sees through simple tricks (`sh1t`, `fuuuck`). Words inside longer words never match. The built-in Turkish and English list is on by default; turn it off with `chat.builtin_word_filter: false`. Your own words apply either way, up to 500 words or phrases of up to 60 characters.

### Moderators

Let someone else moderate without giving them your API key or a dashboard account: create a **moderator link** for them.

```bash
curl https://api.justonair.com/v1/streams/str_e1bzk3dxw9z9allei6n7/chat/moderators \
  -H "Authorization: Bearer $JOA_API_KEY" -H "Content-Type: application/json" -d '{"name": "Mert", "active_days": 7}'
```

The answer has `invite_url`, shown only this once: `https://play.joacdn.com/str_…/chat#mod=…`. Send it privately. It works **once**. The first browser that opens it becomes this moderator, and the link is dead for anyone after. Unused links expire after 7 days.

`active_days` (1–30, default 30) is how long they stay a moderator after opening the link: `1` for a single show, `30` for a regular helper. Then they're `expired`, and you invite them again if you want them back. `active_until` on the moderator says when.

Opening the link shows Mert a short confirmation: he's a moderator, what he can do, where (this stream, or every stream of the moderation group) and until when. Opened between shows, it tells him to open your watch page when the next one starts; he can close the page. On the chat page, and on the player's chat, he then gets a moderator bar: slow mode, pause, and on every message **Pin**, **Delete** and **Ban**. His posts carry the Moderator badge and the name you gave him; he can't change it, so nobody can pose as a moderator under another name. He can't turn chat off, change the word list or invite others; those stay with you. Your feed records who deleted and banned what (`deleted_by`, `created_by`).

`GET …/chat/moderators` lists them (invited, active, expired, revoked); `DELETE …/chat/moderators/{id}` removes one at once. With a moderation group, a moderator covers every stream of the group. In the dashboard: **Moderators** in the chat card.

### Moderation groups

Bans and word lists apply to one stream by default. If one account runs streams for several hosts, give each host's streams the same `chat.moderation_group` (for example the host's user id). Each host's ban list and word list then follow them from show to show, and never reach another host.

## Allowed sites

By default anyone can embed the player and the chat page of any stream they know the id of. Limit it to your own sites with `player.allowed_domains`:

```bash
curl -X PATCH https://api.justonair.com/v1/streams/str_e1bzk3dxw9z9allei6n7 \
  -H "Authorization: Bearer $JOA_API_KEY" -H "Content-Type: application/json" \
  -d '{"player": {"allowed_domains": ["example.com", "*.example.com"]}}'
```

| Entry | Allows |
|---|---|
| `example.com` | `https://example.com` only |
| `*.example.com` | its subdomains (`www.`, `shop.`), not `example.com` itself |
| `http://localhost:3000` | a scheme and port, for development |

Then:
- browsers show the player and the chat page only inside those sites; everywhere else the frame stays empty;
- chat refuses sessions, posts and reactions from browsers on other sites (`origin_not_allowed`).

Changes take up to a minute. An empty list (the default) allows every site.

What it does not do: it doesn't lock the video. A script outside a browser can claim any site. The public embed read still gives the signed playback URL to anyone with the stream id. To control who watches, use [short-lived playback tokens](/own-player#members-only-streams).

## The chat log

The dashboard's chat card has **Export CSV**: every message, hidden ones too, with its flags, fingerprint, network, country and network operator. It's there until the chat is deleted, 30 days after the stream ends. Through the API, page through `GET /v1/streams/{id}/chat?after=…` from `after=0`.

## Server-side SDK

Moderating from your server with the [Node.js SDK](https://www.npmjs.com/package/justonair) (0.2.0 or later):

```ts
import JustOnAir from 'justonair';
const joa = new JustOnAir();
const id = 'str_e1bzk3dxw9z9allei6n7';

// Every message once, oldest first (deleted, filtered and shadow-banned ones too).
for await (const m of joa.chat.watch(id)) {
  console.log(m.nickname, m.text, m.country, m.visible ? '' : '(hidden)');
  if (/buy followers/i.test(m.text)) await joa.chat.ban(id, { message_id: m.id, delete_messages: true });
}
```

The same SDK runs in a browser without a key for the public calls: `embed.session`, `embed.chat`, `embed.postMessage` and `embed.react`.

## Limits

| | |
|---|---|
| Nickname | 2–24 characters; links refused |
| Message | 1–200 characters; links and bare domains refused (your own posts may link) |
| Per viewer | 1 message every 3 s, or the slow mode if higher |
| Per address, per stream | 30 messages a minute |
| Automatic slow mode | Over 10 messages a second for 10 s: 10 s, then 30 s; steps back down after 30 s of calm. Your slow mode is a floor. |
| Per stream | 50 messages a second, then `chat_busy` |
| Reactions | One batch per viewer every 3 s, up to 10 per emoji; sampled to about 200 batches a second per stream |
| Slow mode | 0–300 s |
| Moderators | 50 per stream or moderation group; links expire after 7 days unused; once opened, a moderator lasts `active_days` (1–30, default 30) |
| `player.allowed_domains` | 20 sites |
| Messages in the read | The last 50 |

## Data and retention

Chat messages and nicknames are viewer data. You are the controller and JustOnAir is the processor, as for the rest of your viewers' data. We keep:

- messages, with a keyed hash of the sender's address, its masked network, country and network operator, until **30 days after the stream ends**, then delete them;
- bans for 30 days after the stream ends, or for a moderation group, 30 days after its last stream;
- per-stream totals (counts only, no viewer data) with the stream.

We never store a viewer's IP address in chat.

## Attribution

The built-in word filter starts from the [List of Dirty, Naughty, Obscene, and Otherwise Bad Words](https://github.com/LDNOOBW/List-of-Dirty-Naughty-Obscene-and-Otherwise-Bad-Words) (CC BY 4.0), with a few everyday words removed. Country and network names: [IP Geolocation by DB-IP](https://db-ip.com) (CC BY 4.0).
