WebSocket

Real-time communication with PalcoTimer via WebSocket.

Introduction

PalcoTimer uses WebSocket for real-time synchronization between all connected devices. Any change (play, pause, timer change, message) is propagated instantly.

Connecting

Connect to the WebSocket using the URL:

wss://app.palcotimer.com/ws/{roomId}?output={tipo}&name={nome}
ParameterDescription
roomIdRoom ID or short_code
outputRecommended; required to send control commands. Command types (can control the room): controller, operator, moderator. Read-only types: viewer, embed, agenda, public_agenda. Omitting the parameter is equivalent to viewer: still tolerated today for legacy clients, but deprecated and will stop allowing control. Always send output=controller (or operator/moderator).
nameDevice name (optional)

Client → Server Messages

Request state

{
  "type": "request_state"
}

Ping (keep connection)

{
  "type": "ping"
}

Playback controls

// Play
{ "type": "control", "action": "play" }

// Pause
{ "type": "control", "action": "pause" }

// Stop
{ "type": "control", "action": "stop" }

// Reset
{ "type": "control", "action": "reset" }

// Next timer
{ "type": "control", "action": "next" }

// Previous timer
{ "type": "control", "action": "previous" }

// Go to specific timer
{ "type": "control", "action": "go_to", "timer_id": "uuid" }

// Add time (ms)
{ "type": "control", "action": "add_time", "value": 60000 }

// Set time (ms)
{ "type": "control", "action": "set_time", "value": 300000 }

Display modes

// Toggle blackout
{ "type": "control", "action": "blackout" }
{ "type": "control", "action": "stop_blackout" }

// Toggle flash
{ "type": "control", "action": "flash" }
{ "type": "control", "action": "stop_flash" }

// Toggle focus (message only)
{ "type": "control", "action": "focus" }
{ "type": "control", "action": "stop_focus" }

Messages

// Show message
{ "type": "control", "action": "show_message", "timer_id": "message_uuid" }

// Hide message
{ "type": "control", "action": "hide_message" }

From the stage to the server

Only accepted from an output=viewer connection, the speaker's screen. From any other output type they are ignored. The output type is set by the server on connection, never by the payload. Neither changes the timer.

{ "type": "ack_message", "message_id": "message_uuid" }

{ "type": "send_signal", "signal_key": "slide_issue" }

ack_message: the speaker confirms the message on display. message_id is the message id; an invalid id, one repeated within the same display, a message that is not on air, or ack_enabled off is silently discarded.

send_signal: triggers a predefined signal by its key (signal_key). At most 1 signal every 3 seconds per connection.

Server → Client Messages

Every server frame has a type field plus the payload fields at the same level. The table below is the list of the events that always exist, with no server feature to turn on, plus the control room-stage communication ones, which only exist when the stage is switched on in the room and say so in their own description. A ? in the payload marks a field that may be absent.

