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.

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 and Own.

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.
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 }
  ]
}
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.
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.
pongThe 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.
malformed, unknown-action, already-authenticatedNot JSON / 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.
sendTextThe message event everybody gets (yours with Own), or error.
pingpong. 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

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.

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.

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.
Frames60 in 10 seconds, 16 kB eachThe connection is closed.
Time to log in10 secondsbye / not-authenticated.
Tickets10 a minute per account, each valid 60 seconds, once429 from chat/ticket.
HistoryThe latest 10 messages$ChatHistoryMessages
Pictures4 a message, 400 × 300The 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.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
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 and Read Message History; 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. cron (root): * * * * * /var/www/html/api.ericade.net/websocket/chat-ws.sh — and chmod +x the script. Then tail /var/log/chat-ws.txt: 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.