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 | 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:
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. |
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, 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=1opens it at once;?chat=0hides 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=0hides them. - Viewers pick a nickname the first time they post; the browser remembers it.
?lang=tror?lang=ensets 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):
<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:
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.
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:
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:
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 belowShow 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:
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:
- 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. - 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
<div class="stage" id="stage">
<video id="video" playsinline controls></video>
</div>.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:
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:
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. |
// Video.js
const player = videojs('my-video');
player.ready(() => joaReactions(player.el(), 'str_e1bzk3dxw9z9allei6n7'));// 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: falseand callreact('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: noneso 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:
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:
<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:
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.
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:
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.
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):
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 (CC BY 4.0), with a few everyday words removed. Country and network names: IP Geolocation by DB-IP (CC BY 4.0).