EventRecipientsPayloadDescription
roomOnly the connection involved{ room, timers, messages, playback, server_time }Full room state. Sent on connection and on every request_state.
playback_statusWhole room{ state, timer_id, elapsed_ms, duration_ms, server_time, blackout, flash, focus }Timer state. Sent on every change and periodically while it runs.
timer_updateWhole room{ timer: { action, timer } }A timer was created or edited.
timer_deleteWhole room{ timer: { action, id } }A timer was deleted.
messageWhole room{ message: { action, message? } }A message was created (action create) or all of them were hidden (action hide, with no body).
message_updateWhole room{ message: { action, message } }A message was edited, shown or hidden.
message_deleteWhole room{ message: { action, id } }A message was deleted.
flashWhole room{ flash }The stage screen flash was turned on or off.
blackoutWhole room{ blackout }The stage screen blackout was turned on or off.
focusWhole room{ focus }Focus mode (only the message on the stage screen) was turned on or off.
clientsWhole room{ clients? }The list of room connections changed.
schedule_updateWhole room{ delay_ms, original_end?, projected_end?, timers }Timer times were recomputed: carries the accumulated delay and the timers with their new times.
pongOnly the connection involved{ server_time }Answer to ping, with the server clock. Use it to compute the offset from the local clock.
force_reloadWhole room{ }Request to reload the page. No payload.
errorOnly the connection involved{ code?, message?, error? }A command was refused or the connection was not accepted. See the warning below about the format.
kickedOnly the connection involved{ message }The connection was ended by the administrator. It arrives immediately before the socket closes.
demo_resetWhole room{ roomId, at }The demo room was reset: reload messages and questions. The only event with a camelCase field (roomId).
message_ackOnly controller and operator, plus the viewer that sent the action. Never other viewers, embeds or agenda screens{ message_id, conn_id, device_name, acked_at }Conditional (ack_enabled in the room): the speaker confirmed the message on air ("seen"). Only with ack_enabled in the room.
signalOnly controller and operator, plus the viewer that sent the action. Never other viewers, embeds or agenda screens{ signal }Conditional (signals on in the room): a new stage signal. The label is the one registered in the room, never text sent by the client.
signal_updateOnly controller and operator, plus the viewer that sent the action. Never other viewers, embeds or agenda screens{ signal }Conditional (signals on in the room): the control room handled the signal. The viewer that sent it receives the same event, to show "received".
sponsors_updatedThe stage screen (viewer and embed), the public agenda and the controller. Never other screens{ sponsors: { enabled, blackout, idle, idle_delay_s, rotation_s, style, public_agenda, questions, label, sponsors } }The room's sponsor setup changed (roster, order or settings). The sponsors field carries the WHOLE configuration: enabled, blackout, idle, idle_delay_s, rotation_s, style, public_agenda, questions, label and the sponsors list of active sponsors (id, name, logo_url, optional link_url and weight). It never carries tier, order or dates.
event_ranking_updatedEvery connection of the event rooms (stage screen, embed and PWA), through the event channel. It only arrives if the room is attached to an event{ event_id, top: [{ rank, display_label, points }], frozen, updated_at }Conditional (event channel): the overall event ranking changed. It arrives at most once per second per event, on every connection of the event rooms. It carries event_id, top (up to 50 positions with rank, display_label and points), frozen and updated_at. The display_label is the nickname or an anonymous label; never the real name without the participant consent, nor an e-mail, nor a person identifier. When frozen (frozen true) the big screen keeps the last ranking.
Treat any unknown type as safe to ignore: the protocol is additive and new events show up without notice. Never drop the connection or surface an error to the operator because of a type you do not know.

Room state

{
  "type": "room",
  "room": { "id": "...", "name": "...", ... },
  "timers": [ ... ],
  "messages": [ ... ],
  "playback": {
    "state": "running",
    "current_timer_id": "uuid",
    "elapsed_ms": 60000,
    "blackout": false,
    "flash": false,
    "focus": false
  },
  "server_time": 1704067200000
}

Playback status

{
  "type": "playback_status",
  "state": "running",
  "timer_id": "uuid",
  "elapsed_ms": 65000,
  "duration_ms": 300000,
  "server_time": 1704067200000,
  "blackout": false,
  "flash": false,
  "focus": false
}

Timer update

{
  "type": "timer_update",
  "timer": {
    "action": "update",
    "timer": { ... }
  }
}

Pong

{
  "type": "pong",
  "server_time": 1704067200000
}

The remaining events

Format of the events that have no example of their own above.

// Timer deleted: the id comes, not the timer
{ "type": "timer_delete", "timer": { "action": "delete", "id": "uuid" } }

// Message created, edited and deleted
{ "type": "message",        "message": { "action": "create", "message": { ... } } }
{ "type": "message_update", "message": { "action": "update", "message": { ... } } }
{ "type": "message_delete", "message": { "action": "delete", "id": "uuid" } }

// All messages hidden at once: action hide, with no body
{ "type": "message", "message": { "action": "hide" } }

// Stage screen modes (the value is the state AFTER the change)
{ "type": "flash",    "flash": true }
{ "type": "blackout", "blackout": false }
{ "type": "focus",    "focus": true }

// List of room connections; clients is absent when the list is empty
{
  "type": "clients",
  "clients": [
    { "id": "uuid", "room_id": "uuid", "output_type": "viewer", "device_name": "Telao 1" }
  ]
}

// Timer schedule recomputed after a delay
{
  "type": "schedule_update",
  "delay_ms": 300000,
  "original_end": "2026-01-01T12:30:00Z",   // optional
  "projected_end": "2026-01-01T12:35:00Z",  // optional
  "timers": [ ... ]
}

