API reference

ERICADE Radio API v2 — Chat WebSocket

One conversation for the listeners on radio.ericade.net and the channel #general of the station's Discord server. See also the REST API v2 documentation for the three endpoints that go with it, the station data WebSocket, which this one is built like, and the administrators' alerts WebSocket, which is built like this one.

Updated: 2026-10-11 (replies and reactions)

Overview

The chat is a WebSocket. Everything that can be done over it is done over it: reading, writing, knowing how many are there. The REST API only does what a socket cannot — hand out logins, and let an administrator see the server's state and who wrote what.

WSS wss://api.ericade.net/ws/chat

Unlike the station data feed, nobody is here without an account. Accounts made with a password and accounts made through SceneID are the same thing to the chat.

Privilege of the accountMay connectMay readMay write
administrator, contributor, useryesyesyes
new (not yet accepted by an administrator)yesyesno
banned, or no privilege at allnonono
Not logged innonono

The privilege is read from the database, never from anything the socket is told: when a connection logs in, again for every message it sends, and for everybody connected every 30 seconds. A ban, a demotion and a “log out everywhere” therefore end a conversation that is going on, and an account that is accepted while it is reading along gets its text box without reconnecting.

The server is websocket/chat-server.php, a PHP command line program like the station data relay. What both it and the REST endpoints need — tickets, the account check, the throttle, cleaning text, reading Discord's messages, the log — are plain functions in the last section of config/common.php (“Chat”). Tables: objects/sql_chat.sql. Settings: config.php, $Chat….

Logging in: the ticket

