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, and the station data WebSocket, which this one is built like.
Updated: 2026-09-19
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 and Own. |
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. |
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 }
]
}
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. |
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. |
malformed, unknown-action, already-authenticated | Not JSON / 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 | The message event everybody gets (yours with Own), or error. |
ping | – | pong. The server also sends WebSocket pings every $ChatPingInterval seconds, which a browser answers by itself. |
{ "Action": "send", "Text": "Evening all!" }
There is no action that names a user, a room or a message: a client can say who it is once, with a ticket the API made, and after that only what it says.
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. |
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. |
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. |
| 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.txt. 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 and Read Message History; 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. - cron (root):
* * * * * /var/www/html/api.ericade.net/websocket/chat-ws.sh— andchmod +xthe script. Thentail /var/log/chat-ws.txt: 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.