// Request to reload the page, for the whole room
{ "type": "force_reload" }

// Error; the text comes in message or in error, depending on the case
{ "type": "error", "code": "FORBIDDEN",        "message": "..." }
{ "type": "error", "code": "CONNECTION_LIMIT", "message": "..." }
{ "type": "error", "code": "UNKNOWN_ACTION",   "error": "..." }

// Only to the connection the administrator disconnected, right before closing
{ "type": "kicked", "message": "..." }

// Only in the demo room, when it is reset
{ "type": "demo_reset", "roomId": "uuid", "at": 1704067200000 }
The error envelope is not uniform
Today the error text comes in message for connection and permission errors (CONNECTION_LIMIT, FORBIDDEN) and in error for an unknown command (UNKNOWN_ACTION). Read both fields. There is also a path that emits {"type":"error"} with neither a code nor any text: handle both being absent without breaking.

Polls

Live polls (multiple choice, scale and word cloud) use the same room WebSocket. The poll_* events below are additive: a client that does not know them can safely ignore them, as it already does with any unknown message type.

Only exists with polls turned on
Everything in this section is only sent when the polls feature is turned on in the server (the POLLS_ENABLED variable). With it off, the protocol is identical to the one described in the other sections: no poll_* events and none of the new room state fields. Use room.capabilities.polls to find out whether the feature is on.

New fields in the room state

The room message sent on connection gains optional fields. Every field that already existed keeps its name and format.

FieldDescription
room.capabilitiesInside room. An object with the capabilities turned on in the server; only true keys appear (today polls and participant_identity) and the whole object is omitted when nothing is on. Absent means off. Same format as GET /rooms/{id}.
audience_stageWhat occupies the audience content slot on the stage screen: none or poll. May be absent; treat absent as none.
pollThe poll currently on the stage screen. Absent when there is none.
resultsThe aggregated result of the poll in poll. Absent when no poll is on the stage screen.
{
  "type": "room",
  "room": { "id": "...", "name": "...", ..., "capabilities": { "polls": true } },
  "timers": [ ... ],
  "messages": [ ... ],
  "playback": { ... },
  "server_time": 1704067200000,
  "audience_stage": "poll",
  "poll": { "id": "uuid", "question": "...", "type": "multiple_choice", "status": "open", ... },
  "results": { "poll_id": "uuid", "status": "open", "total_votes": 12, "total_voters": 12, ... }
}

Poll events

All of them have the type field plus the payload fields at the same level, like the other events.

EventRecipientsPayloadDescription
poll_openedWhole room{ poll }Voting was opened (or reopened).
poll_closedWhole room; the numbers only in some cases (see below){ poll, results? }Voting was closed. Carries the final result according to the reach rule below.
poll_resultsWhole room if the poll is on the stage screen; otherwise only controller and moderator{ results }Updated result after new votes. Batched: at most one message every 500 ms per poll.
poll_shownWhole room{ poll, results, audience_stage }The poll went to the stage screen. Carries the current result (may be null if it could not be computed) and the new audience_stage.
poll_hiddenWhole room{ poll_id, audience_stage }The poll left the stage screen. Carries the new audience_stage. The highlighted Q&A question does not come back on its own.
poll_votes_resetWhole room{ poll_id }Votes were reset. The zeroed result follows in poll_results.
poll_deletedWhole room if the poll was open or on the stage screen; otherwise only controller and moderator{ poll_id }The poll was deleted. If it was on the stage screen, a poll_hidden is also sent.
poll_createdOnly controller and moderator{ poll }A poll (draft) was created.
poll_updatedOnly controller and moderator{ poll }A poll was edited.
poll_word_pendingOnly controller and moderator{ poll_id, vote_id, word }A word-cloud word is waiting for moderation. Carries the word and the vote identifier.

Who receives the numbers

The aggregated result (the results field) goes to the whole room only while the poll is on the stage screen, because then the numbers are already on the big screen. Off the stage screen, results goes only to the production screens (controller and moderator). This applies to poll_results, to poll_closed and to the results in the room state.

