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/websocket

This 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 include centrifuge-js, centrifuge-go, centrifuge-python, centrifuge-java, centrifuge-swift, and centrifuge-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 PatternScopeWho may subscribe
streamer:{streamerId}Every public event for the streamerAnyone, including anonymous
streamer:{streamerId}-{category}One category of public eventsAnyone, including anonymous
streamer-admin:{streamerId}Every admin event for the streamerThe streamer and their admins
streamer-admin:{streamerId}-{category}One category of admin eventsThe streamer and their admins
user:{userId}Every event scoped to one userThat user only
user:{userId}-{category}One category of that user's eventsThat 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, settings
  • streamer-admin:action_log, integration, permit, settings
  • user: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

Public

Channelsstreamer:{streamerId}streamer:{streamerId}-learn_list

TypePayloadWhen it fires
learn_list_updateLearnListUpdateA 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

Public

Channelsstreamer:{streamerId}streamer:{streamerId}-livestream

TypePayloadWhen it fires
livestream_endLivestreamThe streamer's livestream ended.
livestream_startLivestreamThe streamer went live.

play_history

Public

Channelsstreamer:{streamerId}streamer:{streamerId}-play_history

TypePayloadWhen it fires
play_history_addPlayHistoryDetailsA song was marked played and a play history entry was created.

queue

Public

Channelsstreamer:{streamerId}streamer:{streamerId}-queue

TypePayloadWhen it fires
now_playing_updateNowPlayingUpdateThe now-playing slot changed — a song was promoted into it, cleared out of it, or auto-promoted after the previous one was played.
queue_addQueueDetailsA song was added to the queue.
queue_clearQueueClearEvery queue entry was removed at once. Carries the ids that were cleared.
queue_removeQueueRemoveA 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_reorderQueueReorderThe 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_updateQueueDetailsA 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

Public

Channelsstreamer:{streamerId}streamer:{streamerId}-saved_queue

TypePayloadWhen it fires
saved_queue_updateSavedQueueUpdateThe streamer's saved queue changed. Carries the entry that moved and what happened to it.

settings

Public

Channelsstreamer:{streamerId}streamer:{streamerId}-settings

TypePayloadWhen it fires
streamer_settings_updateStreamerSettingsUpdateDataStreamer settings changed. Only the columns that moved are sent, shaped like the streamer object so they can be merged in place.

song

Public

Channelsstreamer:{streamerId}streamer:{streamerId}-song

TypePayloadWhen it fires
song_addSongA song was added to the songlist. Payload is null for bulk operations, which announce themselves with song_bulk_update instead.
song_bulk_updateSongBulkUpdateAn import or a bulk edit/delete changed many songs at once. Carries counts rather than one event per row — refetch the songlist.
song_removeSongRemoveA song was deleted from the songlist. Carries the song as it was, since it cannot be fetched back.
song_updateSongUpdatePayloadA 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

Admin

Channelsstreamer-admin:{streamerId}streamer-admin:{streamerId}-action_log

TypePayloadWhen it fires
action_log_addActionLogAddAn action log entry was recorded. One event per entry, carrying the entry itself; a handler that writes two rows publishes two events.

integration

Admin

Channelsstreamer-admin:{streamerId}streamer-admin:{streamerId}-integration

TypePayloadWhen it fires
integration_statusIntegrationStatusA chat platform integration connected, disconnected, errored, or had its settings changed.

permit

Admin

Channelsstreamer-admin:{streamerId}streamer-admin:{streamerId}-permit

TypePayloadWhen it fires
permit_consumedPermitEventA viewer spent a permit on a request.
permit_deletedPermitEventA permit was revoked.
permit_issuedPermitEventA permit was issued to a viewer.
permit_updatedPermitEventA permit was edited.

settings

Admin

Channelsstreamer-admin:{streamerId}streamer-admin:{streamerId}-settings

TypePayloadWhen it fires
streamer_settings_updateStreamerSettingsUpdateDataStreamer 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

Viewer

Channelsuser:{userId}user:{userId}-admin

TypePayloadWhen it fires
admin_addedViewerStreamerYou were made an admin of a streamer's channel.
admin_removedViewerStreamerYour admin access to a streamer's channel was removed.

fav_song_match

Viewer

Channelsuser:{userId}user:{userId}-fav_song_match

TypePayloadWhen it fires
fav_song_match_addFavSongMatchA song matching one of your favorites was queued.

favorite_streamer

Viewer

Channelsuser:{userId}user:{userId}-favorite_streamer

TypePayloadWhen it fires
favorite_addedViewerStreamerYou favorited a streamer.
favorite_removedViewerStreamerYou unfavorited a streamer.

livestream

Viewer

Channelsuser:{userId}user:{userId}-livestream

TypePayloadWhen it fires
livestream_endViewerLivestreamA streamer you favorited ended their livestream.
livestream_startViewerLivestreamA streamer you favorited went live.

permit

Viewer

Channelsuser:{userId}user:{userId}-permit

TypePayloadWhen it fires
permit_consumedPermitEventYou spent a permit on a request.
permit_deletedPermitEventOne of your permits was revoked.
permit_issuedPermitEventA streamer issued you a permit.
permit_updatedPermitEventOne of your permits was edited.

play_history

Viewer

Channelsuser:{userId}user:{userId}-play_history

TypePayloadWhen it fires
play_history_addPlayHistoryDetailsOne 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

Viewer

Channelsuser:{userId}user:{userId}-queue

TypePayloadWhen it fires
queue_addQueueDetailsOne of your requests entered a streamer's queue. Same payload as the public event, with your own requests flagged `mine`.
queue_removeQueueRemoveA queue entry holding one of your requests was removed or played. Same payload as the public event, with your own requests flagged `mine`.
queue_updateQueueDetailsA 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

Viewer

Channelsuser:{userId}user:{userId}-saved_queue

TypePayloadWhen it fires
saved_queue_addSavedQueueUpdateOne of your requests entered a streamer's saved queue.
saved_queue_removeSavedQueueUpdateA saved queue entry holding one of your requests was removed.
saved_queue_updateSavedQueueUpdateA saved queue entry holding one of your requests was edited.

token_balance

Viewer

Channelsuser:{userId}user:{userId}-token_balance

TypePayloadWhen it fires
token_balance_updateTokenBalanceUpdateYour 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:

Credentialuser:{userId}streamer-admin:{streamerId}
Session cookiethat user onlythe streamer and their admins
Authorization: Streamer <token>neveronly the streamer the token belongs to
Authorization: User <token>that user onlyevery streamer that user owns or administrates
Authorization: Bearer <token>that user, with a scopethe 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.