Skip to main content

WebSocket Realtime API

The WebSocket endpoint provides live updates for quiz owners monitoring active attempts and for participants receiving notifications about their own attempt lifecycle.


Connecting

Owner Connection

Owners authenticate with a JWT access token or an API key with the owner role:

wss://<host>/ws?token=<jwt_or_api_key>

Participant Connection

Participants authenticate with their attempt session token:

wss://<host>/ws?session=<session_token>

Provide exactly one of the two query parameters. The credential is validated at connect time only — a reconnect after token expiry requires a fresh credential.

ScenarioStatusDescription
Missing both token and session401"Provide exactly one of 'token' or 'session' query parameter"
Invalid / expired JWT or API key401"Invalid or expired credential"
API key without owner role403"API key does not have owner role"
Invalid session token401"Invalid session"
Malformed query string400"Invalid query parameters"
tip

Browsers auto-respond to WebSocket pings, so you don't need to implement a pong handler manually.


Channels

ChannelFormatAuthSubscribe
Quizquiz:<quiz_id>?token= (owner)Explicit client message
Notificationnotification:<attempt_id>?session= (participant)Auto on connect
Attemptsattempts:<id>?token= (owner)Explicit client message
Leaderboardleaderboard:<id>?token= (owner)Explicit client message

quiz:<quiz_id> — Owner channel

  • Requires token authentication (JWT or owner-role API key).
  • Must explicitly subscribe via a client message.
  • Server validates quiz ownership per id at subscribe time (checked against DB, cached per session).
  • Partial-batch semantics: when subscribing to multiple channels in one message, the accepted subset is confirmed and denied channels are returned as errors.

notification:<attempt_id> — Participant channel

  • Requires session authentication (attempt session token).
  • Auto-subscribed at connect — no client subscribe message needed.
  • The server derives the attempt_id from the session token, so a participant can never listen to another attempt.
  • Listen-only: any client text frame (except WebSocket pong) triggers an error response: "Subscriptions are managed by the server for this session".

Client Messages

Owners send JSON frames to subscribe or unsubscribe from channels:

Subscribe:

{
"action": "subscribe",
"subscriptions": ["quiz:42", "quiz:99"]
}

Unsubscribe:

{
"action": "unsubscribe",
"subscriptions": ["quiz:42"]
}

The subscriptions array uses the format "<channel_type>:<id>". Valid channel types: quiz, notification, attempts, leaderboard.

Participant connections are listen-only and must not send subscribe/unsubscribe frames.


Server Envelope

Every server message follows this structure:

{
"event": "update",
"t": "attempt_quiz",
"channel": "quiz",
"data": {
"attempt_id": "019364ab-7c12-7def-8000-abcdef123456",
"participant_id": "01936400-bbbb-7def-8000-000000000002",
"alias": "Alice",
"quiz_id": 42,
"event_id": null,
"started_at": "2026-06-11T14:30:00+00:00"
}
}
FieldTypeDescription
eventstringOne of: update, subscribed, unsubscribed, snapshot, error
tstring | nullEvent type slug (present when event is update)
channelstring | nullChannel type: "quiz", "notification", "attempts", or "leaderboard"
subscriptionsstring[] | nullPresent on subscribed/unsubscribed confirmations
dataobject | nullEvent payload (present when event is update)
snapshotarray | nullFull state snapshot
errorstring | nullError description (when event is error)
info

The channel field contains only the channel type (e.g. "quiz"), not the full subscription key (e.g. NOT "quiz:42"). Use the data.quiz_id or data.attempt_id inside the payload to distinguish sources when subscribed to multiple channels of the same type.

Confirmation Messages

On successful subscribe, the server sends:

{
"event": "subscribed",
"t": null,
"subscriptions": ["quiz:42", "quiz:99"]
}

On unsubscribe:

{
"event": "unsubscribed",
"t": null,
"subscriptions": ["quiz:42"]
}

