JustOnAir docs
View as Markdown

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:

WhereWhatStatus
The hosted playerChat beside the video on embed_url, reactions over itAvailable
The hosted chat pagehttps://play.joacdn.com/{id}/chat: chat only, to put in an iframe next to any playerAvailable
Your own UIThe public chat endpoints below, next to any player (yours, YouTube's, or none), and reactions over your own videoAvailable

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:

Shell
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
enabledfalseViewer chat. Always off while player.enabled is false.
reactionsfalseEmoji reactions.
moderation_groupnullBans and your word list apply to every stream of your account with the same key. See Moderation groups.
host_namenullThe name on your own posts when a post gives none ("Host").
visible_after_endfalseKeep the chat visible, read-only, after the stream ends.
builtin_word_filtertrueThe built-in Turkish and English word filter.

In the dashboard, 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). 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:

stateMeaning
openViewers post and react. From pending (a waiting room before the show) until 10 minutes after the end.
pausedYou paused it. Messages stay visible; only you can post.
read_onlyAfter the end, kept visible because visible_after_end is on.
closedNot open (yet, or any more). Nothing is shown. By default chat closes 10 minutes after the end; you still have everything in your feed.
offChat 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.

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

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:

JavaScript
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:

JavaScript
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 codeStatusShow the viewer
chat_slow_mode429"Slow mode: wait N s" (details.retry_after_seconds)
chat_rate_limited429"Too many messages from your network, wait a minute"
chat_busy429"Chat is busy, try again"
chat_paused409"Chat is paused"
chat_closed409Chat is not open (details.state)
chat_message_not_allowed422"Links are not allowed"
invalid_nickname400The message says why
invalid_session401Get 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:

JavaScript
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:

JavaScript
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:

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

PlayerWrapperFull screen
<video>, hls.js, dash.jsYour 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.jsplayer.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.
Plyrplayer.elements.containerWorks: Plyr takes its container into full screen.
YouTube or another iframeYour 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.
JavaScript
// 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:

Shell
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:

Shell
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_idThe viewer session (ban by it)
fingerprint6 characters from their address: the same fingerprint under a new nickname is probably the same person
networkTheir network, never their address: 85.105.12.0/24 (IPv6: /48)
country, asnCountry and network operator (home ISP, mobile carrier, VPN or hosting), best effort, null when unknown
filtered, shadow, cleared, deleted_at, visibleWhat 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

ActionCall
Post with a badgePOST /v1/streams/{id}/chat/messages {"text": "…", "role": "host" | "moderator", "display_name": "Ayşe"}. Links allowed; works while paused.
Delete a messageDELETE /v1/streams/{id}/chat/messages/{message_id}
Ban a viewerPOST /v1/streams/{id}/chat/bans {"message_id": "msg_…", "delete_messages": true} (or session_id)
Lift a banDELETE /v1/streams/{id}/chat/bans/{ban_id}; list them with GET …/chat/bans
Slow mode, pause, pinPATCH /v1/streams/{id}/chat {"slow_mode_seconds": 10, "paused": true, "pinned_message_id": "msg_…"} (null unpins)
ClearPOST /v1/streams/{id}/chat/clear: hides everything so far, and the pin
Word listPUT /v1/streams/{id}/chat/words {"words": ["spoiler", "kötü söz"]}
Chat or reactions offPATCH /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.

Shell
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:

Shell
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"]}}'
EntryAllows
example.comhttps://example.com only
*.example.comits subdomains (www., shop.), not example.com itself
http://localhost:3000a 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.

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 (0.2.0 or later):

TypeScript
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

Nickname2–24 characters; links refused
Message1–200 characters; links and bare domains refused (your own posts may link)
Per viewer1 message every 3 s, or the slow mode if higher
Per address, per stream30 messages a minute
Automatic slow modeOver 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 stream50 messages a second, then chat_busy
ReactionsOne batch per viewer every 3 s, up to 10 per emoji; sampled to about 200 batches a second per stream
Slow mode0–300 s
Moderators50 per stream or moderation group; links expire after 7 days unused; once opened, a moderator lasts active_days (1–30, default 30)
player.allowed_domains20 sites
Messages in the readThe 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 (CC BY 4.0), with a few everyday words removed. Country and network names: IP Geolocation by DB-IP (CC BY 4.0).