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.
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 account | May connect | May read | May write |
|---|---|---|---|
administrator, contributor, user | yes | yes | yes |
new (not yet accepted by an administrator) | yes | yes | no |
banned, or no privilege at all | no | no | no |
| Not logged in | no | no | no |
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.
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:
- The page asks its own site (
POST https://radio.ericade.net/chat/, with a CSRF token). - 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. - The page opens the socket and says
{"Action":"auth","Ticket":"…"}as its first message. - 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).
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
| Step | What happens |
|---|---|
| Handshake | An 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. |
welcome | Sent at once. It says how long there is to log in (10 seconds) and nothing about the chat. |
auth | The 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. |
ready | The 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.
| Direction | Shape |
|---|---|
| Server → client | {"Event":"…","Timestamp":1789772127, …} |
| Client → server | {"Action":"…", …} |
What the server says
welcome
| Field | Meaning |
|---|---|
Protocol | Version of this protocol: 1. |
AuthTimeout | Seconds the connection has to send auth. |
Documentation | The address of this page. |
ready
| Field | Meaning |
|---|---|
You | Name (the name your messages carry), Privilege, CanSend (1 / 0). |
Users | How many connections are logged in, this one included. |
MaxLength | The longest message the server accepts, in characters. |
Discord | Configured and Connected, each 1 / 0. With Connected 0 the chat works among the website's users only. |
History | The 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. |
ReactionEmoji | The emoji a reaction may be, as characters — 122 standard ones, the website's picker. |
MaxReactions | Different emoji one message may carry: 20, Discord's own limit. |
message
| Field | Meaning |
|---|---|
ID | Unique. 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. |
Source | web or discord. |
Name | Who wrote it, ready to show: Daemon [ericade web] (Administrator), or Somebody [Discord]. Text. |
Bot | 1 when a Discord bot wrote it. |
Text | The message. Text, never markup — see below. May be empty when there are pictures. |
Images | Pictures attached in Discord, at most 4: URL, Width, Height — already scaled, see below. Always empty for web. |
Reply | null, 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. |
Reactions | A 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). |
Own | 1 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 } ]
}
chat/log).history, users, you, bridge, pong
| Event | Fields | When |
|---|---|---|
history | History | The 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. |
reactions | ID, Reactions | The 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. |
users | Users | The number of logged-in connections changed. |
you | You | The database says something else about your account than before: another name, or you may now write (or no longer). |
bridge | Discord | The 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.
| Code | Meaning |
|---|---|
throttled | Too many messages. RetryAfter: seconds until the account may write again. |
read-only | The account is new: it may read, not write. |
invalid-message | Text is not a string of 1 to MaxLength characters of valid UTF-8. |
temporary | The server could not reach its database. Nothing was sent; try again. |
message-gone | The 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-emoji | The Emoji of a react is not one of ReactionEmoji. |
too-many-reactions | The message carries 20 different emoji already; only those can be added. |
reactions-throttled | Too many reactions. RetryAfter: seconds until the account may react again. Writing is not affected. |
malformed, unknown-action, already-authenticated | Not 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.
| Code | Meaning | Connect again? |
|---|---|---|
not-authenticated | No 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-out | The account has no login left that is alive. | No — show the login. |
not-allowed | The account was banned, or lost its privilege. | No. |
replaced | The 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. |
unavailable | The chat's tables are not in the database yet. | Later. |
shutdown | The server is restarting. | Yes, after a moment. |
What a client may say
| Action | Fields | Answer |
|---|---|---|
auth | Ticket | ready, or bye. |
send | Text; ReplyTo (optional): the ID of the message it answers | The message event everybody gets (yours with Own), or error. |
react | ID (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.
| What | On the website | In Discord |
|---|---|---|
| A website user replies | Reply 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 replies | Reply 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 reacts | Counted 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 reacts | The 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. | – |
Mine. An account under trace has its
reactions in its trace.Names, text and pictures
| What | Rule |
|---|---|
| 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]. |
| Text | Plain 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 visitor | Refused (invalid-message) unless it is valid UTF-8 of 1–$ChatMaxMessageLength (500) characters. Nothing is repaired: the writer is told. |
| From Discord | Repaired 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. |
| Pictures | Only 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. |
| Emoji | Ordinary 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
| Direction | How |
|---|---|
| Website → #general | A 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 → website | The 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. |
| Commands | A 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.
$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
| Limit | Value | Setting | What happens |
|---|---|---|---|
| Connections at the same time | 200 | $ChatMaxClients | HTTP 503 to the next one. |
| Connections from one address | 10 | $ChatMaxClientsPerIP | HTTP 503. The address is the visitor's, from the proxy's X-Forwarded-For. |
| Connections of one account | 5 | $ChatMaxConnectionsPerAccount | The oldest one gets bye / replaced. |
| Messages | 5 in 1 second | $ChatThrottleMessages, $ChatThrottleWindowSeconds | One more, and the account — all its windows and devices — is silent for $ChatThrottleCooldownSeconds (20): error / throttled with RetryAfter. It can still read. |
| Message length | 500 characters | $ChatMaxMessageLength | error / invalid-message. |
| Reactions | 10 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 message | 20 different emoji | – | error / too-many-reactions. |
| Messages that can be answered or reacted to | The latest 200 | – | error / message-gone. |
| Frames | 60 in 10 seconds, 16 kB each | – | The connection is closed. |
| Time to log in | 10 seconds | – | bye / not-authenticated. |
| Tickets | 10 a minute per account, each valid 60 seconds, once | – | 429 from chat/ticket. |
| History | The latest 10 messages | $ChatHistoryMessages | – |
| Pictures | 4 a message, 400 × 300 | – | The rest is dropped. |
| The log | 30 days | $ChatLogRetentionDays | Older 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
| Field | Meaning |
|---|---|
| Used / Available / Total websockets | Connections (those still logging in included), the room left before 503, and $ChatMaxClients. |
| Logged in | Connections that have presented a valid ticket. |
| Discord | connected, not reachable or not configured. |
| Waiting for Discord | Messages 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 type | Fires when |
|---|---|
capacity-total-90, capacity-total-100 | Connections reach 90% / 100% of $ChatMaxClients. |
capacity-ip-90, capacity-ip-100 | One visitor address reaches 90% / 100% of $ChatMaxClientsPerIP. |
capacity-total-full | A connection is refused because the chat is full. |
capacity-total-available | There is room again after it was full. |
log-throttled | More 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
- Database: run
objects/sql_chat.sql(tablesChatTicketsandChatLog). Until thenchat/ticketanswers503and the server turns logins away; nothing else notices. - Apache, in the api.ericade.net virtual host, next to the
/ws/stationdatalines, then reload:ProxyPass "/ws/chat" "ws://127.0.0.1:8147/" ProxyPassReverse "/ws/chat" "ws://127.0.0.1:8147/" - 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: scopebot, 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). - Discord, writing — a webhook. Right-click #general → Edit Channel → Integrations → Webhooks →
New Webhook → Copy Webhook URL (
$ChatDiscordWebhookURL). - config.php: the three values, and
$ChatEnabled = 1. - The station's bot, so that it answers commands typed on the website: in
discord/config.jsonon the server,"chatWebhookIds": ["<id>"]— the number in the webhook's address (https://discord.com/api/webhooks/<id>/<token>), never the token. Thenpkill -f 'discord/index\.js';discord/discordbot.shstarts it again within the minute. Without the line the bot goes on ignoring the website's messages and nothing else changes. - cron (root):
* * * * * /var/www/html/api.ericade.net/websocket/chat-ws.sh— andchmod +xthe script. Thentail /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 401there means a wrong token,403a bot without the two permissions in #general,404a 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.