Anti-Cheat Flags
The anti-cheat flag system allows proctoring clients to report suspicious behavior during quiz attempts. Flags are stored server-side and surfaced to quiz owners in realtime and via REST queries.
Submitting Flags
Proctoring clients (native apps, browser extensions, or the proctor module) submit flags via the REST endpoint.
Request:
POST /api/v1/attempts/{session_token}/flags
Headers:
Content-Type: application/json
No Authorization header is needed — the session token in the URL path authenticates the request.
Body:
{
"flags": [
{
"label": "TAB_SWITCH",
"detail": {
"window_title": "Chrome - Google Search",
"duration_ms": 3200
},
"question_id": "550e8400-e29b-41d4-a716-446655440000",
"occurred_at": "2026-06-11T14:30:00Z"
},
{
"label": "CLIPBOARD",
"detail": null,
"question_id": null,
"occurred_at": null
}
]
}
| Field | Type | Required | Description |
|---|---|---|---|
flags | array | Yes | Array of flag items (min 1, max 20) |
flags[].label | string | Yes | Flag label. Free-form, max 50 chars. Stored UPPERCASE. |
flags[].detail | object | null | No | Arbitrary metadata (max 1KB serialized) |
flags[].question_id | string | null | No | UUID of the question being answered at flag time |
flags[].occurred_at | string | null | No | Client-reported event time (ISO 8601, untrusted) |
Label Rules
- Labels are free-form strings, max 50 characters.
- Stored and returned in UPPERCASE regardless of input case.
- The prefix
OXIDE_is reserved for system-generated flags. Submitting a label with this prefix returns400. - Examples:
TAB_SWITCH,CLIPBOARD,SCREEN_SHARE,FOCUS_LOST,DEVTOOLS_OPEN
Limits
| Constraint | Value |
|---|---|
| Max flags per request (batch) | 20 |
| Max total flags per attempt | 300 |
| Max detail size per flag | 1 KB |
| Grace window after submit | 30 seconds |
Flags submitted more than 30 seconds after the attempt is submitted are rejected.
Response Codes
Success
| Code | HTTP | Description |
|---|---|---|
0000 | 201 | Flags accepted |
Errors
| Code | HTTP | Description |
|---|---|---|
VAL-001 | 400 | Validation error (empty label, label too long) |
AT-601 | 400 | Reserved label prefix (OXIDE_) |
AT-602 | 400 | Batch too large (> 20 flags) |
AT-604 | 400 | Detail payload too large (> 1KB) |
AT-404 | 400 | Attempt not found |
AT-405 | 400 | Attempt already submitted (grace window expired) |
AT-603 | 429 | Flag cap exceeded for this attempt (> 300 total) |
DS-000 | 400/500 | Internal database error |
Success response (201):
{
"code": "0000",
"message": "flags accepted",
"data": {
"accepted": 2
}
}
Error response (400 — validation):
{
"code": "VAL-001",
"message": "flags[0].label: label must not be empty",
"data": null
}
Error response (400 — reserved prefix):
{
"code": "AT-601",
"message": "flags[0].label: reserved label prefix OXIDE_",
"data": null
}
Error response (429 — cap exceeded):
{
"code": "AT-603",
"message": "attempt has 295 flags; adding 10 would exceed cap of 300",
"data": null
}
Realtime Event
When flags are accepted, the server broadcasts an attempt_flagged event to all owners subscribed to the quiz:<quiz_id> channel via WebSocket.
The realtime payload contains only labels and metadata — the full detail object is not broadcast for bandwidth reasons. Owners retrieve details via the timeline endpoint below.
Flag Timeline
Retrieve the chronological list of all flags for an attempt.
Request:
GET /api/v1/info/attempts/{attempt_id}/flags
Authorization: Bearer <owner_jwt_or_api_key>
Response:
{
"code": "0000",
"message": "ok",
"data": {
"attempt_id": "01936400-aaaa-7def-8000-000000000001",
"quiz_id": 448,
"event_id": null,
"flag_score": 12.5,
"flags": [
{
"id": "019364ab-7c12-7def-8000-abcdef123456",
"label": "TAB_SWITCH",
"detail": {
"window_title": "Chrome - Google Search",
"duration_ms": 3200
},
"question_id": "550e8400-e29b-41d4-a716-446655440000",
"occurred_at": "2026-06-11T14:30:00Z",
"created_at": "2026-06-11T14:30:01Z"
}
]
}
}
| Field | Type | Description |
|---|---|---|
attempt_id | string | The flagged attempt UUID |
quiz_id | number | Quiz ID |
event_id | string | null | Event stream ID if the attempt belongs to an event |
flag_score | number | null | Server-computed suspicion score (securelib) |
flags | array | Chronological list of flag events |
flags[].id | string | Flag event UUID |
flags[].label | string | ALL CAPS flag label (one per row) |
flags[].detail | object | null | Full detail payload as submitted |
flags[].question_id | string | null | Question being answered at flag time |
flags[].occurred_at | string | null | Client-reported event time (untrusted) |
flags[].created_at | string | Server timestamp (authoritative) |
Quiz Flag Summary
Get aggregate flag statistics for a quiz.
Request:
GET /api/v1/info/quizezz/{quiz_id}/flags/summary
Authorization: Bearer <owner_jwt_or_api_key>
Response:
{
"code": "0000",
"message": "ok",
"data": {
"quiz_id": 448,
"event_id": null,
"counts_by_label": [
{ "label": "TAB_SWITCH", "count": 7 },
{ "label": "CLIPBOARD", "count": 5 }
],
"top_flagged": [
{
"attempt_id": "01936400-aaaa-7def-8000-000000000001",
"participant_alias": "John D.",
"flag_count": 5,
"distinct_labels": 3,
"last_flag_at": "2026-06-11T14:35:00Z"
}
]
}
}
Filtering Attempts by Flag Status
The attempts list endpoint accepts an isFlagged query parameter:
GET /api/v1/info/attempts?quizId=42&isFlagged=true
When isFlagged=true, only attempts that have at least one accepted flag are returned. This filter composes with all other existing filters (eventId, participantAlias, isFullyEvaluated, pagination).
See Also
- WebSocket Realtime API — Live event stream including
attempt_flagged - Managing Active Attempts — Attempt lifecycle and force-submit