On error (e.g. subscribing to a quiz you don't own):

{
"event": "error",
"t": null,
"error": "Subscription denied: quiz:99: not found or not owned"
}

Event Types — Quiz Channel

All quiz-channel payloads include event_id: string | null to support event-stream filtering client-side (the channel remains keyed by quiz_id).

attempt_quiz

Fired when a participant starts a new attempt.

{
"event": "update",
"t": "attempt_quiz",
"channel": "quiz",
"data": {
"attempt_id": "019364ab-7c12-7def-8000-abcdef123456",
"participant_id": "01936400-bbbb-7def-8000-000000000002",
"alias": "Alice",
"quiz_id": 42,
"event_id": "01936400-cccc-7def-8000-000000000003",
"started_at": "2026-06-11T14:30:00+00:00"
}
}
FieldTypeDescription
attempt_idstringUUID of the new attempt
participant_idstringUUID of the participant
aliasstringParticipant display name
quiz_idnumberQuiz ID
event_idstring | nullEvent stream ID if applicable
started_atstringISO 8601 timestamp

attempt_update

Fired when a participant submits an answer.

{
"event": "update",
"t": "attempt_update",
"channel": "quiz",
"data": {
"attempt_id": "019364ab-7c12-7def-8000-abcdef123456",
"participant_id": "01936400-bbbb-7def-8000-000000000002",
"alias": "Alice",
"quiz_id": 42,
"event_id": null,
"question_id": "550e8400-e29b-41d4-a716-446655440000",
"answer_id": "660e8400-e29b-41d4-a716-446655440099",
"is_correct": true,
"score": 10.0,
"answered_at": "2026-06-11T14:31:05+00:00",
"answered_count": 3,
"question_order": 2
}
}
FieldTypeDescription
attempt_idstringAttempt UUID
participant_idstringParticipant UUID
aliasstringParticipant display name
quiz_idnumberQuiz ID
event_idstring | nullEvent stream ID if applicable
question_idstringQuestion UUID answered
answer_idstringAnswer record UUID
is_correctbooleanAuto-grade result
scorenumberPoints awarded
answered_atstringISO 8601 timestamp
answered_countnumberTotal answers submitted so far
question_ordernumber1-based position in quiz

attempt_finished

Fired when an attempt is submitted (manually or by time expiry).

{
"event": "update",
"t": "attempt_finished",
"channel": "quiz",
"data": {
"attempt_id": "019364ab-7c12-7def-8000-abcdef123456",
"participant_id": "01936400-bbbb-7def-8000-000000000002",
"alias": "Alice",
"quiz_id": 42,
"event_id": null,
"answered_count": 10,
"time_taken_ms": 182400
}
}
FieldTypeDescription
attempt_idstringAttempt UUID
participant_idstringParticipant UUID
aliasstringParticipant display name
quiz_idnumberQuiz ID
event_idstring | nullEvent stream ID if applicable
answered_countnumberTotal answers submitted
time_taken_msnumberTime taken in milliseconds

attempt_flagged

Fired when the anti-cheat system accepts flags for a participant. One message per accepted batch; labels are deduped and ALL CAPS.

{
"event": "update",
"t": "attempt_flagged",
"channel": "quiz",
"data": {
"attempt_id": "019364ab-7c12-7def-8000-abcdef123456",
"participant_id": "01936400-bbbb-7def-8000-000000000002",
"alias": "Alice",
"quiz_id": 42,
"event_id": null,
"labels": ["TAB_SWITCH", "CLIPBOARD"],
"flagged_at": "2026-06-11T14:32:00+00:00"
}
}
FieldTypeDescription
attempt_idstringAttempt UUID
participant_idstringParticipant UUID
aliasstringParticipant display name
quiz_idnumberQuiz ID
event_idstring | nullEvent stream ID if applicable
labelsstring[]Flag labels (deduped, ALL CAPS)
flagged_atstringISO 8601 timestamp of the batch
note

The detail payload for each flag is not broadcast over WebSocket. Use the REST flag timeline endpoint to retrieve full detail objects.


Event Types — Notification Channel

attempt_closed

Sent to the participant when their attempt is closed by the system (manual submit, time expiry, or force-close by owner).

{
"event": "update",
"t": "attempt_closed",
"channel": "notification",
"data": {
"attempt_id": "019364ab-7c12-7def-8000-abcdef123456",
"quiz_id": 42,
"event_id": null,
"reason": "time_up",
"score": 75.0,
"answered_count": 8,
"submitted_at": "2026-06-11T15:00:00+00:00"
}
}
FieldTypeDescription
attempt_idstringAttempt UUID
quiz_idnumberQuiz ID
event_idstring | nullEvent stream ID if applicable
reasonstringOne of: submitted, time_up, forced_by_owner
scorenumberFinal score at close time
answered_countnumberTotal answers submitted
submitted_atstringISO 8601 timestamp of submission

Complete Connection Examples

JavaScript — Owner (Browser)

const token = "eyJhbGciOiJIUzI1NiIs..."; // JWT access token or API key
const ws = new WebSocket(`wss://example.com/ws?token=${token}`);

ws.onopen = () => {
console.log("Connected as owner");

// Subscribe to quiz channels
ws.send(JSON.stringify({
action: "subscribe",
subscriptions: ["quiz:42", "quiz:99"]
}));
};

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

switch (msg.event) {
case "subscribed":
console.log("Subscribed to:", msg.subscriptions);
break;

case "error":
console.error("WS error:", msg.error);
break;

case "update":
switch (msg.t) {
case "attempt_quiz":
console.log(`${msg.data.alias} started attempt ${msg.data.attempt_id}`);
break;
case "attempt_update":
console.log(`${msg.data.alias} answered Q${msg.data.question_order}: ${msg.data.is_correct ? "✓" : "✗"}`);
break;
case "attempt_finished":
console.log(`${msg.data.alias} finished in ${msg.data.time_taken_ms}ms`);
break;
case "attempt_flagged":
console.warn(`${msg.data.alias} flagged: ${msg.data.labels.join(", ")}`);
break;
}
break;
}
};