In a poll_closed for a poll that is not on the stage screen, the non-production screens receive the event with poll only, without results. The audience reads the result through the HTTP API, which honors the poll visibility setting.

poll and results format

// poll
{
  "id": "uuid", "room_id": "uuid",
  "question": "...",
  "type": "multiple_choice",            // multiple_choice | scale | word_cloud
  "options": [{ "id": "...", "text": "..." }],                 // multiple_choice
  "scale": { "min": 1, "max": 5, "min_label": "...", "max_label": "..." },  // scale
  "word_cloud": { "max_words_per_voter": 3, "max_word_length": 30 },        // word_cloud
  "status": "open",                     // draft | open | closed
  "show_on_stage": true,
  "results_visibility": "always",       // always | after_vote | when_closed | never
  "position": 0,
  "created_at": "2026-01-01T12:00:00Z",
  "opened_at": "2026-01-01T12:05:00Z",  // optional
  "closed_at": "2026-01-01T12:10:00Z",  // optional
  "total_votes": 12
}

// results
{
  "poll_id": "uuid", "status": "open",
  "total_votes": 12, "total_voters": 12,
  "options": [{ "id": "...", "text": "...", "count": 7, "percent": 58.3 }],  // multiple_choice
  "scale": { "average": 3.8, "distribution": { "1": 0, "2": 1, "3": 4 } },  // scale
  "words": [{ "word": "...", "count": 5 }]                                   // word_cloud
}

// audience_stage: "none" | "poll"

Live quiz

The live quiz (multiple-choice questions with scoring and a ranking) uses the same room WebSocket. The quiz_* events are additive: a client that does not know them can safely ignore them, as it already does with any unknown message type.

Only exists with the quiz turned on
Everything in this section is only sent when the quiz is turned on in the server (the QUIZ_ENABLED variable). With it off, no quiz_* event is emitted and the room state gains no new field. Use room.capabilities.quiz to find out whether the feature is on.

New fields in the room state

While the quiz is on the stage screen, the room message gains the optional quiz field — the same format as the quiz field of quiz_state — and audience_stage also accepts the value quiz. The stage screen has a single audience content slot: a poll and the quiz cannot share it. When the quiz takes it, the poll that was on air leaves with a poll_hidden and the highlighted Q&A question leaves with a question_highlights_cleared.

Quiz events

All of them have the type field plus the payload fields at the same level, like the other events.

EventRecipientsPayloadDescription
quiz_stateWhole room if the quiz is on the stage screen; otherwise only controller and moderator{ server_time, audience_stage, quiz }The full quiz state, and the source of truth on reconnect. A null quiz means no quiz is on air.
quiz_question_openWhole room{ server_time, quiz_id, question }A question was fired. It never carries the answer key. opened_at and deadline are on the server clock.
quiz_answer_countWhole room if the quiz is on the stage screen; otherwise only controller and moderator{ quiz_id, question_id, count }How many answers have arrived. Batched: at most two messages per second per room.
quiz_question_closedWhole room if the quiz is on the stage screen; otherwise only controller and moderator{ server_time, quiz_id, question_id, correct_option, distribution, total_answers }The question closed. It is the only event that carries the answer key (correct_option), and only after the close.
quiz_rankingWhole room when the production takes the quiz to the ranking step; in the other cases only controller and moderator{ quiz_id, stage, total_participants, entries }Ranking of the top places. It is also sent to the production screens on every question close, even with the stage screen on another step.
quiz_finishedWhole room if the quiz is on the stage screen; otherwise only controller and moderator{ quiz_id, total_participants, entries }The quiz ended and the score is frozen. Carries the final ranking. The podium stays on the stage screen until the operator removes it.
quiz_hiddenWhole room{ audience_stage }The quiz left the stage screen. audience_stage is the state of the audience slot AFTER it left.
The answer key never travels before the close: no payload of an open question has the correct_option field. No quiz event carries a name, an e-mail or a device identifier — the label that shows up on public surfaces is display_label.

quiz, question and entries format

