API reference
ERICADE Radio API v2 — Administrators' Alerts WebSocket
Every alert the API raises for the station's administrators — a nightly job's summary, a worker's complaint, a throttled address, a new account, a comment, a line of the changelog: what it posts to the station's Discord — pushed the moment it is raised to the administrators of radio.ericade.net who are logged in. See also the REST API v2 documentation for the four endpoints that go with it, and the chat WebSocket, which this one is built like.
Updated: 2026-10-17
Overview
The API talks to its administrators through one function, sendAdminAlert() — until 2026-10-15 called
sendDiscordWebhook(), because posting the alert to the station's Discord was all it did. Since 2026-10-15 every
alert goes three ways at once, in this order:
- it is stored in the table
AdminAlerts— the record, whichadmindata/listandadmindata/gethand out and radio.ericade.net/conelrad/alerts/ shows; - it is pushed to this socket as the event
admin-alert, and the server hands it to every logged-in administrator at once — the purple box at the top of whatever page they have open; - it is posted to Discord through the station's webhook, as it always was.
None of the three may fail the caller, and none may fail the others: a database that is down costs the record, not the push and not Discord; a server that is not running costs the push alone.
Like the chat and unlike the station data feed, nobody is here without an account — and here only one kind of account gets in at all.
| Privilege of the account | May get a ticket | May connect | Gets the alerts |
|---|---|---|---|
administrator | yes | yes | yes |
contributor, user, new | no (403) | no | 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, before every alert the server hands out, and for everybody connected every 30 seconds. A demotion, a ban and a “log out everywhere” therefore end a connection that is open — and an alert raised the moment after a demotion reaches the demoted account no more.
websocket/admindata-server.php, a PHP command line program like the station data
relay and the chat server. What it and the REST endpoints need — the settings, tickets, the account check, the shape of an
alert, the purge — are plain functions in config/admindata.php, a file of its own.
Tables: objects/sql_admindata.sql. Settings: config.php, $AdminData….What an alert is
Whatever a job on the API's server has to say to the people who run the station. The nightly jobs each end with one
(the track presence validation, the media file check, the ModArchive enrichment, the playlist build, the podcast
integrity check, the log analysis, the performance analysis, the WebP conversion); the workers report what they measured
(the song scan, the song tempo); the Shoutcast statistics sum up the listening day from the server's own logs at 23:30
(ShoutcastLogStats, red when the source dropped or the server crashed); the rate limiter says which address it throttled and when it let it go; the ingest says
when it had to make an artist's card; and every notification — a new
account, a changed profile, a privilege change, a comment, a star, a maintenance job started from the website — is
one too; and so is every line of the changelog — a new track, a change the playout made to one, a listener request,
a star (updatechangelog(): titled Changelog: <what happened>, in blue, the line's own words
first, then the track, its id, the station, the time and the Guid; any IP address in it is taken out unless
$RedactAdminAlerts in config.php is 0). An alert has a title, a text and a colour, the three things a
Discord embed has, and the colour says how it went:
| Colour (decimal RGB) | Level | Meaning |
|---|---|---|
5763719 (green) | ok | Done, nothing to look at. |
16776960 (yellow) | warnings | Done, with something to look at: missing files, a job that was switched off, a measurement that failed. |
15548997 (red), 15158332 | errors | Something went wrong: a job that could not run, a throttled address, a deleted account. |
anything else (the notifications' blue 3447003 and gold 16705372) | info | Something happened. |
An alert is the station's own text about itself. It may name a log file's path on the server, an address the rate
limiter saw, a person's public display name, a comment's first words — which is why only administrators see it, why
the server never writes an alert's text to its own log, and why the website's box writes it with textContent.
Nothing in it is ever a password, a token or a visitor's e-mail address: those go in the administrators' mail, as the
notifications say.
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, exactly as for the chat:
- The page asks its own site (
POST https://radio.ericade.net/admindata/, with a CSRF token) — and only when the page has reason to think its visitor is an administrator; a visitor who is not logged in, or not an administrator, never asks. - The website's PHP checks the real login and the real privilege, then asks the API, server to server, with the
visitor's user token:
POST /radio/v2/admindata/ticket. The API answers an administrator with a ticket — 64 hexadecimal characters, good for one login within 60 seconds — and everybody else with403. Only the ticket's 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: that it exists, that it is an administrator now, and that it 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). A chat ticket opens the chat and nothing else: the two sockets keep their tickets in two
tables, and neither looks in the other's.
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 $AdminDataAllowedOrigins (https://radio.ericade.net), otherwise 403. When all 50 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 alerts. |
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, and how many administrators are listening. Nothing is replayed: an alert raised before the connection is in the record, not on the socket. |
Example (browser JavaScript)
// ticket and url come from the website's own /admindata/ 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": console.log("listening as", data.You.Name); break;
case "admin-alert": show(data.Payload); break;
case "bye": /* data.Code says whether to try again - see below */ break;
}
};
function show(p) {
box.querySelector(".title").textContent = p.Title; // textContent. Always.
box.querySelector(".text").textContent = p.Message;
box.dataset.level = p.Level; // ok | info | warnings | errors
box.hidden = false;
setTimeout(() => { box.hidden = true; }, 15000);
}
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: 2, 5, 10, 20, 30, then 60
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 4 kB are protocol errors and close the connection.
| Direction | Shape |
|---|---|
| Server → client | {"Event":"…","Timestamp":1792018640, …} |
| 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 account's public display name, one line) and Privilege (always administrator — nobody else gets here). |
Users | How many connections are logged in, this one included. |
admin-alert
One alert, the moment it was raised. Everything about it is in Payload:
Field of Payload | Meaning |
|---|---|
AlertID | The alert's number in the record — what admindata/get takes, and what the website's box links to. 0 when the alert could not be stored (the tables are not there, or the database did not answer): it still went out. |
Timestamp | When it was raised, Unix time. |
Title | At most 256 characters. Text. |
Message | The text, line breaks kept, at most 12 000 characters on the socket (the whole text is in the record). Text, never markup: <b> in a job's report arrives as the five characters <b>. Valid UTF-8, no control characters but line breaks and tabs, no Unicode direction overrides. |
Truncated | 1 when Message is the beginning of a longer text. |
Colour | The decimal RGB value the job chose, as Discord shows it. |
Level | ok, info, warnings or errors — what the colour means (above). |
Source | The function that raised it: VerifyMediaFiles, AdminNotifyEvent, enforceApiRateLimit, updatechangelog; (main) for a script's top level. Letters, digits and a few marks only. |
Origin | web for an alert raised inside a request, otherwise the script that ran: tasks.php, actionworker.php, songscan.php. |
Example
{
"Event": "admin-alert",
"Timestamp": 1792018640,
"Payload": {
"AlertID": 1288,
"Timestamp": 1792018640,
"Title": "Media File Check - 2026-10-15",
"Message": "Podcast episodes: 167 checked, 167 present.\nSongs (MP3): 3104 checked, 2 missing.\n\nMissing:\n titles id 2231 - ...",
"Truncated": 0,
"Colour": 16776960,
"Level": "warnings",
"Source": "VerifyMediaFiles",
"Origin": "tasks.php"
}
}
Discord, Broadcast), a few
seconds later at most.you, pong
| Event | Fields | When |
|---|---|---|
you | You | The database says something else about your account than before: another display name. |
pong | – | The answer to ping. |
error
Something a client sent was refused. The connection stays. Message is a sentence for a person.
| Code | Meaning |
|---|---|
malformed | Not JSON, or JSON without a string Action. |
unknown-action | No such action. There are two: auth and ping. A send is refused: a client only listens. |
already-authenticated | 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, something other than auth first, a ticket that was not valid (unknown, used, too old), or an account that is not an administrator with a live login. | Once, with a new ticket. If the website says the visitor is not an administrator: stop. |
logged-out | The account has no login left that is alive. | No. |
not-allowed | The account is no longer an administrator, or is gone. | No. |
replaced | The account opened more connections than it may have; this, the oldest one, gave way. | Only when the visitor opens another page — otherwise two windows take turns throwing each other out. |
unavailable | The alerts' 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. |
ping | – | pong. The server also sends WebSocket pings every $AdminDataPingInterval seconds, which a browser answers by itself. |
{ "Action": "auth", "Ticket": "5f0c...64 hexadecimal characters...a91e" }
{ "Action": "ping" }
That is all. A client can say who it is once, with a ticket the API made, and after that only that it is still there. Nothing a client sends reaches any other client, the API or Discord.
Security
| What | Rule |
|---|---|
| Who gets in | Administrators, and nobody else — refused three times over: the website's PHP hands out no ticket request for anybody else, admindata/ticket answers everybody else 403, and the server reads the privilege from the database when the ticket is used, before every alert, and every half minute. |
| The ticket | 32 random bytes, one login, 60 seconds, only its SHA-256 stored, in a table of its own (AdminDataTickets): a chat ticket is no login here. |
| The alert's way in | One UDP datagram on the loopback ($AdminDataEventHost:$AdminDataEventPort), carrying a key — $AdminDataEventKey, or the station data relay's $WebSocketEventKey while that one is empty. A datagram without the key, of another kind, or without a Payload object is dropped and logged. What is in the payload is shaped once more by the server — the same keys and types every time — so nothing a stray process on the machine adds to a datagram reaches a client. No key, no server: it refuses to start. |
| Who may open the socket | A browser's Origin must be one of $AdminDataAllowedOrigins; a program that sends none still needs a ticket, which only the website's PHP can fetch. |
| The visitor's address | Behind Apache every connection comes from 127.0.0.1. The visitor is the last entry of X-Forwarded-For — the one Apache added; what a client writes in front of it counts for nothing — and only when the peer is one of $AdminDataTrustedProxies. Stored with the ticket, used for the per-address limit. |
| What is on the wire | The alert as the API raised it, and a display name. No account number, no login name, no e-mail address, no network address of anybody. |
| What is in the server's log | That alert #N of level L went to M clients; who logged in from where; what was refused and why. Never an alert's title or text, never a ticket, never the key. |
| Nothing is replayed | The server keeps no alert: a connection sees what is raised while it is open. The record is the table, behind an administrators-only endpoint. |
| Traced accounts | An account under trace has its arrival on this socket in its trace. |
Limits
| Limit | Value | Setting | What happens |
|---|---|---|---|
| Connections at the same time | 50 | $AdminDataMaxClients | HTTP 503 to the next one. |
| Connections from one address | 10 | $AdminDataMaxClientsPerIP | HTTP 503. The address is the visitor's, from the proxy's X-Forwarded-For. |
| Connections of one account | 5 | $AdminDataMaxConnectionsPerAccount | The oldest one gets bye / replaced. |
| Frames | 20 in 10 seconds, 4 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 admindata/ticket. |
| An alert's text on the socket | 12 000 characters | – | Truncated: 1; the whole text is in the record (up to 100 000 characters; Discord gets the first 4096). |
| The record | kept until deleted by hand | $AdminAlertRetentionDays | Set to a number of days and the server deletes older rows once an hour. |
Operations
The server listens on 127.0.0.1:8148 behind Apache, next to the station data relay (8146) and the chat
(8147), and takes the alert datagrams on UDP 127.0.0.1:8149. It is kept alive by
websocket/admindata-ws.sh from cron. It reads config.php once, when it starts: after
changing an $AdminData… setting, run pkill -f 'admindata-server\.php' and the watchdog starts it
again within the minute. Its log is /var/log/admindata-ws/admindata-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 alerts:
Used websockets: 3, Available websockets: 47, Total websockets: 50
Logged in: 2, Alerts relayed: 14, Last alert: 2026-10-15 03:12:40
| Field | Meaning |
|---|---|
| Used / Available / Total websockets | Connections (those still logging in included — the status request itself is one), the room left before 503, and $AdminDataMaxClients. |
| Logged in | Connections that have presented a valid ticket. |
| Alerts relayed | Datagrams accepted since the server started. |
| Last alert | When the last one arrived (server time), or never. |
Both are only reachable on the server itself (curl http://127.0.0.1:8148/status); an administrator gets the
same through admindata/status. When every place is taken they answer
503 like any other connection.
Capacity event log
/var/log/websocket_admindata_events/<YYYY-MM-DD>.log, one file a day, each line
[timestamp] [event-type] message, with the relay's event types (capacity-total-90,
capacity-total-100, capacity-ip-90, capacity-ip-100, capacity-total-full,
capacity-total-available, log-throttled) and the relay's rate limit. Each threshold fires once per
crossing and is armed again when usage falls back under it.
When nothing arrives
| Symptom | Look at |
|---|---|
| The website's box never shows anything, Discord gets everything | admindata/status (or /conelrad/alerts/): Running 0 — the watchdog's crontab line; Keyed 0 — no key in config.php; Enabled 0. Then the record: an alert with Broadcast 0 was never pushed; with Broadcast 1 the datagram was written and the server's log says what it did with it. |
| Alerts arrive but the record is empty | SchemaReady 0: objects/sql_admindata.sql has not been run. The event's AlertID is 0 meanwhile. |
| “Discarded internal event datagram with bad key” in the log | The server and the API read different keys — the server was started before $AdminDataEventKey was changed. Restart it. |
| An administrator is thrown out every half minute | The account has no live session (logged out everywhere) or is no longer an administrator; the website's cookie is stale. Log in again. |
Setting it up
- Database: run
objects/sql_admindata.sql(tablesAdminAlertsandAdminDataTickets). Until thensendAdminAlert()pushes and posts to Discord without storing anything,admindata/ticketanswers503, and the server turns logins away; nothing else notices. - Apache, in the api.ericade.net virtual host, next to the
/ws/stationdataand/ws/chatlines, then reload:ProxyPass "/ws/admindata" "ws://127.0.0.1:8148/" ProxyPassReverse "/ws/admindata" "ws://127.0.0.1:8148/" - config.php: the block “Administrative alerts”. Everything has a default; the one thing worth
deciding is
$AdminDataEventKey— empty, the alert datagrams are signed with the relay's$WebSocketEventKey; for a key of its own,php -r 'echo bin2hex(random_bytes(32)), "\n";'. - cron (root):
* * * * * /var/www/html/api.ericade.net/websocket/admindata-ws.sh— andchmod +xthe script. Thentail /var/log/admindata-ws/admindata-ws.log: it says where it listens and where it takes the datagrams. - Logs:
bash /var/www/html/api.ericade.net/logrotate/install.shas root, once more, for the two new folders (admindata-ws/,websocket_admindata_events/) and their rotation. - The website: nothing to configure. Its header carries the box,
js/legacy.jsconnects for an administrator,/admindata/fetches the ticket,/conelrad/alerts/shows the record.
No package has to be installed: the server uses PHP's mbstring and pdo_mysql extensions, which the
API needs already.