Overview
The station data WebSocket lets the website receive events the moment they happen, instead of polling
/radio/v2/nowplaying on a timer. One connection carries events
for both stations; every event states the StationID and StationName
it belongs to, so a client can show either station (or both) from a single socket.
Six station events are pushed:
| Event | Sent when | Carries full track data? |
|---|---|---|
now-playing | A new song starts playing on a station, or a live override begins. | Yes (except live overrides) |
next-up | A new “next song” appears for a station. | Yes |
song-request | A listener request is accepted on a station. | Yes |
song-rating | A star rating is set for a track. | Yes |
now-playing-on-playlist | The playlist position of a running show changes. | No — the playlist entry is a plain text field. |
override | A station goes live (override on) or returns to normal broadcast operations (override off). | No |
Authentication: none. The socket is read-only and carries the same public data as the REST endpoints. Clients cannot inject data through it — the only accepted client messages are the control actions listed below.
Connecting
Open the socket, optionally subscribe to a subset of stations, and handle incoming JSON messages.
Immediately after connecting the server sends a welcome message followed by a replay of the
latest known now-playing, next-up and now-playing-on-playlist events (marked
"Replay": 1), so a client is usually up to date without any REST call.
Example (browser JavaScript)
let ws;
function connect() {
ws = new WebSocket("wss://api.ericade.net/ws/stationdata");
ws.onopen = () => {
// Optional: only receive events for station 1.
ws.send(JSON.stringify({ Action: "subscribe", StationID: [1] }));
};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
switch (msg.Event) {
case "now-playing":
console.log(`[${msg.StationName}] Now playing:`,
msg.Payload.Track ? msg.Payload.Track.Title : msg.Payload.Title);
break;
case "next-up":
console.log(`[${msg.StationName}] Next up:`, msg.Payload.Title);
break;
case "song-request":
console.log(`[${msg.StationName}] ${msg.Payload.Requester} requested ${msg.Payload.Title}`);
break;
case "song-rating":
console.log(`[${msg.StationName}] ${msg.Payload.Title} rated ${msg.Payload.Stars} stars`);
break;
}
};
// Reconnect with backoff; see "Client best practices".
ws.onclose = () => setTimeout(connect, 2000 + Math.random() * 3000);
}
connect();
Message envelope
Every message from the server — station events and control messages alike — is a JSON object with this envelope:
| Attribute | Description | Example |
|---|---|---|
Event | Message type. Station events: now-playing, next-up, song-request, song-rating, now-playing-on-playlist, override. Control messages: welcome, subscribed, pong, error. | now-playing |
StationID | Station the event belongs to. 0 for control messages, which concern the connection rather than a station. | 1 = ericade.radio 2 = Best of ericade.radio |
StationName | Station name in plain text. Empty for control messages. | 24/7 tracked music |
Timestamp | When the event occurred, Unix epoch. | 1785440425 |
TimestampHR | When the event occurred, human-readable server local time. | 2026-07-30 21:00:25 |
Replay | Present and set to 1 when the message is a cached snapshot replayed after connect/subscribe, rather than a freshly fired event. Absent on live events. | 1 |
Payload | Event-specific data, see each event below. Absent on some control messages. | Object |
Station events
now-playing
Sent when a new song starts playing on a station. The Payload.Track object contains the complete
track data — the same object the Now Playing and
Track by ID REST endpoints return (Artist, Title, Slug, Image,
CompositeRating, TrackArtists, Playlog and so on). See the REST documentation for the full field list.
| Payload attribute | Description | Example |
|---|---|---|
TrackID | Track identifier. 0 when the track could not be resolved (rare; e.g. live broadcasts). | 14906 |
Artist | Complete artist field as ingested. For listener requests this includes the request prefix. | Dr. Future |
Title | Track title. | Plastic pop |
Guid | Internal GUID of the media file. | 4f2c… |
isListenerRequested | 1 when this play is a listener request, otherwise 0. Use this instead of the field inside Track, which is always 0. | 0 or 1 |
StartedAt | When the song started, Unix epoch. Remember to apply WebStreamingOffset before flipping the display, exactly as with the REST endpoint. | 1785440425 |
WebStreamingOffset | Seconds to delay the display by to compensate for stream buffering. Always present, even when there is no Track object (e.g. live overrides). | 12 |
Track | Full track object (see above). Omitted if the track lookup failed; fall back to the flat fields in that case. | Object |
Example
{
"Event": "now-playing",
"StationID": 1,
"StationName": "24/7 tracked music",
"Timestamp": 1785440425,
"TimestampHR": "2026-07-30 21:00:25",
"Payload": {
"TrackID": 14906,
"Artist": "Dr. Future",
"Title": "Plastic pop",
"Guid": "4f2c8c31-6b2e-4a44-9d0a-1c2f3e4d5a6b",
"isListenerRequested": 0,
"StartedAt": 1785440425,
"WebStreamingOffset": 12,
"Track": {
"Artist": "Dr. Future",
"Title": "Plastic pop",
"StationID": 1,
"StationName": "24/7 tracked music",
"Slug": "https://ericade.radio/#/song/14906/plastic-pop-dr-future",
"Image": "https://radio.ericade.net/images/demoscene.png",
"CompositeRating": 4.2,
"PlayLength": 213.4,
"TrackArtists": [ { "ArtistID": 4821, "Artist": "Dr. Future" } ]
}
}
}
Track object above is heavily shortened. The real object contains every field
documented for the Now Playing response.now-playing event fires whose data comes from the
station’s settings table instead of the track database: TrackID is 0,
isOverride is 1, there is no Track object, and the flat
Artist/Title/Image fields carry the free-text override values. Display
those as they are.next-up
Sent when a new “next song” appears for a station. Payload attributes:
| Payload attribute | Description | Example |
|---|---|---|
TrackID | Track identifier of the upcoming song. For listener requests this is resolved to the real track. | 10663 |
Artist | Artist of the upcoming song. For listener requests the request prefix is stripped. | Cube |
Title | Title of the upcoming song. | My pixels are weapons |
Guid | Internal GUID of the media file. | 9a1b… |
WebStreamingOffset | Seconds to delay the display by to compensate for stream buffering. Same meaning as on now-playing. | 12 |
Track | Full track object, same shape as for now-playing. Omitted if the lookup failed. | Object |
next-up events in two ways:
1. Buffer window: a
next-up arriving within 5 seconds after the last
now-playing for the same station is discarded — listeners are still hearing the previous song
then, and flipping the next-up display that early would spoil it. A discarded event is forgotten completely, so
a later resend of the same track is still broadcast.
2. Duplicate suppression: if the next-up slot for a station is set to the same track it already holds (matched by
TrackID, or by Artist/Title/Guid when the track could not be resolved), no
new event is broadcast. The cached snapshot replayed to newly connected clients is likewise left untouched, so it
still reflects the timestamp of the last genuine change rather than a repeat call.song-request
Sent when a listener request passes all checks and is queued. The greeting and the requester’s IP are deliberately not broadcast. Payload attributes:
| Payload attribute | Description | Example |
|---|---|---|
TrackID | Track identifier of the requested song. | 10663 |
Artist | Artist of the requested song. | Dr Future |
Title | Title of the requested song. | Plastic pop (Plastic kills life mix) |
Requester | Name the requester entered. | Erik |
QueuePosition | Position in the request queue after this request was added. | 3 |
ExpectedWaitTime | Human-readable estimate of the time until play. | 48 minutes |
Track | Full track object, same shape as for now-playing. | Object |
song-rating
Sent when a listener sets a star rating on a track. The voter’s IP and browser hash are deliberately not broadcast. Payload attributes:
| Payload attribute | Description | Example |
|---|---|---|
TrackID | Track identifier of the rated song. | 14906 |
Artist | Artist of the rated song. | Dr. Future |
Title | Title of the rated song. | Plastic pop |
Stars | The rating that was just given, 1–5. | 5 |
CompositeRating | New composite rating for the track after this vote. | 4.31 |
Votes | Total number of votes for the track after this vote. | 17 |
Track | Full track object with the fresh rating, same shape as for now-playing. | Object |
song-rating concerns the track that was rated, which is not necessarily the track
currently playing. Match on Payload.TrackID before updating any “now playing” UI.now-playing-on-playlist
Sent when the playlist position of a running show changes. Shows (for example live sets or podcast episodes)
carry a PlayList text field with timestamped entries; while such a show is playing, the relay
re-evaluates the current entry every 5 seconds and fires this event whenever it changes. The value is a plain
text field — there is no artist or song data behind it. Present it as it is.
| Payload attribute | Description | Example |
|---|---|---|
NowPlayingOnPlaylist | The playlist entry that is playing right now, exactly as written in the show’s playlist. May be empty when no entry matches the current position. | Jester of Elysium - Elysium |
Example
{
"Event": "now-playing-on-playlist",
"StationID": 1,
"StationName": "24/7 tracked music",
"Timestamp": 1785441106,
"TimestampHR": "2026-07-30 21:11:46",
"Payload": {
"NowPlayingOnPlaylist": "Jester of Elysium - Elysium"
}
}
now-playing event as
Track.NowPlayingOnPlaylist; this event only fires for subsequent changes. The latest value is
cached and replayed to newly connecting clients (marked "Replay": 1) for as long as the show is
playing; the cache is cleared when the next song starts.now-playing events alone there:
it also actively checks every 5 seconds which track is currently playing and picks up the running
episode’s playlist by itself — for example after a relay restart in the middle of an episode. The
playlist position is polled continuously either way, so now-playing-on-playlist events keep flowing
throughout the whole episode.override
Sent when a station’s override state changes in the settings table — that is, when a live broadcast takes over the station (override on) or the station returns to normal broadcast operations (override off). The event only fires on an actual state change, not on every call to the settings endpoint.
| Payload attribute | Description | Example |
|---|---|---|
Override | The new override state. 1 = live override active, 0 = normal broadcast. | 0 or 1 |
Message | Human-readable state description: Just went live when the override turns on, Normal broadcast operations when it turns off. | Just went live |
Artist | Override artist text from the settings table. Only present when Override is 1. | ericade.radio |
Title | Override title text from the settings table. Only present when Override is 1. | Live from the demo party! |
Image | Override image URL from the settings table. Only present when Override is 1. | https://radio.ericade.net/images/live.png |
Example
{
"Event": "override",
"StationID": 1,
"StationName": "24/7 tracked music",
"Timestamp": 1785442000,
"TimestampHR": "2026-07-30 21:26:40",
"Payload": {
"Override": 1,
"Message": "Just went live",
"Artist": "ericade.radio",
"Title": "Live from the demo party!",
"Image": "https://radio.ericade.net/images/live.png"
}
}
now-playing event with the override
data from the settings table fires alongside this event (see the note in the
now-playing section). When the override turns off, only the
override event fires — the next regular now-playing arrives when the playout
system starts the next song. Clients that want the current track immediately can make one REST call to
/radio/v2/nowplaying on receiving Override: 0.Control messages
Control messages use StationID: 0 and an empty StationName.
welcome
First message after the connection is established.
| Attribute | Description | Example |
|---|---|---|
Protocol | Protocol version. Bumped when the message format changes. | 1 |
Stations | Array of available stations: StationID and StationName. | [{"StationID":1,"StationName":"24/7 tracked music"}, …] |
Documentation | URL of this page. |
subscribed
Acknowledges a subscribe action. Subscribed lists the station IDs the connection now
receives. After the acknowledgement, cached now-playing/next-up/now-playing-on-playlist messages for the chosen
stations are replayed (marked "Replay": 1).
pong
Answer to the application-level ping action.
error
Sent when a client message could not be processed. Message contains a human-readable explanation.
The connection stays open.
Client actions
Clients may send the following JSON messages. Anything else is answered with an error message.
subscribe
Restrict (or widen) which stations the connection receives events for. Without a subscribe, a connection receives all stations.
{ "Action": "subscribe", "StationID": [1, 2] }
| Attribute | Mandatory? | Description | Notes |
|---|---|---|---|
Action | Yes | Must be subscribe. | |
StationID | Yes | Array of station IDs (a single integer is also accepted). An empty array means all stations. | Unknown IDs are rejected with an error and the previous subscription is kept. |
snapshot
Asks the server to resend the cached now-playing/next-up/now-playing-on-playlist messages (marked
"Replay": 1) for the subscribed stations. Useful after a mobile browser wakes a background tab.
{ "Action": "snapshot" }
ping
Application-level keepalive for clients that cannot send protocol-level pings (browsers). The server answers
with pong. Not normally needed — the server pings every client at the protocol level anyway.
{ "Action": "ping" }
Client best practices
| Topic | Recommendation |
|---|---|
| Initial state | Rely on the replayed messages after welcome. If none arrive within a couple of seconds (the relay was just restarted and has not seen a song change yet), fall back to one REST call to /radio/v2/nowplaying. |
| Reconnecting | Reconnect on close with a randomized backoff (e.g. 2–5 s, doubling up to ~60 s). Do not hammer the server with immediate retries. |
| Buffer offset | Apply Payload.WebStreamingOffset (seconds) before flipping the now-playing display, exactly as when polling. It is present even for live overrides, where there is no Track object. The event fires when the playout system starts the song, which is earlier than listeners hear it. |
| Replay flag | Treat "Replay": 1 messages as state synchronization, not as fresh events — do not re-trigger animations or notifications for them. |
| Keep polling as fallback | The WebSocket is an optimization. Keep a slow REST poll (for example every few minutes) or use the Sequence mechanism as a safety net if your use case cannot tolerate a missed event. |
| Unknown events | Ignore Event values you do not recognize; new event types may be added without a protocol bump. |
Limits
| Limit | Value | What happens when exceeded |
|---|---|---|
| Simultaneous clients | 200 | New connections are refused with HTTP 503. |
| Simultaneous clients per IP | 20 | New connections are refused with HTTP 503. |
| Client messages | 20 per 10 seconds | Connection is closed (code 1008). |
| Client message size | 4 KB | Connection is closed (code 1009). |
| Keepalive | Protocol ping every 30 s | Clients that miss two pings in a row are dropped (code 1001). |
| Message types | Text frames with JSON only | Binary frames close the connection (code 1003). |
These values are configurable server-side; see Operations.
Operations
The relay exposes two plain-HTTP endpoints on the same TCP port as the WebSocket listener (before the upgrade handshake), for use by uptime monitors and dashboards — not by station-data clients.
GET /health
Liveness check. Always returns 200 OK with the body OK as long as the relay's
event loop is running. Does not reflect capacity.
GET /status
Current connection capacity, as plain text:
Used websockets: 42, Available websockets: 158, Total websockets: 200
| Field | Meaning |
|---|---|
| Used websockets | Clients currently connected (including those still completing the handshake). |
| Available websockets | Remaining room before new connections are refused with HTTP 503. |
| Total websockets | The configured $WebSocketMaxClients ceiling. |
Capacity event log
The relay writes capacity and connection-availability events to
/var/log/websocket_stationdata_events/<YYYY-MM-DD>.log, one file per day, so an operator can see
when the relay approached or hit its limits without polling /status. Each line is
[timestamp] [event-type] message. Events:
| Event type | Fires when |
|---|---|
capacity-total-90 | Total connected clients reach 90% of $WebSocketMaxClients. |
capacity-total-100 | Total connected clients reach 100% of $WebSocketMaxClients. |
capacity-ip-90 | A single IP's connections reach 90% of $WebSocketMaxClientsPerIP. |
capacity-ip-100 | A single IP's connections reach 100% of $WebSocketMaxClientsPerIP. |
capacity-total-full | A new connection is refused because the relay is at capacity. |
capacity-total-available | The relay can accept new connections again after having been full. |
log-throttled | The event log itself is rate-limited: at most 10 entries per 5 seconds. Once exceeded, logging stops for 30 seconds and this entry records that it happened; regular events during the hold-off are dropped, not queued. |
Each threshold event fires once per crossing and resets once usage falls back under it, so a station going
90% → 100% → 80% → 95% logs capacity-total-90, capacity-total-100, then
capacity-total-90 again — not a line per connection.