// quiz (the quiz field of quiz_state and of the room state)
{
  "id": "uuid", "title": "...",
  "status": "running",                  // draft | running | finished
  "stage": "question",                  // lobby | question | reveal | ranking | podium
  "question_index": 2, "question_count": 10, "participant_count": 48,
  "question": { ... },                  // only on the question step
  "reveal": { "question_id": "uuid", "correct_option": 1, "distribution": [3, 9, 1, 0], "total_answers": 13 },
  "ranking": [ ... ]                    // only on the ranking and podium steps
}

// question (no answer key)
{
  "id": "uuid", "index": 3, "total": 10,
  "text": "...", "options": ["...", "..."],
  "time_limit_ms": 30000,
  "opened_at": 1704067200000,           // epoch ms on the server clock
  "deadline": 1704067230000
}

// entries (also the entries field of quiz_ranking and quiz_finished)
[
  { "position": 1, "quiz_participant_id": "uuid", "display_label": "...", "score": 2840, "correct_time_sum_ms": 18230 }
]

// audience_stage: "none" | "poll" | "quiz"

Audience questions (Q&A)

The questions the audience sends use the same room WebSocket. There is no environment variable for Q&A: it is turned on in each room's settings (qa_enabled).

Only the URL with the room UUID produces an event
The question_* events are only emitted when the HTTP route that made the change used the room UUID in the path. The same call through the short code changes the question normally and answers with the same status, but no event is sent over the WebSocket. This is inherited behavior, kept on purpose; if your integration depends on the real-time notice, use the UUID.

Question events

All of them have the type field plus the payload fields at the same level, like the other events.

EventRecipientsPayloadDescription
question_createdWhole room{ question }A new question arrived from the audience. With moderation on it arrives with status pending: do not put it on the stage screen before a question_approved.
question_approvedWhole room{ question }The question was approved by moderation.
question_rejectedWhole room{ question }The question was rejected by moderation.
question_answeredWhole room{ question }The question was marked as answered.
question_highlightedWhole room{ question }The question was highlighted on the stage screen. Only one is highlighted at a time.
question_upvotedWhole room{ question }The question got a vote. A repeated vote from the same visitor produces no event.
question_deletedWhole room{ question_id }The question was deleted. It carries only the identifier.
question_highlight_clearedWhole room{ question_id }The highlight of that question was removed.
question_highlights_clearedWhole room{ }Every highlight was removed. No payload. It is also emitted when a poll or the quiz takes the audience slot of the stage screen.
Every screen in the room receives these events, the stage screen and the audience phone included, and the question_created payload carries the text and the author_name even with moderation on. Whoever displays it is responsible for filtering by status. The IP and the user agent of the person who asked never appear in the payload.

question format

// question
{
  "id": "uuid", "room_id": "uuid",
  "text": "...",
  "author_name": "...",                 // optional
  "status": "pending",                  // pending | approved | rejected | answered
  "highlighted": false,
  "upvotes": 7,
  "created_at": "2026-01-01T12:00:00Z",
  "reviewed_at": "2026-01-01T12:01:00Z", // optional
  "answered_at": "2026-01-01T12:05:00Z"  // optional
}

Enriched agenda

The enriched public agenda (speakers, tracks, locations and the event map) uses the same room WebSocket, so that the phone the audience has open reflects an edit made by the production without reloading the page.

Only exists with the enriched agenda turned on
Everything in this section is only sent when the enriched agenda is turned on in the server (the AGENDA_ENRICHED_ENABLED variable). With it off, no agenda_update is emitted and the room state does not carry the agenda field. Use room.capabilities.agenda_enriched to find out whether the feature is on.

New field in the room state

On the connections that draw the agenda (output=public_agenda and output=agenda), the room message gains the optional agenda field, with exactly the same content as agenda_update, and room.capabilities.agenda_enriched comes back true. On the other output types the frame is identical to the usual one. The public payload carries no private production notes, no room_id and no creation dates.

The agenda_update event

Emitted on every agenda edit made by the production: creating, editing or deleting a speaker, a location or the event map, and saving the public agenda block of a session. It carries the WHOLE agenda, not a delta: replace what you hold in memory with the content you receive.

