Realtime Events
StreamerSonglist publishes realtime events over a WebSocket transport powered by Centrifugo. Subscribe to a channel to receive push updates whenever the queue, songlist, play history, saved queue, learn list, livestream state, or streamer settings change — no polling required.
Endpoint
wss://events.staging.streamersonglist.com/connection/websocketThis is the endpoint for the environment you are reading these docs on, so the staging and production portals each show their own host.
Public streamer channels accept anonymous connections: you do not need a token to connect or subscribe to them. Admin and viewer channels are access-controlled — see Channels.
Clients
You can integrate with any tool that speaks the Centrifugo client protocol:
- An official
centrifuge-*SDK in your language. See the SDK list — available SDKs includecentrifuge-js,centrifuge-go,centrifuge-python,centrifuge-java,centrifuge-swift, andcentrifuge-dart. - A native WebSocket client in your language, speaking the Centrifugo JSON framing directly. See the WebSocket transport and client protocol references.
- A read-only client on the unidirectional WebSocket, where the only message you ever send is the first one. See Example: unidirectional WebSocket.
Channels
There are three channel families, and which one an event goes to decides who is allowed to hear it.
| Channel Pattern | Scope | Who may subscribe |
|---|---|---|
streamer:{streamerId} | Every public event for the streamer | Anyone, including anonymous |
streamer:{streamerId}-{category} | One category of public events | Anyone, including anonymous |
streamer-admin:{streamerId} | Every admin event for the streamer | The streamer and their admins |
streamer-admin:{streamerId}-{category} | One category of admin events | The streamer and their admins |
user:{userId} | Every event scoped to one user | That user only |
user:{userId}-{category} | One category of that user's events | That user only |
Every event is published to a category-specific channel and the corresponding base channel. Subscribe to a base channel for a firehose; subscribe to a category channel to receive only the events you care about.
Categories in use:
streamer:—queue,song,play_history,saved_queue,learn_list,livestream,settingsstreamer-admin:—action_log,integration,permit,settingsuser:—queue,play_history,saved_queue,favorite_streamer,admin,livestream,fav_song_match,permit,token_balance
Category names are snake_case, and one event name resolves to one category on every channel family.
Event Envelope
Every publication has the following shape:
{
"type": "<event_type>",
"data": <payload or null>
}
When data is null, treat the event as a signal to refetch the relevant resource via the
REST API.
An event type is not unique on its own. The same name means different things depending on
the channel family it arrived on. queue_add on a streamer: channel is "a song joined this
streamer's queue"; the same name on your own user: channel is "one of your requests
did". permit_issued on a streamer-admin: channel is about someone else's permit; on your
own channel it is about yours. Always interpret type together with the channel you received
it on.
A user: event carries the same payload as the public event for the same change, with one
addition: the requests on it that belong to you are flagged mine. That flag is the only way
to tell — the user id behind a request is never serialized.
Public channels
Anyone may subscribe, including anonymous connections.
learn_list
PublicChannelsstreamer:{streamerId}streamer:{streamerId}-learn_list
| Type | Payload | When it fires |
|---|---|---|
learn_list_update | LearnListUpdate | A learn list song was added, moderated, marked learned, or its vote total moved. Carries only the row that changed — the list reorders with every vote, so read the new totals from the row and re-sort locally. |
livestream
PublicChannelsstreamer:{streamerId}streamer:{streamerId}-livestream
| Type | Payload | When it fires |
|---|---|---|
livestream_end | Livestream | The streamer's livestream ended. |
livestream_start | Livestream | The streamer went live. |
play_history
PublicChannelsstreamer:{streamerId}streamer:{streamerId}-play_history
| Type | Payload | When it fires |
|---|---|---|
play_history_add | PlayHistoryDetails | A song was marked played and a play history entry was created. |
queue
PublicChannelsstreamer:{streamerId}streamer:{streamerId}-queue
| Type | Payload | When it fires |
|---|---|---|
now_playing_update | NowPlayingUpdate | The now-playing slot changed — a song was promoted into it, cleared out of it, or auto-promoted after the previous one was played. |
queue_add | QueueDetails | A song was added to the queue. |
queue_clear | QueueClear | Every queue entry was removed at once. Carries the ids that were cleared. |
queue_remove | QueueRemove | A queue entry was removed, or marked played and moved to play history. Carries the row as it was, since it cannot be fetched back. |
queue_reorder | QueueReorder | The queue order changed. Carries the complete post-change ordering, because position is a rank over the whole queue and one row moving renumbers every row below it. |
queue_update | QueueDetails | A queue entry changed — it was edited, moved, gained or lost a request, or returned to the queue from the now-playing slot. Carries the entry as it now stands. Payload is null only when no single entry survives the change to be named: the queue was cleared, a song was played, or an entry took the now-playing slot with nothing demoted out of it. Read null as 'refetch the queue'; the queue_clear, queue_remove, queue_reorder and now_playing_update events published alongside carry the detail. |
saved_queue
PublicChannelsstreamer:{streamerId}streamer:{streamerId}-saved_queue
| Type | Payload | When it fires |
|---|---|---|
saved_queue_update | SavedQueueUpdate | The streamer's saved queue changed. Carries the entry that moved and what happened to it. |
settings
PublicChannelsstreamer:{streamerId}streamer:{streamerId}-settings
| Type | Payload | When it fires |
|---|---|---|
streamer_settings_update | StreamerSettingsUpdateData | Streamer settings changed. Only the columns that moved are sent, shaped like the streamer object so they can be merged in place. |
song
PublicChannelsstreamer:{streamerId}streamer:{streamerId}-song
| Type | Payload | When it fires |
|---|---|---|
song_add | Song | A song was added to the songlist. Payload is null for bulk operations, which announce themselves with song_bulk_update instead. |
song_bulk_update | SongBulkUpdate | An import or a bulk edit/delete changed many songs at once. Carries counts rather than one event per row — refetch the songlist. |
song_remove | SongRemove | A song was deleted from the songlist. Carries the song as it was, since it cannot be fetched back. |
song_update | SongUpdatePayload | A song's queued count or play stats moved, or the streamer edited it. Play-count changes send only the fields that moved; an edit carries the whole song under `song`. |
Admin channels
Only the streamer and their admins may subscribe.
action_log
AdminChannelsstreamer-admin:{streamerId}streamer-admin:{streamerId}-action_log
| Type | Payload | When it fires |
|---|---|---|
action_log_add | ActionLogAdd | An action log entry was recorded. One event per entry, carrying the entry itself; a handler that writes two rows publishes two events. |
integration
AdminChannelsstreamer-admin:{streamerId}streamer-admin:{streamerId}-integration
| Type | Payload | When it fires |
|---|---|---|
integration_status | IntegrationStatus | A chat platform integration connected, disconnected, errored, or had its settings changed. |
permit
AdminChannelsstreamer-admin:{streamerId}streamer-admin:{streamerId}-permit
| Type | Payload | When it fires |
|---|---|---|
permit_consumed | PermitEvent | A viewer spent a permit on a request. |
permit_deleted | PermitEvent | A permit was revoked. |
permit_issued | PermitEvent | A permit was issued to a viewer. |
permit_updated | PermitEvent | A permit was edited. |
settings
AdminChannelsstreamer-admin:{streamerId}streamer-admin:{streamerId}-settings
| Type | Payload | When it fires |
|---|---|---|
streamer_settings_update | StreamerSettingsUpdateData | Streamer settings changed. Mirrors the public settings event so admin views can react to changes made in another session. |
Viewer channels
Only the user themselves may subscribe.
admin
ViewerChannelsuser:{userId}user:{userId}-admin
| Type | Payload | When it fires |
|---|---|---|
admin_added | ViewerStreamer | You were made an admin of a streamer's channel. |
admin_removed | ViewerStreamer | Your admin access to a streamer's channel was removed. |
fav_song_match
ViewerChannelsuser:{userId}user:{userId}-fav_song_match
| Type | Payload | When it fires |
|---|---|---|
fav_song_match_add | FavSongMatch | A song matching one of your favorites was queued. |
favorite_streamer
ViewerChannelsuser:{userId}user:{userId}-favorite_streamer
| Type | Payload | When it fires |
|---|---|---|
favorite_added | ViewerStreamer | You favorited a streamer. |
favorite_removed | ViewerStreamer | You unfavorited a streamer. |
livestream
ViewerChannelsuser:{userId}user:{userId}-livestream
| Type | Payload | When it fires |
|---|---|---|
livestream_end | ViewerLivestream | A streamer you favorited ended their livestream. |
livestream_start | ViewerLivestream | A streamer you favorited went live. |
permit
ViewerChannelsuser:{userId}user:{userId}-permit
| Type | Payload | When it fires |
|---|---|---|
permit_consumed | PermitEvent | You spent a permit on a request. |
permit_deleted | PermitEvent | One of your permits was revoked. |
permit_issued | PermitEvent | A streamer issued you a permit. |
permit_updated | PermitEvent | One of your permits was edited. |
play_history
ViewerChannelsuser:{userId}user:{userId}-play_history
| Type | Payload | When it fires |
|---|---|---|
play_history_add | PlayHistoryDetails | One of your requests was played. Same payload as the public event, with your own requests flagged `mine`. |
play_history_remove | { streamerId: number; playHistoryId: number } | A play history entry holding one of your requests was deleted. |
queue
ViewerChannelsuser:{userId}user:{userId}-queue
| Type | Payload | When it fires |
|---|---|---|
queue_add | QueueDetails | One of your requests entered a streamer's queue. Same payload as the public event, with your own requests flagged `mine`. |
queue_remove | QueueRemove | A queue entry holding one of your requests was removed or played. Same payload as the public event, with your own requests flagged `mine`. |
queue_update | QueueDetails | A queue entry holding one of your requests changed — it was edited, moved, gained or lost a request, or swapped places with the now-playing song. Same payload as the public event, with your own requests flagged `mine`. |
saved_queue
ViewerChannelsuser:{userId}user:{userId}-saved_queue
| Type | Payload | When it fires |
|---|---|---|
saved_queue_add | SavedQueueUpdate | One of your requests entered a streamer's saved queue. |
saved_queue_remove | SavedQueueUpdate | A saved queue entry holding one of your requests was removed. |
saved_queue_update | SavedQueueUpdate | A saved queue entry holding one of your requests was edited. |
token_balance
ViewerChannelsuser:{userId}user:{userId}-token_balance
| Type | Payload | When it fires |
|---|---|---|
token_balance_update | TokenBalanceUpdate | Your token balance with a streamer changed — a request spend, refund, cheer, sub, or admin adjustment. |
Payload Types
QueueDetails
{
id: number;
note: string | null;
createdAt: string; // ISO-8601
songId: number | null;
nonlistSong: string | null;
streamerId: number;
position: number;
song: {
title: string;
artist: string;
lastPlayed: string | null; // ISO-8601
lastPlayedFrom: string;
timesPlayed: number;
comment: string | null;
capo: string | null;
duration: number | null; // seconds
hasTabs: boolean;
hasLyrics: boolean;
hasChords: boolean;
isNew: boolean;
attributes: { name: string; image: string | null }[];
};
requests: {
id: number;
name: string;
amount: number;
requestText: string;
source: string;
createdAt: string; // ISO-8601
user: { username: string; platform: string } | null;
mine?: boolean; // only on your own `user:` channel
}[];
}
position is a rank computed when the event is built, not a stored column. One
row leaving or moving renumbers every row below it, which is why a reorder is announced
with queue_reorder carrying the whole ordering rather than one event per
affected row. Treat position as "where this sat at the moment of the event" and take the
ordering from queue_reorder.
QueueRemove
The row is gone by the time the event lands, so it carries the entry as it was rather than an
id you could no longer resolve. playHistoryId is set when the row left because it was
marked played, so you can move your own copy into history instead of refetching both lists.
{
id: number;
streamerId: number;
songId: number | null;
playHistoryId: number | null;
queue: QueueDetails | null;
}
QueueReorder
The complete ordering after a change that moved more than the edited row — a reorder, a promotion, a clear of the now-playing slot, or the auto-promotion that follows a song being played. A partial delta is not expressible, so the whole list is sent; it is a few bytes per entry and replaces the refetch it stands in for.
Apply it by reordering the entries you already hold and dropping any whose id is absent.
{
streamerId: number;
items: {
id: number;
position: number;
}
[];
playingId: number | null; // the row occupying the now-playing slot, if any
}
Queues longer than 1000 entries do not get this event — the payload would cost more than the
refetch it saves. The queue_update published alongside it still arrives, so treat that as
the signal to refetch when no queue_reorder follows.
QueueClear
{
streamerId: number;
removedIds: number[];
deleted: number;
}
NowPlayingUpdate
playing is null when the slot was cleared and nothing replaced it. previousId names
the row that was in the slot before, so a client can move its own copy rather than
rebuilding both.
{
streamerId: number;
playing: {
id: number;
note: string | null;
createdAt: string; // ISO-8601
songId: number | null;
nonlistSong: string | null;
streamerId: number;
nowPlayingStartedAt: string | null; // ISO-8601
song: QueueDetails['song'];
requests: QueueDetails['requests'];
} | null;
previousId: number | null;
}
PlayHistoryDetails
{
id: number;
createdAt: string; // ISO-8601
donationAmount: number;
nonlistSong: string | null;
songId: number | null;
streamerId: number;
playedAt: string; // ISO-8601
startedAt: string | null; // ISO-8601
updatedAt: string; // ISO-8601
note: string | null;
song: {
title: string;
artist: string;
lastPlayed: string | null; // ISO-8601
timesPlayed: number;
comment: string | null;
capo: string | null;
};
requests: {
id: number;
name: string;
amount: number;
requestText: string;
source: string;
createdAt: string; // ISO-8601
user: { username: string; platform: string } | null;
}[];
}
SavedQueueUpdate
One event name covers add, edit and remove, so action is what tells them apart. item is
the entry as it stands after the change, and is null on a removal — there is nothing left
to send but the id.
{
streamerId: number;
action: 'add' | 'update' | 'remove';
itemId: number;
item: {
id: number;
note: string | null;
createdAt: string; // ISO-8601
songId: number | null;
nonlistSong: string | null;
streamerId: number;
song: QueueDetails['song'];
requests: QueueDetails['requests'];
} | null;
}
LearnListUpdate
Carries the single row that changed, not the board.
The list is ordered by vote total and reorders on every vote, but that ordering is
derivable: apply the new voteTotal to the entry you already hold and re-sort. Sending the
whole board on every vote would cost far more than it saves.
song is null when the change named no row — reason: "config" means the streamer
changed how the board itself behaves.
Who suggested a song is not included: this is a public channel.
{
streamerId: number;
reason: 'added' | 'voted' | 'unvoted' | 'moderated' | 'learned' | 'config';
song: {
id: string; // UUID
streamerId: number;
title: string;
artist: string;
note: string;
status: string;
voteTotal: number;
createdAt: string; // ISO-8601
updatedAt: string; // ISO-8601
learnedAt: string | null; // ISO-8601
learnedSongId: number | null;
} | null;
}
Song
The full songlist row, as returned by the REST songs endpoints.
{
id: number;
title: string;
artist: string;
createdAt: string; // ISO-8601
active: boolean;
lastActivation: string; // ISO-8601
comment: string;
tabs: string;
lyrics: string;
minAmount: number;
chords: string;
capo: string;
learned: boolean;
requestedBy: string;
bypassRequestLimits: boolean;
streamerId: number;
timesPlayed: number;
lastPlayed: string | null; // ISO-8601
lastPlayedFrom: string | null;
durationSeconds: number | null;
masterSongId: number | null;
learnListId: string | null; // UUID
}
Alongside every field the songlist row itself has, this carries what a viewer needs to
render the song without a second call: numQueued, the resolved minTokens, isNew, and
the song's visible attributes. Hidden attributes are never included — they are the
streamer's own tooling, and this is an open channel.
learned and requestedBy are legacy columns nothing writes any more; they are still sent
so the payload stays a superset of what it published before.
song_add sends null instead of a Song for bulk operations, where emitting one event per
row would be wasteful. Those announce themselves with song_bulk_update
— refetch the songlist when you receive either.
SongRemove
A song was deleted. The row is gone by the time the event lands, so it carries the song as it was rather than an id you could no longer resolve.
{
id: number;
streamerId: number;
song: Song | null;
}
SongBulkUpdate
An import or a bulk edit/delete changed many songs at once. One event per row would be far more traffic than the refetch it was meant to save, so this reports what ran and how much moved. Refetch the songlist.
{
streamerId: number;
operation:
| 'import'
| 'bulk_update'
| 'bulk_edit'
| 'bulk_delete'
| 'update_by_filter'
| 'delete_by_filter';
created: number;
updated: number;
deleted: number;
skipped: number;
}
SongUpdatePayload
Two shapes share one event name.
A play-count change is a field-level patch: only the counters that moved are present, so
merge by id rather than replacing the row.
A streamer edit can touch anything, so it carries the whole song under song and no
counters. Replace your copy with it.
{
id: number;
numQueued?: number;
lastPlayed?: string; // ISO-8601
lastPlayedFrom?: string;
timesPlayed?: number;
song?: Song; // present on an edit
}
Livestream
{
id: string; // UUID
streamerId: number;
platform: string;
remoteBroadcastId: string | null;
startTime: string; // ISO-8601
endTime: string | null; // ISO-8601
createdAt: string; // ISO-8601
updatedAt: string; // ISO-8601
}
ViewerLivestream
What a favouriter receives when a streamer they follow goes live or ends. livestream is
null only when the row could not be read.
{
streamerId: number;
livestream: Livestream | null;
}
ViewerStreamer
Names the streamer a relationship event is about — one you favourited, or one whose channel
you were made an admin of. streamer is null only when the streamer could not be read.
{
streamerId: number;
streamer: {
streamerId: number;
username: string;
displayName: string;
profileImageUrl: string | null;
} | null;
}
FavSongMatch
{
queueId: number;
streamerId: number;
queue: QueueDetails | null;
}
PermitEvent
{
streamerId: number;
permitId: string; // UUID
permit: {
id: string; // UUID
streamerId: number;
userId: number | null;
platform: string;
username: string;
bypasses: string[];
issuedAt: string; // ISO-8601
expiresAt: string | null; // ISO-8601
consumedAt: string | null; // ISO-8601
consumedSongRequestId: number | null;
} | null;
}
Which admin issued the permit is not included: the same event goes to the streamer's admins and to the viewer holding it.
TokenBalanceUpdate
balance is where you ended up; amount is the signed movement that got you there —
negative for a spend — so a client can render "+100" without diffing against a balance it may
never have held. occurredAt lets it discard an event that arrives out of order.
{
streamerId: number;
balance: number;
entryType: string;
amount: number;
occurredAt: string; // ISO-8601
}
StreamerSettingsUpdateData
Carries only the settings that actually moved, keyed and shaped exactly as they appear on the streamer object, so you can merge them into the streamer you already hold instead of refetching. Nested configuration leaves arrive inside their parent object.
{
streamerId: number;
fields: Record<string, unknown>;
}
Every field that can appear here is already readable from the unauthenticated
GET /streamers response, which is why this event is published to the public channel as
well as the admin one.
ActionLogAdd
One event per entry. A single action can write more than one row — saving a queued song writes one against the queue and one against the saved queue — and each gets its own event.
entry is null when the entry could not be established, which happens for changes made
outside a request (background jobs). Treat that as the signal to refetch the log.
The display name of the user who acted is not included — resolving it would mean two extra
joins on nearly every write. userId is there so you can resolve it once and cache it.
{
streamerId: number;
entry: {
id: string; // UUID
streamerId: number;
type: string;
action: string;
data: Record<string, unknown>;
userId: number | null;
createdAt: string; // ISO-8601
revertedAt: string | null; // ISO-8601
revertible: boolean;
} | null;
}
IntegrationStatus
The connection state of a chat platform integration.
{
id: number;
type: string; // "twitch", "kick", "youtube", ...
connectionHealth?: string;
connectionError?: string | null;
settings?: Record<string, unknown>;
}
connectionHealth/connectionError are sent when the connection state changes; settings
is sent when the integration's configuration is edited.
Example: centrifuge-js
Use the endpoint shown under Endpoint above for the environment you are targeting.
import { Centrifuge } from 'centrifuge';
const client = new Centrifuge(WEBSOCKET_ENDPOINT);
const sub = client.newSubscription(`streamer:${streamerId}-queue`);
sub.on('publication', (ctx) => {
const event = ctx.data as { type: string; data: unknown };
switch (event.type) {
case 'queue_add':
// event.data is QueueDetails
break;
case 'queue_remove':
// event.data is QueueRemove — the row as it was, since it cannot be
// fetched back, plus playHistoryId when it left because it was played
break;
case 'queue_update':
// event.data is QueueDetails — the entry as it now stands. It is null
// only when no single entry survives the change (the queue was cleared,
// a song was played, a row took the now-playing slot with nothing
// demoted); read null as "refetch the queue"
break;
}
});
sub.subscribe();
client.connect();
To receive every public event for a streamer in a single subscription, subscribe to
streamer:${streamerId} instead and switch on event.type.
Example: native WebSocket
If you'd rather not pull in a Centrifugo SDK, you can speak the protocol
directly over a plain WebSocket. The framing is documented in the
Centrifugo client protocol
reference — at minimum you'll need to send a connect command on open and a
subscribe command for each channel, then read publication frames as they
arrive.
Example: unidirectional WebSocket
For consumers that only ever read — overlays, bots, dashboards — the
unidirectional WebSocket
is simpler: connect to /connection/uni_websocket on the same host, send one
JSON message naming the channels you want, and read frames from then on. There
is no subscribe command and nothing further to send.
const ws = new WebSocket('wss://events.streamersonglist.com/connection/uni_websocket');
ws.onopen = () => {
ws.send(
JSON.stringify({
name: 'my-overlay',
subs: {
'streamer:1-queue': {},
'streamer:1-play_history': {},
},
}),
);
};
ws.onmessage = (message) => {
const frame = JSON.parse(message.data);
if (frame.connect) {
console.log('subscribed to', Object.keys(frame.connect.subs ?? {}));
return;
}
if (frame.pub) {
const { event, data } = frame.pub.data;
console.log(frame.channel, event, data);
}
};
The connect frame that comes back lists the channels you were actually
subscribed to in its subs map. Channels you asked for but may not read are
omitted rather than rejected, so check that map instead of assuming every
requested channel was granted.
Authenticating a connection
Public streamer: channels need no credential. To reach user: or
streamer-admin: channels, authenticate the WebSocket handshake — the same
credentials the REST API takes, in the same Authorization header, on both
transports:
| Credential | user:{userId} | streamer-admin:{streamerId} |
|---|---|---|
| Session cookie | that user only | the streamer and their admins |
Authorization: Streamer <token> | never | only the streamer the token belongs to |
Authorization: User <token> | that user only | every streamer that user owns or administrates |
Authorization: Bearer <token> | that user, with a scope | the streamer that user owns, with a scope |
Each credential reaches exactly what it reaches on the REST API. A streamer
access token authenticates a channel rather than the person behind it, so it
never reads user: channels; an OAuth2 token additionally needs a read scope
for what it subscribes to — the category's own scope where one exists
(streamer-admin:{id}-action_log wants streamer.action-log.read), otherwise
streamer.*.read. user: channels take user.read or any user.*.read.
A browser cannot set headers on a WebSocket handshake, so browser clients
authenticate with the session cookie. Everything else — bots, overlays, servers
— should send Authorization, which takes precedence when both are present. A
credential that fails to resolve does not fail the connection: it degrades to
anonymous, and public channels still work.
