Skip to main content

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
info

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
}
]
}
FieldTypeRequiredDescription
flagsarrayYesArray of flag items (min 1, max 20)
flags[].labelstringYesFlag label. Free-form, max 50 chars. Stored UPPERCASE.
flags[].detailobject | nullNoArbitrary metadata (max 1KB serialized)
flags[].question_idstring | nullNoUUID of the question being answered at flag time
flags[].occurred_atstring | nullNoClient-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 returns 400.
  • Examples: TAB_SWITCH, CLIPBOARD, SCREEN_SHARE, FOCUS_LOST, DEVTOOLS_OPEN

Limits

ConstraintValue
Max flags per request (batch)20
Max total flags per attempt300
Max detail size per flag1 KB
Grace window after submit30 seconds

Flags submitted more than 30 seconds after the attempt is submitted are rejected.


Response Codes

Success

CodeHTTPDescription
0000201Flags accepted

Errors

CodeHTTPDescription
VAL-001400Validation error (empty label, label too long)
AT-601400Reserved label prefix (OXIDE_)
AT-602400Batch too large (> 20 flags)
AT-604400Detail payload too large (> 1KB)
AT-404400Attempt not found
AT-405400Attempt already submitted (grace window expired)
AT-603429Flag cap exceeded for this attempt (> 300 total)
DS-000400/500Internal 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"
}
]
}
}
FieldTypeDescription
attempt_idstringThe flagged attempt UUID
quiz_idnumberQuiz ID
event_idstring | nullEvent stream ID if the attempt belongs to an event
flag_scorenumber | nullServer-computed suspicion score (securelib)
flagsarrayChronological list of flag events
flags[].idstringFlag event UUID
flags[].labelstringALL CAPS flag label (one per row)
flags[].detailobject | nullFull detail payload as submitted
flags[].question_idstring | nullQuestion being answered at flag time
flags[].occurred_atstring | nullClient-reported event time (untrusted)
flags[].created_atstringServer 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