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:

  1. it is stored in the table AdminAlerts — the record, which admindata/list and admindata/get hand out and radio.ericade.net/conelrad/alerts/ shows;
  2. 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;
  3. 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.

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

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 accountMay get a ticketMay connectGets the alerts
administratoryesyesyes
contributor, user, newno (403)nono
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, 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.

The server is 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)LevelMeaning
5763719 (green)okDone, nothing to look at.
16776960 (yellow)warningsDone, with something to look at: missing files, a job that was switched off, a measurement that failed.
15548997 (red), 15158332errorsSomething went wrong: a job that could not run, a throttled address, a deleted account.
anything else (the notifications' blue 3447003 and gold 16705372)infoSomething 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:

  1. 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.
  2. 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 with 403. Only the ticket's 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: 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.

Writing a client of your own? You need an administrator's 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 $AdminDataAllowedOrigins (https://radio.ericade.net), otherwise 403. When all 50 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 alerts.
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, 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.

DirectionShape
Server → client{"Event":"…","Timestamp":1792018640, …}
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 account's public display name, one line) and Privilege (always administrator — nobody else gets here).
UsersHow 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 PayloadMeaning
AlertIDThe 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.
TimestampWhen it was raised, Unix time.
TitleAt most 256 characters. Text.
MessageThe 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.
Truncated1 when Message is the beginning of a longer text.
ColourThe decimal RGB value the job chose, as Discord shows it.
Levelok, info, warnings or errors — what the colour means (above).
SourceThe function that raised it: VerifyMediaFiles, AdminNotifyEvent, enforceApiRateLimit, updatechangelog; (main) for a script's top level. Letters, digits and a few marks only.
Originweb 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"
  }
}
Whether Discord took the alert is not in the event — Discord is asked after the push, so that a slow Discord never delays the box. The record has both delivery flags (Discord, Broadcast), a few seconds later at most.

you, pong

EventFieldsWhen
youYouThe 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.

CodeMeaning
malformedNot JSON, or JSON without a string Action.
unknown-actionNo such action. There are two: auth and ping. A send is refused: a client only listens.
already-authenticatedA second auth.

bye

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

CodeMeaningConnect again?
not-authenticatedNo 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-outThe account has no login left that is alive.No.
not-allowedThe account is no longer an administrator, or is gone.No.
replacedThe 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.
unavailableThe alerts' 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.
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

WhatRule
Who gets inAdministrators, 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 ticket32 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 inOne 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 socketA 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 addressBehind 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 wireThe 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 logThat 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 replayedThe 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 accountsAn account under trace has its arrival on this socket in its trace.

Limits

LimitValueSettingWhat happens
Connections at the same time50$AdminDataMaxClientsHTTP 503 to the next one.
Connections from one address10$AdminDataMaxClientsPerIPHTTP 503. The address is the visitor's, from the proxy's X-Forwarded-For.
Connections of one account5$AdminDataMaxConnectionsPerAccountThe oldest one gets bye / replaced.
Frames20 in 10 seconds, 4 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 admindata/ticket.
An alert's text on the socket12 000 characters–Truncated: 1; the whole text is in the record (up to 100 000 characters; Discord gets the first 4096).
The recordkept until deleted by hand$AdminAlertRetentionDaysSet 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
FieldMeaning
Used / Available / Total websocketsConnections (those still logging in included — the status request itself is one), the room left before 503, and $AdminDataMaxClients.
Logged inConnections that have presented a valid ticket.
Alerts relayedDatagrams accepted since the server started.
Last alertWhen 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

SymptomLook at
The website's box never shows anything, Discord gets everythingadmindata/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 emptySchemaReady 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 logThe server and the API read different keys — the server was started before $AdminDataEventKey was changed. Restart it.
An administrator is thrown out every half minuteThe 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

  1. Database: run objects/sql_admindata.sql (tables AdminAlerts and AdminDataTickets). Until then sendAdminAlert() pushes and posts to Discord without storing anything, admindata/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 and /ws/chat lines, then reload:
    ProxyPass        "/ws/admindata" "ws://127.0.0.1:8148/"
    ProxyPassReverse "/ws/admindata" "ws://127.0.0.1:8148/"
  3. 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";'.
  4. cron (root): * * * * * /var/www/html/api.ericade.net/websocket/admindata-ws.sh — and chmod +x the script. Then tail /var/log/admindata-ws/admindata-ws.log: it says where it listens and where it takes the datagrams.
  5. Logs: bash /var/www/html/api.ericade.net/logrotate/install.sh as root, once more, for the two new folders (admindata-ws/, websocket_admindata_events/) and their rotation.
  6. The website: nothing to configure. Its header carries the box, js/legacy.js connects 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.