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.
| Scenario | Status | Description |
|---|---|---|
Missing both token and session | 401 | "Provide exactly one of 'token' or 'session' query parameter" |
| Invalid / expired JWT or API key | 401 | "Invalid or expired credential" |
API key without owner role | 403 | "API key does not have owner role" |
| Invalid session token | 401 | "Invalid session" |
| Malformed query string | 400 | "Invalid query parameters" |
Browsers auto-respond to WebSocket pings, so you don't need to implement a pong handler manually.
Channels
| Channel | Format | Auth | Subscribe |
|---|---|---|---|
| Quiz | quiz:<quiz_id> | ?token= (owner) | Explicit client message |
| Notification | notification:<attempt_id> | ?session= (participant) | Auto on connect |
| Attempts | attempts:<id> | ?token= (owner) | Explicit client message |
| Leaderboard | leaderboard:<id> | ?token= (owner) | Explicit client message |
quiz:<quiz_id> — Owner channel
- Requires
tokenauthentication (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
sessionauthentication (attempt session token). - Auto-subscribed at connect — no client subscribe message needed.
- The server derives the
attempt_idfrom 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"
}
}
| Field | Type | Description |
|---|---|---|
event | string | One of: update, subscribed, unsubscribed, snapshot, error |
t | string | null | Event type slug (present when event is update) |
channel | string | null | Channel type: "quiz", "notification", "attempts", or "leaderboard" |
subscriptions | string[] | null | Present on subscribed/unsubscribed confirmations |
data | object | null | Event payload (present when event is update) |
snapshot | array | null | Full state snapshot |
error | string | null | Error description (when event is error) |
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"
}
}
| Field | Type | Description |
|---|---|---|
attempt_id | string | UUID of the new attempt |
participant_id | string | UUID of the participant |
alias | string | Participant display name |
quiz_id | number | Quiz ID |
event_id | string | null | Event stream ID if applicable |
started_at | string | ISO 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
}
}
| Field | Type | Description |
|---|---|---|
attempt_id | string | Attempt UUID |
participant_id | string | Participant UUID |
alias | string | Participant display name |
quiz_id | number | Quiz ID |
event_id | string | null | Event stream ID if applicable |
question_id | string | Question UUID answered |
answer_id | string | Answer record UUID |
is_correct | boolean | Auto-grade result |
score | number | Points awarded |
answered_at | string | ISO 8601 timestamp |
answered_count | number | Total answers submitted so far |
question_order | number | 1-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
}
}
| Field | Type | Description |
|---|---|---|
attempt_id | string | Attempt UUID |
participant_id | string | Participant UUID |
alias | string | Participant display name |
quiz_id | number | Quiz ID |
event_id | string | null | Event stream ID if applicable |
answered_count | number | Total answers submitted |
time_taken_ms | number | Time 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"
}
}
| Field | Type | Description |
|---|---|---|
attempt_id | string | Attempt UUID |
participant_id | string | Participant UUID |
alias | string | Participant display name |
quiz_id | number | Quiz ID |
event_id | string | null | Event stream ID if applicable |
labels | string[] | Flag labels (deduped, ALL CAPS) |
flagged_at | string | ISO 8601 timestamp of the batch |
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"
}
}
| Field | Type | Description |
|---|---|---|
attempt_id | string | Attempt UUID |
quiz_id | number | Quiz ID |
event_id | string | null | Event stream ID if applicable |
reason | string | One of: submitted, time_up, forced_by_owner |
score | number | Final score at close time |
answered_count | number | Total answers submitted |
submitted_at | string | ISO 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:
- On disconnect, use exponential backoff (e.g. 1s, 2s, 4s, 8s, 16s — max 5 attempts).
- After reconnecting, re-send your
subscribemessage for all channels. - If the connection is rejected with
401, refresh your credential (JWT or API key) before retrying. - If using a session token and it's expired (attempt already submitted), do not reconnect.
See Also
- Anti-Cheat Flags — REST endpoints for submitting and querying flags
- Managing Active Attempts — Force-submit and lifecycle details