ws.onclose = (event) => {
console.log("Disconnected:", event.code, event.reason);
// Implement reconnection with exponential backoff
};

JavaScript — Participant (Browser)

const sessionToken = "abc123-session-token";
const ws = new WebSocket(`wss://example.com/ws?session=${sessionToken}`);

ws.onopen = () => {
// No subscribe needed — auto-subscribed to notification:<attempt_id>
console.log("Connected as participant");
};

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

if (msg.event === "subscribed") {
console.log("Auto-subscribed to:", msg.subscriptions);
return;
}

if (msg.event === "update" && msg.t === "attempt_closed") {
const { reason, score, answered_count, submitted_at } = msg.data;

switch (reason) {
case "time_up":
alert("Time's up! Your quiz has been submitted automatically.");
break;
case "forced_by_owner":
alert("Your quiz has been closed by the instructor.");
break;
case "submitted":
// Normal submit confirmation
break;
}

console.log(`Final score: ${score}, Answered: ${answered_count}`);
}
};

// Do NOT send any frames — participant connections are listen-only

Heartbeat & Connection Health

  • The server sends WebSocket ping frames every 5 seconds.
  • If no pong is received within 10 seconds, the connection is terminated.
  • Browser WebSocket implementations respond to pings automatically.

Reconnection Guidance

The server does not persist subscriptions or replay missed messages across disconnects.

Recommended strategy:

  1. On disconnect, use exponential backoff (e.g. 1s, 2s, 4s, 8s, 16s — max 5 attempts).
  2. After reconnecting, re-send your subscribe message for all channels.
  3. If the connection is rejected with 401, refresh your credential (JWT or API key) before retrying.
  4. If using a session token and it's expired (attempt already submitted), do not reconnect.

See Also