EventRecipientsPayloadDescription
agenda_updateOnly the output=public_agenda and output=agenda connections{ agenda }The room agenda changed. The payload is the complete agenda, in the same format it came in on the first frame.
agenda_noticeOnly the output=public_agenda connections (the audience phone agenda), and only with ENABLE_WEB_PUSH on{ kind, timer_id?, title, body, texts?, created_at }An agenda notice: schedule change, session cancellation or a manual notice from the operator. It mirrors, on the screen of anyone with the agenda open, the push notification. It carries kind, title, body and created_at, plus timer_id when the notice is about a single session. In automatic notices, texts carries title and body per language (pt-BR and en). It never carries a participant name or identifier.
The stage screen (viewer), the OBS embed and the command screens do NOT receive this event: the agenda is weight they do not use, on a path that cannot slow down the live operation. If your integration needs the agenda, connect with output=agenda. A read failure on the server emits nothing — the edit was already saved and the next first frame carries the new agenda.

agenda fields

FieldDescription
agenda.speakersThe room's speaker catalog. An array, never null.
agenda.speakers[].idThe speaker identifier. It is the value used in sessions[].speaker_ids.
agenda.speakers[].nameDisplay name. Always present.
agenda.speakers[].title_companyRole and company. Absent when not filled in.
agenda.speakers[].bioShort biography. Absent when not filled in.
agenda.speakers[].photo_urlhttps URL of the photo. Absent when not filled in.
agenda.locationsThe event locations. An array, never null.
agenda.locations[].idThe location identifier. It is the value used in sessions[].location_id.
agenda.locations[].nameLocation name. Always present.
agenda.locations[].pin_xHorizontal position of the pin on the map, from 0 to 1, relative to the image. Absent when the location has no pin.
agenda.locations[].pin_yVertical position of the pin on the map, from 0 to 1, relative to the image. Absent when the location has no pin.
agenda.sessionsThe public agenda block of the sessions. An array, never null. It only carries a session with something filled in: an empty session does not show up.
agenda.sessions[].timer_idIdentifier of the timer the session corresponds to. It is the key to match the timers array of the room state.
agenda.sessions[].descriptionPublic description of the session. Absent when not filled in.
agenda.sessions[].trackTrack of the session. Absent when not filled in.
agenda.sessions[].location_idIdentifier of the session location, among those in locations. Absent when the session has no location.
agenda.sessions[].speaker_idsIdentifiers of the session speakers, among those in speakers. An array, never null; it may come empty.
agenda.event_mapThe event map, one per room. Absent when the room has no map.
agenda.event_map.image_urlhttps URL of the map image. The pins in locations are relative to it.
{
  "type": "agenda_update",
  "agenda": {
    "speakers": [
      {
        "id": "66666666-6666-4666-8666-666666666666",
        "name": "Ana Souza",
        "title_company": "CTO, Acme",       // optional
        "bio": "Fala de plataformas.",      // optional
        "photo_url": "https://cdn.exemplo/ana.jpg"  // optional
      }
    ],
    "locations": [
      {
        "id": "77777777-7777-4777-8777-777777777777",
        "name": "Auditorio",
        "pin_x": 0.25,                      // optional
        "pin_y": 0.75                       // optional
      }
    ],
    "sessions": [
      {
        "timer_id": "22222222-2222-4222-8222-222222222222",
        "description": "Como escalamos o palco.",   // optional
        "track": "Engenharia",                      // optional
        "location_id": "77777777-7777-4777-8777-777777777777",  // optional
        "speaker_ids": ["66666666-6666-4666-8666-666666666666"]
      }
    ],
    "event_map": { "image_url": "https://cdn.exemplo/planta.png" }  // optional
  }
}

A room with no agenda data

A room that never filled the agenda in — or that had everything deleted — receives the same event, with the three arrays empty and no event_map. This is the minimum payload:

{
  "type": "agenda_update",
  "agenda": {
    "speakers": [],
    "locations": [],
    "sessions": []
  }
}
The three arrays are always arrays: they never come null and never come absent. A client that iterates speakers, locations and sessions without checking does not break. Only event_map may be absent.

Control room and stage communication

The speaker, on a viewer screen on stage, can confirm they saw the message on air ("seen") and trigger predefined signals, such as "slide problem". The control room receives both in real time over the same room WebSocket.

Only exists with control room-stage communication on
Everything in this section depends, in each room, on settings.stage_signals.enabled (signals) and settings.stage_signals.ack_enabled (seen), both off by default. With both off, ack_message and send_signal are silently ignored and nothing new is emitted: no message_ack, signal or signal_update, and no message_acks or signals fields in the room state. Use room.capabilities.stage_signals to know whether the feature is available.

