ReactionLinkPublic Poll WebSocket

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.

SettingValue
Namespace/v1/reactionlink/public-polls
Socket.IO handshake path/socket.io relative to the configured API base URL
Transportwebsocket only
AuthenticationPublic; 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.io

Use 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

DirectionEventPurpose
Client to serverrl-public-poll:subscribeSelect one campaign and optionally specific polls.
Server to clientrl-public-poll:stateDeliver the complete current active-poll snapshot.
Client to serverrl-public-poll:submitSubmit one answer for one poll.
Server to clientrl-public-poll:errorReport 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

campaignId
string
required

Public Scanly campaign ID. This is the frontend-facing campaign identifier, not the internal ReactionLink session ID.

pollIds
Array<string>

Optional whitelist of poll IDs. Omit it, pass undefined, or send an empty array to subscribe to all active polls of the campaign.

pollSessionId
string

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 pollIds subscribes 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 pollSessionId rejects 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 ok first and the backend may later emit rl-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

campaignId
string
required

The public Scanly campaign ID for which this snapshot was produced.

polls
Array<PublicPollSocketStatePoll>
required

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

status
'active'
required

Current stream status of the poll. Only active polls are emitted right now, so the value is always active.

visible
boolean
required

Whether the frontend should currently render the poll. false means the poll stays part of the viewer state because conditions may reveal it later.

poll
PublicChoicePollInput | PublicSliderPollInput
required

The normalized poll configuration as stored in ReactionLink. This contains the render data for the poll itself.

session
PublicPollSocketSession
required

Viewer-specific session data used for submit validation. The same session object is repeated on every poll of one snapshot.

polls[*].session Attributes

pollSessionId
string
required

Opaque viewer session token that must be echoed back in rl-public-poll:submit. Treat it like a secret and do not log it.

earliestSubmitAt
string
required

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: false items 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 polls array.

rl-public-poll:submit

Direction: client to server

Purpose: submits one answer for one poll in the current campaign viewer session.

Payload Attributes

campaignId
string
required

Public Scanly campaign ID. It must match the campaign used when the session token was issued.

pollId
string
required

Poll ID of the poll being answered.

pollSessionId
string
required

Viewer session token received in the latest rl-public-poll:state payload.

answer
SubmitChoicePollAnswer | SubmitSliderPollAnswer
required

The actual answer payload. Its structure depends on the poll type.

answer Attributes For Choice Polls

type
'CHOICE'
required

Identifies the answer payload as a choice submission.

choiceIds
Array<string>
required

Selected choice IDs. Use the choice IDs from poll.answerOptions.choices[*].id, never the display labels.

answer Attributes For Slider Polls

type
'SLIDER'
required

Identifies the answer payload as a slider submission.

value
number
required

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 earliestSubmitAt before 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

message
string
required

Human-readable error message. Suitable for logs and debugging, but not guaranteed to be stable for application logic.

statusCode
number
required

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.
  1. Connect the socket.
  2. Register state and error listeners.
  3. Emit rl-public-poll:subscribe on every connect, including the stored pollSessionId when one exists.
  4. Replace local poll state whenever rl-public-poll:state arrives.
  5. Keep the latest pollSessionId in sessionStorage and reuse it when the page reconnects or refreshes.
  6. Submit answers through rl-public-poll:submit.
  7. 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.