A visitor's login on radio.ericade.net is a user token kept in an HttpOnly cookie on radio.ericade.net: no script can read it, and the browser never sends it to api.ericade.net. The socket can therefore never see it, and it is not supposed to. Instead:

  1. The page asks its own site (POST https://radio.ericade.net/chat/, with a CSRF token).
  2. The website's PHP asks the API, server to server, with the visitor's user token: POST /radio/v2/chat/ticket. The API answers with a ticket: 64 hexadecimal characters, good for one login within 60 seconds. Only its SHA-256 is stored.
  3. The page opens the socket and says {"Action":"auth","Ticket":"…"} as its first message.
  4. The server uses the ticket up — one UPDATE … WHERE Used IS NULL, so of two logins with the same ticket at the same instant exactly one gets in — and then reads the account it was made for: privilege, display name, and whether the account still has a login that is alive.

A ticket says who, and nothing else. What the account may do is decided by the server from the database, at that moment and all the time after. An account gets at most 10 tickets a minute (429 after that).

Writing a client of your own? You need a user token, which only authenticate/login hands out, and that endpoint is for the station's own website. The protocol is documented so that the website's code can be understood and changed, not as an invitation.

Connecting

StepWhat happens
HandshakeAn ordinary WebSocket upgrade. A browser's Origin header must be one of $ChatAllowedOrigins (https://radio.ericade.net), otherwise 403. When all 200 places are taken, or the visitor's address has too many connections already: 503.
welcomeSent at once. It says how long there is to log in (10 seconds) and nothing about the chat.
authThe client's first message. Anything else before it — a ping too — ends the connection with bye / not-authenticated, and so does silence for 10 seconds.
readyThe login worked: who you are, whether you may write, how many are here, the state of the Discord bridge and the latest 10 messages.

Example (browser JavaScript)

// ticket and url come from the website's own /chat/ endpoint (see "Logging in")
const ws = new WebSocket(url);
ws.onopen = () => ws.send(JSON.stringify({ Action: "auth", Ticket: ticket }));
ws.onmessage = (e) => {
  const data = JSON.parse(e.data);
  switch (data.Event) {
    case "ready":   data.History.forEach(show); break;
    case "message": show(data); break;
    case "error":   if (data.Code === "throttled") waitSeconds(data.RetryAfter); break;
    case "bye":     /* data.Code says whether to try again - see below */ break;
  }
};
function show(m) {
  const li = document.createElement("li");
  li.textContent = m.Name + ": " + m.Text;      // textContent. Always.
  list.appendChild(li);
}
ws.send(JSON.stringify({ Action: "send", Text: "Hello!" }));

On a connection that closed without a bye, or with one that allows it, a client fetches a new ticket and connects again, waiting a little longer each time (the website: 1, 2, 5, 10, 20, then 30 seconds). A ticket cannot be used twice, so there is nothing to keep.

Message envelope

Every message is one JSON text frame. Everything the server sends has an Event and a Timestamp (Unix time); everything a client sends has an Action. Binary frames, fragmented messages, compressed frames and frames over 16 kB are protocol errors and close the connection.

DirectionShape
Server → client{"Event":"…","Timestamp":1789772127, …}
Client → server{"Action":"…", …}

What the server says

welcome

FieldMeaning
ProtocolVersion of this protocol: 1.
AuthTimeoutSeconds the connection has to send auth.
DocumentationThe address of this page.

ready

FieldMeaning
YouName (the name your messages carry), Privilege, CanSend (1 / 0).
UsersHow many connections are logged in, this one included.
MaxLengthThe longest message the server accepts, in characters.
DiscordConfigured and Connected, each 1 / 0. With Connected 0 the chat works among the website's users only.
HistoryThe latest messages, oldest first, at most $ChatHistoryMessages (10). Each one is shaped like a message event, without Event — Own and each reaction's Mine as they are for your account.
ReactionEmojiThe emoji a reaction may be, as characters — 122 standard ones, the website's picker.
MaxReactionsDifferent emoji one message may carry: 20, Discord's own limit.

message

FieldMeaning
IDUnique. web-<number> for a message written on the website, Discord's message id for one written there. A client that sees an ID twice shows it once.
Sourceweb or discord.
NameWho wrote it, ready to show: Daemon [ericade web] (Administrator), or Somebody [Discord]. Text.
Bot1 when a Discord bot wrote it.
TextThe message. Text, never markup — see below. May be empty when there are pictures.
ImagesPictures attached in Discord, at most 4: URL, Width, Height — already scaled, see below. Always empty for web.
Replynull, or the message this one answers: ID (to find it by — may be "", or a message the client no longer has), Name (whose it was; "" when not known), Text (its first 100 characters, one line). All three text. See Replies and reactions.
ReactionsA list, possibly empty, of Emoji (characters; a Discord server's own emoji as :name:), Count, Mine (1: your account is among them) and Pickable (1: one of ReactionEmoji, so it can be added and taken away from here).
Own1 on the copies that go to the connections of the account that wrote it. Absent otherwise.

Example

{
  "Event": "message",
  "Timestamp": 1789772127,
  "ID": "1418923377712345088",
  "Source": "discord",
  "Name": "ModFan [Discord]",
  "Bot": 0,
  "Text": "Found my old A1200 in the attic today",
  "Images": [
    { "URL": "https://media.discordapp.net/attachments/1/2/a1200.jpg?ex=...&width=400&height=300", "Width": 400, "Height": 300 }
  ],
  "Reply": { "ID": "web-1288", "Name": "Daemon [ericade web] (Administrator)", "Text": "Anybody still got an Amiga?" },
  "Reactions": [ { "Emoji": "\ud83d\udc4d", "Count": 3, "Mine": 1, "Pickable": 1 } ]
}
Nothing about the writer but the name is ever on the wire: no account number, no login name, no e-mail address, no network address. Who wrote a message is in the server's log, for administrators (chat/log).

history, users, you, bridge, pong

EventFieldsWhen
historyHistoryThe server has just read the channel's past from Discord while clients were connected already (right after a restart, or when Discord came back). A client merges it by ID, and takes the Reactions of those it shows already.
reactionsID, ReactionsThe reactions on message ID changed — from the website at once, from Discord within 10 seconds. Reactions is the whole new list, shaped as in message, with Mine for your account. A client that does not show that message ignores it.
usersUsersThe number of logged-in connections changed.
youYouThe database says something else about your account than before: another name, or you may now write (or no longer).
bridgeDiscordThe bridge to Discord went down or came back.
pong–The answer to ping.

error

Something a client sent was refused. The connection stays. Message is a sentence for a person.

CodeMeaning
throttledToo many messages. RetryAfter: seconds until the account may write again.
read-onlyThe account is new: it may read, not write.
invalid-messageText is not a string of 1 to MaxLength characters of valid UTF-8.
temporaryThe server could not reach its database. Nothing was sent; try again.
message-goneThe ReplyTo of a send, or the ID of a react, is not one of the latest 200 messages the server knows (or not an ID at all). A reply is not sent.
invalid-emojiThe Emoji of a react is not one of ReactionEmoji.
too-many-reactionsThe message carries 20 different emoji already; only those can be added.
reactions-throttledToo many reactions. RetryAfter: seconds until the account may react again. Writing is not affected.
malformed, unknown-action, already-authenticatedNot JSON, or On of a react that is not 1/0/true/false / no such action / a second auth.

bye

The server is about to close the connection, and says why.

CodeMeaningConnect again?
not-authenticatedNo ticket in time, a ticket that was not valid (unknown, used, too old), or an account that may not log in.Once, with a new ticket. If the website says the visitor is not logged in: show the login.
logged-outThe account has no login left that is alive.No — show the login.
not-allowedThe account was banned, or lost its privilege.No.
replacedThe account opened more connections than it may have; this, the oldest one, gave way.Only when the visitor asks for it — otherwise two windows take turns throwing each other out.
unavailableThe chat's tables are not in the database yet.Later.
shutdownThe server is restarting.Yes, after a moment.

What a client may say

ActionFieldsAnswer
authTicketready, or bye.
sendText; ReplyTo (optional): the ID of the message it answersThe message event everybody gets (yours with Own), or error.
reactID (a message), Emoji (one of ReactionEmoji), On (1 add, 0 take away; default 1)The reactions event everybody gets, or error. Nothing when nothing changes (adding what is yours already).
ping–pong. The server also sends WebSocket pings every $ChatPingInterval seconds, which a browser answers by itself.
{ "Action": "send", "Text": "Evening all!" }
{ "Action": "send", "Text": "Mine too!", "ReplyTo": "1418923377712345088" }
{ "Action": "react", "ID": "web-1288", "Emoji": "\ud83d\udd25", "On": 1 }

There is no action that names a user or a room: a client can say who it is once, with a ticket the API made, and after that only what it says — and which message it answers or reacts to. What a reply says about that message (its name and text) is taken from the server's own record, never from the client. send and react are only for an account that may write (read-only otherwise).

Replies and reactions

Any of the latest 200 messages the server knows can be answered and reacted to — as many as the website's window shows. Older ones, and those from before a restart that the server did not read back, are message-gone. A website user's own message can be reacted to, as in Discord.

WhatOn the websiteIn Discord
A website user repliesReply on the message: whose, and the start of it.A webhook cannot post a real reply, so the message opens with a line of small print: -# ↪ Replying to <whom>: <the start of it>. <whom> is a mention when the answered message is a Discord person's — and that one person is pinged, as a reply in Discord does (allowed_mentions.users: exactly that id) — a name otherwise (a website user, a bot: nobody is pinged). Everything in the line is escaped, so a name cannot format, mention or link. When the line does not fit in Discord's 2000 characters it is shortened, then left out.
A Discord user repliesReply from Discord's own reference: the message's website ID when it is a website user's, Discord's ID otherwise. Discord's reply line of our own messages is not repeated in the excerpt.–
A website user reactsCounted per account: Mine, and one more in Count.A webhook cannot react either: the chat bot ($ChatDiscordBotToken) adds the emoji while at least one website account holds it, and takes it away when the last one does — one reaction standing for all of them. Needs Add Reactions in #general. Refused (403): the reactions stay on the website, the server's log says so once, and it asks again after five minutes.
A Discord user reactsThe server reads Discord's reactions on the latest 50 messages again every 10 seconds; a change is a reactions event. The chat bot's own reaction is not counted a second time. A server's own emoji shows as :name:, through the word filter, and cannot be picked here.–
Reactions live in the server's memory, like the history: nothing about them is stored, and the log keeps messages, not reactions. After a restart the server knows Discord's counts but no longer which website accounts held what: a reaction of the chat bot's that nobody has touched since counts as one, and is nobody's Mine. An account under trace has its reactions in its trace.

Names, text and pictures

WhatRule
A website user's name<display name> [ericade web] (<Privilege>) — Daemon [ericade web] (Administrator). The display name is the account's public one, the one its comments carry (scene handle, else first and last name, else user name), one line, at most 40 characters. The label (ericade web) is $ChatWebLabel.
A Discord user's name<display name> [Discord].
TextPlain text. <b> typed by somebody arrives as the five characters <b> and must be shown as such: textContent, never innerHTML. Nothing is HTML-encoded on the way — there is nothing to decode. Control characters and Unicode direction overrides are removed, more than two line breaks in a row become two.
From a visitorRefused (invalid-message) unless it is valid UTF-8 of 1–$ChatMaxMessageLength (500) characters. Nothing is repaired: the writer is told.
From DiscordRepaired instead: made valid UTF-8, cut at 2000 characters. <@id> becomes @Name, a role @role, a channel #channel, a custom emoji :name:. Only ordinary messages and replies are shown — no joins, pins or boosts.
Word filter$CommentProfanityList masks words (f***) in both directions and in names: before a website user's message reaches the other listeners and Discord, and before a Discord message reaches the website. The server's log keeps what was really typed.
PicturesOnly from Discord, only image/png|jpeg|gif|webp of known size, only from media.discordapp.net / cdn.discordapp.com, at most 4 a message. The address handed on is always the media proxy's with width and height in it, so what a browser downloads is never larger than 400 × 300, proportions kept. A client should check the host again before it makes an <img>, and use referrerpolicy="no-referrer". Website users cannot post pictures.
EmojiOrdinary Unicode characters in the text, nothing more: no shortcode is translated in either direction (Discord does not turn :smile: in a webhook's text into a picture), and a Discord server's custom emoji arrives as :name:, as above. radio.ericade.net's window has a picker of 122 standard emoji — the ones Discord's own picker has, Emoji 11.0 or older — next to the text box, so only for an account that may write (not new, not banned). The server applies no rule of its own to them: an emoji is text, and whoever may send text may send emoji. One emoji can be two or more characters (❤️ is a heart and a variation selector); the 500 count characters.

The Discord bridge

DirectionHow
Website → #generalA channel webhook ($ChatDiscordWebhookURL), so each message carries its writer's name instead of a bot's. Sent with allowed_mentions: {"parse": []} — the text may say @everyone, it pings nobody, no role and no person — and with link previews suppressed. The name is made acceptable to Discord first (no @ # :, no “discord”). ?wait=true returns the message's id, which is stored with the log row.
#general → websiteThe server asks Discord's REST API for what is new in the channel every $ChatDiscordPollSeconds (2) with a bot token ($ChatDiscordBotToken, $ChatDiscordChannelID). What it posted itself through the webhook is recognised by the webhook's id and not shown twice.
CommandsA message that starts with ! or / is sent like any other — the chat server does not look at it. The station's bot (discord/index.js) answers !help and its other commands in #general, and the answer comes back to the website like any Discord message, marked Bot: 1. Discord marks every webhook message as a bot's, and the bot ignores bots: it makes an exception for the webhooks listed as chatWebhookIds in its config.json (see Setting it up) and for no others. A website user may also start a command with /: /nowplaying and /nowplaying2 answer as Discord's slash commands do, /help is !help; a / before anything that is not one of the bot's commands is just text. In #general the bot answers help, ping, tune and the two slash commands; for the others it points to #askthebot, as it does for a Discord user.

Both run inside the server's event loop without ever waiting for Discord: a message crosses the website at once and reaches Discord when Discord takes it. A 429 is obeyed (retry_after), a failed delivery is tried three times, at most 100 messages wait. A bot that Discord refuses (401, 403, 404) is asked about again after five minutes, not every two seconds. When Discord cannot be reached, everybody gets a bridge event and the chat goes on among the website's users. After a restart the server reads the latest messages of #general, so the history survives it; messages the website posted come back as website messages, under the ID they had.

Leave $ChatDiscordBotToken or $ChatDiscordWebhookURL empty and that direction is simply off. The webhook address must be a https://discord.com/api/webhooks/<id>/<token> address: anything else is refused, so a typing mistake cannot post the chat somewhere else. Both values are secrets and are never written to a log or an answer.

Limits

LimitValueSettingWhat happens
Connections at the same time200$ChatMaxClientsHTTP 503 to the next one.
Connections from one address10$ChatMaxClientsPerIPHTTP 503. The address is the visitor's, from the proxy's X-Forwarded-For.
Connections of one account5$ChatMaxConnectionsPerAccountThe oldest one gets bye / replaced.
Messages5 in 1 second$ChatThrottleMessages, $ChatThrottleWindowSecondsOne more, and the account — all its windows and devices — is silent for $ChatThrottleCooldownSeconds (20): error / throttled with RetryAfter. It can still read.
Message length500 characters$ChatMaxMessageLengtherror / invalid-message.
Reactions10 changes in 10 seconds–One more, and the account may not react for 20 seconds: error / reactions-throttled with RetryAfter. It can still write.
Reactions on one message20 different emoji–error / too-many-reactions.
Messages that can be answered or reacted toThe latest 200–error / message-gone.
Frames60 in 10 seconds, 16 kB each–The connection is closed.
Time to log in10 seconds–bye / not-authenticated.
Tickets10 a minute per account, each valid 60 seconds, once–429 from chat/ticket.
HistoryThe latest 10 messages$ChatHistoryMessages–
Pictures4 a message, 400 × 300–The rest is dropped.
The log30 days$ChatLogRetentionDaysOlder rows are deleted by the server, once an hour.

Operations

The server listens on 127.0.0.1:8147 behind Apache, next to the station data relay (8146), and is kept alive by websocket/chat-ws.sh from cron. It reads config.php once, when it starts: after changing a $Chat… setting or $CommentProfanityList, run pkill -f 'chat-server\.php' and the watchdog starts it again within the minute. Its log is /var/log/chat-ws/chat-ws.log. The two reporting endpoints and the capacity log work exactly like the relay's.

GET /health

Liveness check. Returns 200 OK with the body OK as long as the event loop is running.

GET /status

Capacity as plain text — the first line is the relay's, word for word, the second is about the chat:

Used websockets: 42, Available websockets: 158, Total websockets: 200
Logged in: 40, Discord: connected, Waiting for Discord: 0
FieldMeaning
Used / Available / Total websocketsConnections (those still logging in included), the room left before 503, and $ChatMaxClients.
Logged inConnections that have presented a valid ticket.
Discordconnected, not reachable or not configured.
Waiting for DiscordMessages not yet delivered to the webhook.

Both are only reachable on the server itself (curl http://127.0.0.1:8147/status); an administrator gets the same through chat/status. When every place is taken they answer 503 like any other connection.

Capacity event log

/var/log/websocket_chat_events/<YYYY-MM-DD>.log, one file a day, each line [timestamp] [event-type] message, with the relay's event types and the relay's rate limit:

Event typeFires when
capacity-total-90, capacity-total-100Connections reach 90% / 100% of $ChatMaxClients.
capacity-ip-90, capacity-ip-100One visitor address reaches 90% / 100% of $ChatMaxClientsPerIP.
capacity-total-fullA connection is refused because the chat is full.
capacity-total-availableThere is room again after it was full.
log-throttledMore than 10 entries in 5 seconds: logging stops for 30 seconds, and this line says so.

Each threshold fires once per crossing and is armed again when usage falls back under it.

The visitor's address

Behind Apache every connection comes from 127.0.0.1. The visitor's address is the last entry of X-Forwarded-For — the one Apache added itself; what a client writes in front of it counts for nothing — and it is believed only when the peer is one of $ChatTrustedProxies. It is used for the per-address limit and stored with each logged message.

Setting it up

  1. Database: run objects/sql_chat.sql (tables ChatTickets and ChatLog). Until then chat/ticket answers 503 and the server turns logins away; nothing else notices.
  2. Apache, in the api.ericade.net virtual host, next to the /ws/stationdata lines, then reload:
    ProxyPass        "/ws/chat" "ws://127.0.0.1:8147/"
    ProxyPassReverse "/ws/chat" "ws://127.0.0.1:8147/"
  3. Discord, reading — a bot. At discord.com/developers/applications: New Application → Bot → Reset Token (shown once: $ChatDiscordBotToken) → switch on Message Content Intent (without it Discord hands over messages with empty text) → switch Public Bot off. OAuth2 → URL Generator: scope bot, permissions View Channels, Read Message History and Add Reactions (the website's reactions; a bot invited without it: Server Settings → Roles → its role, or the channel's permissions); open the address, choose the server. In Discord: Settings → Advanced → Developer Mode, right-click #general → Copy Channel ID ($ChatDiscordChannelID).
  4. Discord, writing — a webhook. Right-click #general → Edit Channel → Integrations → Webhooks → New Webhook → Copy Webhook URL ($ChatDiscordWebhookURL).
  5. config.php: the three values, and $ChatEnabled = 1.
  6. The station's bot, so that it answers commands typed on the website: in discord/config.json on the server, "chatWebhookIds": ["<id>"] — the number in the webhook's address (https://discord.com/api/webhooks/<id>/<token>), never the token. Then pkill -f 'discord/index\.js'; discord/discordbot.sh starts it again within the minute. Without the line the bot goes on ignoring the website's messages and nothing else changes.
  7. cron (root): * * * * * /var/www/html/api.ericade.net/websocket/chat-ws.sh — and chmod +x the script. Then tail /var/log/chat-ws/chat-ws.log: it names the channel it reads and the webhook it writes through, and says “Discord is reachable”. HTTP 401 there means a wrong token, 403 a bot without the two permissions in #general, 404 a wrong channel id.

No package has to be installed: the server uses PHP's curl, mbstring and pdo_mysql extensions, which the API needs already.