New fields in the room state

Only on controller and operator connections, and only with the feature on, the type room message gains two optional fields: message_acks (the "seen" confirmations of the current cycle of the message on air, only with ack_enabled) and signals (the signals of the last 24 hours, most recent first, only with enabled in the room). They are absent when empty. room.capabilities.stage_signals is true. No existing field changes; viewer, embed and agenda receive the usual frame.

{
  "type": "room",
  "room": { "capabilities": { "stage_signals": true } },
  "message_acks": [
    {
      "message_id": "aaaaaaaa-0000-4000-8000-00000000000a",
      "conn_id": "bbbbbbbb-0000-4000-8000-00000000000b",
      "device_name": "iPad Palco",
      "acked_at": "2026-10-08T20:52:03Z"
    }
  ],
  "signals": [
    {
      "id": "cccccccc-0000-4000-8000-00000000000c",
      "signal_key": "slide_issue",
      "label": "Problema no slide",
      "conn_id": "bbbbbbbb-0000-4000-8000-00000000000b",
      "device_name": "iPad Palco",
      "status": "new",
      "created_at": "2026-10-08T20:52:10Z"
    }
  ]
}

Event format

device_name is the name the device reported: at most 60 characters, with no control characters. It is user text: show it as text, never as HTML. conn_id is generated by the server. A signal status is new or acked; acked_at and acked_by_device only exist once handled.

// message_ack
{
  "type": "message_ack",
  "message_id": "aaaaaaaa-0000-4000-8000-00000000000a",
  "conn_id": "bbbbbbbb-0000-4000-8000-00000000000b",
  "device_name": "iPad Palco",
  "acked_at": "2026-10-08T20:52:03Z"
}

// signal
{
  "type": "signal",
  "signal": {
    "id": "cccccccc-0000-4000-8000-00000000000c",
    "signal_key": "slide_issue",
    "label": "Problema no slide",
    "conn_id": "bbbbbbbb-0000-4000-8000-00000000000b",
    "device_name": "iPad Palco",
    "status": "new",
    "created_at": "2026-10-08T20:52:10Z"
  }
}

// signal_update (handled by the control room)
{
  "type": "signal_update",
  "signal": {
    "id": "cccccccc-0000-4000-8000-00000000000c",
    "signal_key": "slide_issue",
    "label": "Problema no slide",
    "conn_id": "bbbbbbbb-0000-4000-8000-00000000000b",
    "device_name": "iPad Palco",
    "status": "acked",
    "created_at": "2026-10-08T20:52:10Z",
    "acked_at": "2026-10-08T20:52:40Z",          // only once handled
    "acked_by_device": "Régie Principal"         // only once handled
  }
}

Errors of this feature

These are type error frames sent ONLY to the connection that sent the signal, never to the room. Identify them by the code field.

// error (to the sender only)
{ "type": "error", "code": "signal_rate_limited", "retry_after_ms": 2100 }

{ "type": "error", "code": "signal_unknown_or_inactive" }

signal_rate_limited: the anti-spam refused the signal. retry_after_ms tells how many milliseconds remain before sending again.

signal_unknown_or_inactive: the key does not exist, is inactive, or signals are off in the room. It carries no retry_after_ms.

Complete Example

// JavaScript - Connect and control
const ws = new WebSocket('wss://app.palcotimer.com/ws/ABC123?output=controller');

ws.onopen = () => {
  console.log('Connected!');
  // Request initial state
  ws.send(JSON.stringify({ type: 'request_state' }));
};

ws.onmessage = (event) => {
  const data = JSON.parse(event.data);

  switch (data.type) {
    case 'room':
      console.log('Room state:', data);
      break;
    case 'playback_status':
      console.log('Playback:', data.state, data.elapsed_ms);
      break;
  }
};

// Control
function play() {
  ws.send(JSON.stringify({ type: 'control', action: 'play' }));
}

function pause() {
  ws.send(JSON.stringify({ type: 'control', action: 'pause' }));
}
The server sends a ping every 30 seconds. Respond with pong to keep the connection active.