Public Poll WebSocket
This document describes how browser and test clients connect to Scanly’s public ReactionLink poll stream, which events they send, and which events they receive.
Protocol And Address
The endpoint uses Socket.IO 4.x over WebSocket. It is not a plain JSON WebSocket endpoint.
| Setting | Value |
|---|---|
| Namespace | /v1/reactionlink/public-polls |
| Socket.IO handshake path | /socket.io relative to the configured API base URL |
| Transport | websocket only |
| Authentication | Public; no user login is required |
Deployments may proxy the API below a prefix such as /api. Build both the
namespace URL and handshake path from the application’s configured API base URL
instead of hard-coding the production host or prefix.
For a direct local backend on port 3334:
Namespace URL: http://127.0.0.1:3334/v1/reactionlink/public-polls
Handshake path: /socket.ioUse http:// or https:// with socket.io-client; the library performs the
WebSocket upgrade. Raw ws://.../socket.io?EIO=4&transport=websocket URLs are
only needed for low-level tools that do not speak Socket.IO themselves.
Client Setup
The recommended client is socket.io-client with the same major version as the
backend.
import { io } from 'socket.io-client';
const apiBaseUrl = new URL('http://127.0.0.1:3334');
const namespaceUrl = new URL(
'v1/reactionlink/public-polls',
`${apiBaseUrl.toString().replace(/\/$/, '')}/`
).toString();
const socket = io(namespaceUrl, {
path: `${apiBaseUrl.pathname.replace(/\/$/, '')}/socket.io`,
transports: ['websocket'],
reconnection: true,
});Register event handlers before connecting when autoConnect: false is used.
On every connect, emit rl-public-poll:subscribe again because a reconnect
creates a new server-side socket subscription.
Event Overview
| Direction | Event | Purpose |
|---|---|---|
| Client to server | rl-public-poll:subscribe | Select one campaign and optionally specific polls. |
| Server to client | rl-public-poll:state | Deliver the complete current active-poll snapshot. |
| Client to server | rl-public-poll:submit | Submit one answer for one poll. |
| Server to client | rl-public-poll:error | Report stream setup or asynchronous Firestore errors. |
Socket.IO acknowledgements are separate from emitted server events. Both client
events accept an acknowledgement callback returning { ok: boolean }.
rl-public-poll:subscribe
Direction: client to server
Purpose: selects one campaign and optionally narrows the stream to specific poll IDs.
Payload Attributes
Public Scanly campaign ID. This is the frontend-facing campaign identifier, not the internal ReactionLink session ID.
Optional whitelist of poll IDs. Omit it, pass undefined, or send an empty array to subscribe to all active polls of the campaign.
Previously issued session ID from a state event. Send it again after reconnecting or refreshing so submitted answers and conditional progress remain bound to the same viewer. Omit it only for a new browser session.
Acknowledgement
type PublicPollSocketAck = {
ok: boolean;
};{ ok: true } only confirms that the backend accepted the subscription request
and registered the listeners. The first rl-public-poll:state event still
arrives asynchronously after Firestore has delivered the required poll and
dependency snapshots.
Example
socket.emit(
'rl-public-poll:subscribe',
{
campaignId: 'lpbz45utjs',
pollIds: ['bapwdEqxXmlUvkJtEpMl'],
pollSessionId: sessionStorage.getItem('rlPollSessionId') ?? undefined,
},
(ack: { ok: boolean }) => {
if (!ack.ok) {
// The request was invalid or listener setup failed.
}
}
);Notes
- Omitting
pollIdssubscribes to all campaign polls. - Duplicate and whitespace-only poll IDs are normalized away.
- At most 100 poll IDs are accepted.
- Campaign and poll IDs must be at most 128 characters.
- A new subscribe call on the same socket replaces the previous subscription.
- An unknown, expired, cross-campaign, or poll-specific
pollSessionIdrejects the subscription; clear it and retry without one only when starting a new browser session. - If a requested poll ID does not exist, the subscribe ack can still be
okfirst and the backend may later emitrl-public-poll:error.
rl-public-poll:state
Direction: server to client
Purpose: sends the complete active snapshot for the current campaign subscription, excluding polls already answered by this session. This is a full replacement payload, not a patch.
Payload Attributes
The public Scanly campaign ID for which this snapshot was produced.
Ordered list of currently active polls for this viewer session. The array is already filtered to active polls, but can still contain entries with visible: false.
polls[*] Attributes
Current stream status of the poll. Only active polls are emitted right now, so the value is always active.
Whether the frontend should currently render the poll. false means the poll stays part of the viewer state because conditions may reveal it later.
The normalized poll configuration as stored in ReactionLink. This contains the render data for the poll itself.
Viewer-specific session data used for submit validation. The same session object is repeated on every poll of one snapshot.
polls[*].session Attributes
Opaque viewer session token that must be echoed back in rl-public-poll:submit. Treat it like a secret and do not log it.
ISO timestamp after which the viewer may submit an answer. The backend uses this to enforce the minimum wait time after load.
Example
socket.on('rl-public-poll:state', (state: PublicPollSocketState) => {
setPolls(state.polls);
});{
"campaignId": "lpbz45utjs",
"polls": [
{
"status": "active",
"visible": true,
"poll": {
"id": "bapwdEqxXmlUvkJtEpMl",
"title": "How was it?",
"bottomText": null,
"subTitle": null,
"type": "CHOICE",
"answerOptions": {
"choiceAmount": 1,
"choices": [
{ "id": "0", "value": "Good" },
{ "id": "2", "value": "Very good" }
]
}
},
"session": {
"pollSessionId": "95ee816b-89e7-4af1-ad95-cd7f277851ba",
"earliestSubmitAt": "2026-07-07T09:59:45.936Z"
}
}
]
}Notes
- Replace the previous poll array whenever a new state arrives.
- Preserve the backend-provided order.
- Render only polls with
visible: true. - Keep
visible: falseitems in local state because user answers may make them visible later. - Paused or closed polls disappear from the next snapshot instead of changing status in place.
- An unavailable campaign emits an empty
pollsarray.
rl-public-poll:submit
Direction: client to server
Purpose: submits one answer for one poll in the current campaign viewer session.
Payload Attributes
Public Scanly campaign ID. It must match the campaign used when the session token was issued.
Poll ID of the poll being answered.
Viewer session token received in the latest rl-public-poll:state payload.
The actual answer payload. Its structure depends on the poll type.
answer Attributes For Choice Polls
Identifies the answer payload as a choice submission.
Selected choice IDs. Use the choice IDs from poll.answerOptions.choices[*].id, never the display labels.
answer Attributes For Slider Polls
Identifies the answer payload as a slider submission.
Numeric slider value. It must respect the configured min, max, and step values of the poll.
Acknowledgement
type PublicPollSocketAck = {
ok: boolean;
};{ ok: true } means the backend accepted the submission and completed the
transaction. The next visible effect is usually a fresh rl-public-poll:state
event because the answer may change conditions or aggregates.
Example
socket.emit(
'rl-public-poll:submit',
{
campaignId: 'lpbz45utjs',
pollId: 'bapwdEqxXmlUvkJtEpMl',
pollSessionId: session.pollSessionId,
answer: {
type: 'CHOICE',
choiceIds: ['0'],
},
},
(ack: { ok: boolean }) => {
if (!ack.ok) {
// Keep current UI state and show a retry or rejection message.
}
}
);Notes
- Wait until
earliestSubmitAtbefore sending the submit event. - Choice answers must contain unique IDs that exist in the poll config.
- Choice answer count must not exceed
answerOptions.choiceAmount. - Slider values must be inside min and max and aligned to the configured step.
- The backend verifies that the session belongs to the campaign, that the minimum wait time passed, that the poll still accepts answers, and that this session has not already answered the poll.
- A successful submit updates both the poll result documents and the campaign-wide answer document used by conditions.
rl-public-poll:error
Direction: server to client
Purpose: reports asynchronous stream problems after the socket connection itself was already established.
Payload Attributes
Human-readable error message. Suitable for logs and debugging, but not guaranteed to be stable for application logic.
HTTP-like status code describing the failure category, for example 400, 404, or 500.
Example
socket.on('rl-public-poll:error', (error: PublicPollSocketError) => {
console.error(error.statusCode, error.message);
});Notes
- This event is used for delayed failures such as missing requested polls or Firestore listener errors.
- Subscribe and submit acknowledgements only cover the immediate request/response envelope. They do not replace this error channel.
Recommended Frontend Flow
- Connect the socket.
- Register
stateanderrorlisteners. - Emit
rl-public-poll:subscribeon everyconnect, including the storedpollSessionIdwhen one exists. - Replace local poll state whenever
rl-public-poll:statearrives. - Keep the latest
pollSessionIdinsessionStorageand reuse it when the page reconnects or refreshes. - Submit answers through
rl-public-poll:submit. - Expect the backend to push a fresh full state afterwards.
The frontend should treat rl-public-poll:state as the single source of
truth. Do not try to locally predict future poll visibility after submit;
wait for the next backend snapshot.