Use webhooks for live evidence and completion summaries. Use the REST read endpoints as a pull fallback.
| Event | When it sends | Payload summary |
|---|---|---|
violation.detected |
After a violation is saved | One violation and the Endpoint session UUID. |
recording.uploaded |
After a recording uploads to your S3 bucket | One partner_s3 recording. |
exam.completed |
After the session reaches completed and no recording upload is pending |
Session summary, all violations, deliverable recordings, and upload errors if any. |
To receive events, configure a webhook URL and webhook secret on your tenant. Event filtering is tenant-level; an empty event list means all events are enabled.
POST <your webhook URL>
Content-Type: application/json
X-Webhook-Signature: <64 hex chars>
X-Webhook-Timestamp: <unix seconds>
User-Agent: EndpointProctoring/1.0
Any 2xx response is success. Current delivery does not guarantee redelivery for all failure types. Return 2xx quickly, store the signed payload or event_id, and process asynchronously.
Every payload has a top-level event_id. Use it as your deduplication key.
Endpoint signs the exact JSON body it sends with:
HMAC-SHA256(webhook_secret, json_body)
The header value is lower-case hex with no sha256= or v1= prefix. Verify against the raw request body bytes before parsing JSON. The timestamp header is not part of the signed value; use it only for your own replay window.
Ruby example:
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, request.raw_post)
valid = ActiveSupport::SecurityUtils.secure_compare(expected, request.headers["X-Webhook-Signature"].to_s)
Node example:
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const valid = Buffer.byteLength(expected) === Buffer.byteLength(signature || '') &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature || ''));
violation.detected{
"event": "violation.detected",
"event_id": "5c3e1f8a-2b6d-57e2-9f1a-6d0c2e7b4a91",
"timestamp": "2026-09-10T14:41:03Z",
"session_id": "9d2b6c31-1f45-4a80-b7de-5c1e0a3f9b22",
"violation": {
"id": 4412,
"type": "tab_switch",
"severity": "medium",
"description": "Switched away from the exam tab",
"occurred_at": "2026-09-10T14:41:03Z",
"auto_detected": true
}
}
recording.uploaded{
"event": "recording.uploaded",
"event_id": "9b1deb4d-3b7d-5bad-9bdd-2b0d7b3dcb6d",
"timestamp": "2026-09-10T15:30:44Z",
"session_id": "9d2b6c31-1f45-4a80-b7de-5c1e0a3f9b22",
"recording": {
"id": 771,
"type": "camera",
"url": "s3://your-bucket/proctoring/9d2b6c31/camera.webm",
"storage_provider": "partner_s3",
"duration_seconds": 5189,
"file_size_bytes": 104857600,
"uploaded_at": "2026-09-10T15:30:44Z"
}
}
exam.completed{
"event": "exam.completed",
"event_id": "0a128f02-a18b-5558-9df7-058104ed5272",
"timestamp": "2026-09-10T15:31:12Z",
"session": {
"id": "9d2b6c31-1f45-4a80-b7de-5c1e0a3f9b22",
"exam_id": "exam-204",
"reservation_uuid": "3f6a1d92-7c40-4b1e-9a55-0e2f8b4c1d77",
"candidate_email": "ada@example.com",
"candidate_name": "Ada Lovelace",
"exam_name": "Network Security Final",
"course_name": "Network Security",
"started_at": "2026-09-10T14:02:11Z",
"ended_at": "2026-09-10T15:28:40Z",
"duration_minutes": 86,
"compliance_score": 90
},
"violations": [
{
"id": 4412,
"type": "tab_switch",
"severity": "medium",
"description": "Switched away from the exam tab",
"occurred_at": "2026-09-10T14:41:03Z"
}
],
"recordings": [
{
"type": "camera",
"url": "s3://your-bucket/proctoring/9d2b6c31/camera.webm",
"storage_provider": "partner_s3",
"duration_seconds": 5189,
"file_size_bytes": 104857600
}
],
"recording_errors": [
{
"type": "screen",
"error": "AccessDenied",
"failed_at": "2026-09-10T15:30:02Z"
}
]
}
duration_minutes is elapsed exam time, not scheduled length. recording_errors is omitted when there are no upload errors.
exam.completed includes only recordings with storage_provider partner_s3 or local_only.
| Provider | Meaning |
|---|---|
partner_s3 |
Uploaded to your bucket. The URL is an s3:// URI; fetch it with your AWS credentials. |
local_only |
Endpoint is holding the file because no partner S3 bucket was configured. The URL is a signed HTTPS URL valid for 7 days. |
failed |
Not listed in recordings; represented in recording_errors when an upload error was recorded. |
skipped |
Not listed in recordings. Screen upload is skipped unless explicitly enabled. |
Current attempt recordings use camera and screen; room-scan video may appear as room_scan_video. Do not reject a payload solely because a future recording type is unfamiliar. Check-in still images are not delivered by webhook in the current flow.
Severity is always low, medium, high, or critical. Treat type as an open catalogue: handle unknown future types generically by severity.
| Type | Typical severity |
|---|---|
tab_switch, window_blur, alt_tab_attempt |
medium |
copy_attempt, cut_attempt, paste_attempt, excessive_selection |
low to high |
right_click, drag_attempt, form_submission_blocked |
low |
print_attempt, print_shortcut, screenshot_attempt |
medium to high |
screen_capture_attempt, screen_share_stopped, devtools_detected |
critical |
devtools_shortcut, console_accessed, camera_access_denied, screen_share_denied |
high |
screen_share_muted |
medium |
navigation_attempt, navigation_blocked, navigation_violation |
low to high |
multiple_displays_detected |
medium |
iframe_injection, suspicious_dom_injection, spoofed_end_signal |
medium to high |
no_person_visible, multiple_people |
critical |
phone_visible, ai_detected_violation |
high |
looking_away, suspicious_object |
medium |
improper_lighting, background_activity |
low |
test_violation is used during staging and is deleted before the real exam starts.
| Integration | Stop signal |
|---|---|
| LTI Proctoring Services | POST /lti/end_assessment with LtiEndAssessment |
| REST | POST /proctoring/api/v1/webhooks/exam/status with event_type: "assessment_ended" |
assessment_finalized marks the session finalized after completion. It is a partner/platform lifecycle marker, not proof that every recording was delivered.
Last verified: 2026-09-03 against main.