Proctor SDK
A browser SDK for instrumenting integrity signals in your test runner. It owns its own Worker, IndexedDB queue, media uploaders, heartbeat, and clean-end drain path, then ships events to your dashboard with idempotent IDs. This page is everything you need to ship your first proctored session today.
From npm install to your first event in five minutes. You will need a Proctor workspace and one app key — create them via the sign-up flow and the keys page.
Install the SDK
The SDK is a single ES module. For Vue apps, install the Vue wrapper alongside the SDK peer dependency. It works in modern bundlers such as Vite, webpack, Next.js, and Remix.
pnpm add @a4anthony/proctorkit-vue @a4anthony/proctorkit-sdk
# or
npm install @a4anthony/proctorkit-vue @a4anthony/proctorkit-sdkCreate an app key
App keys are public (they appear in candidate browsers) but locked to allow-listed origins. Create one from the keys page — copy it on the only screen you can see it in full.
Use the Vue wrapper
Vue customers should start with ProctoredAssessment. It resolves the internal attempt, handles preflight and skip logic, wires face-photo detection, starts the SDK from the candidate's Begin test click, and exposes the active ProctoringClient through a scoped slot.
<script setup lang="ts">
import { ProctoredAssessment } from "@a4anthony/proctorkit-vue";
import "@a4anthony/proctorkit-vue/style.css";
const candidate = {
id: "cand_123",
name: "Jane Candidate",
email: "jane@example.com",
};
function saveLifecycleEvent(name: string) {
console.info(name);
}
</script>
<template>
<ProctoredAssessment
app-id="pk_live_xxx"
correlation-id="client-attempt-123"
:candidate="candidate"
preset="standard"
api-base-url="https://proctoring.example.com"
:policy-overrides="{
preflight: {
// Local development only. Leave false or omit in production.
developmentMode: false,
},
}"
@attempt-resolved="saveLifecycleEvent('attempt-resolved')"
@preflight-passed="saveLifecycleEvent('preflight-passed')"
@session-started="saveLifecycleEvent('session-started')"
@session-ended="saveLifecycleEvent('session-ended')"
>
<template #default="{ client, attempt, endSession }">
<AssessmentQuestions
:proctoring-client="client"
:session-id="attempt.sessionId"
@complete="endSession"
/>
</template>
</ProctoredAssessment>
</template>correlationId stays stable for grouping attempts. The wrapper resolves the internal sessionId and uses it for preflight, runtime events, heartbeat, and media uploads.Presets
A preset is a complete proctoring policy — every preflight check and runtime observer set for you. The preset prop is the single most important configuration choice; there are three, increasing in strictness. Omit it and you get standard.
| Capability | basic | standard | strict |
|---|---|---|---|
| Preflight — browser & system checks | ✓ | ✓ | ✓ |
| Preflight — camera (+ face photo) | – | ✓ | ✓ |
| Preflight — microphone | – | ✓ | ✓ |
| Preflight — speaker | – | ✓ | ✓ |
| Preflight — fullscreen support | – | ✓ | ✓ |
| Preflight — screen-share attestation | – | – | ✓ |
| Runtime — heartbeat / idle | ✓ | ✓ | ✓ |
| Runtime — focus & tab-visibility | ✓ | ✓ | ✓ |
| Runtime — fullscreen enforcement | – | ✓ | ✓ |
| Runtime — clipboard monitoring | – | ✓ | ✓ |
| Runtime — block right-click menu | – | – | ✓ |
| Runtime — keyboard monitoring | – | ✓ | ✓ |
| Runtime — screenshot detection | – | ✓ | ✓ |
| Runtime — screenshot blur deterrent | – | – | ✓ |
| Runtime — webcam snapshots | – | ✓ | ✓ |
| Runtime — webcam video recording | – | – | ✓ |
| Runtime — screen-share recording | – | – | ✓ |
| Runtime — face presence (lost / multiple) | – | ✓ | ✓ |
| Runtime — gaze tracking | – | – | ✓ |
Use basic for low-stakes / monitored-only (browser check + activity signals, no camera or recording), standard for most proctored exams (camera/mic preflight + face photo, fullscreen, clipboard/keyboard monitoring, webcam snapshots, face presence — no recording), and strict for high-stakes (everything in standard plus screen-share recording, webcam video, and gaze).
Adjust individual values on top of a preset with :policy-overrides — a deep partial, so you only specify what differs. The final policy is the chosen preset (default standard) with your :policy-overrides deep-merged on top; a preset key inside :policy-overrides also selects the base. And verificationMode (preflight_required default, runtime_only, or disabled) controls whether the preflight wizard runs at all.
screenshot.blurOnSuspicion, which frosts the page when a capture is suspected so a delayed screenshot grabs a blurred view — still not a hard block.Complete policy reference
Every field under policyOverrides.proctoring. Booleans toggle a detector; the sub-objects refine it. The Default column is the effective value when you set nothing — i.e. the standard preset, which is the base whether or not you pass preset. (Pick basic or strict to shift the defaults — see the preset table above.)
| Option | Type | Default | What it does |
|---|---|---|---|
| heartbeat | boolean | true | Activity heartbeat + idle detection. |
| idleThresholdSeconds | number | 60 | Seconds of inactivity before an idle flag (needs heartbeat). |
| focus | boolean | true | Tab/window focus-loss, page-visibility, and pointer-leave detection. |
| visibility | boolean | = focus | Override page-visibility detection independently of focus. |
| pointer | boolean | = focus | Override pointer-leave detection independently of focus. |
| fullscreen | boolean | true | Fullscreen enter/exit detection. |
| network | boolean | true | Online/offline detection. |
| clipboard | boolean | object | true | true = detect copy/cut/paste events; { captureContent, maxBytes, block } also stores the text and/or blocks the actions in-page. |
| clipboard.captureContent | boolean | false | Store the clipboard text, not just the event (privacy-sensitive). |
| clipboard.maxBytes | number | 2000 | Cap on captured clipboard bytes. |
| clipboard.block | boolean | object | false | Prevent the action in-page (preventDefault), not just flag it. true blocks all; { copy, cut, paste, contextmenu } selects which (e.g. { contextmenu: true } disables right-click). Page-level only — can't stop OS menus/shortcuts. |
| keyboard | boolean | object | true | true = the SDK's default shortcut set; { block } narrows to specific shortcuts. |
| keyboard.block | string[] | (SDK default) | Specific shortcuts to flag, e.g. ["Meta+c","Meta+v"]. |
| screenshot | boolean | object | true | false/off, true = detect-only, or { blurOnSuspicion, resumeRevealMs, resumeButtonText, warningText } for the blur deterrent. |
| screenshot.blurOnSuspicion | boolean | false | Frost the page on a suspected capture (strict preset enables it). |
| screenshot.resumeRevealMs | number | 5000 | Auto-reveal the blur after N ms. |
| screenShare.enabled | boolean | false | Capture + record the screen share. |
| screenShare.chunkMs | number | 10000 | MediaRecorder timeslice for screen chunks. |
| screenShare.enforceEntireScreen | boolean | false | Reject window/tab shares; require the whole display. (strict preset sets true.) |
| screenShare.videoBitrate | number | 500000 | Target screen-recording bitrate (bps). |
| webcam.snapshots | boolean | true | Periodic webcam stills. |
| webcam.snapshotEveryMs | number | 30000 | Interval between snapshots. |
| webcam.snapshotJpegQuality | number 0–1 | 0.7 | JPEG quality of snapshots. |
| webcam.record | boolean | false | Continuous webcam video recording. |
| webcam.recordChunkMs | number | 10000 | MediaRecorder timeslice for webcam chunks. |
| webcam.recordVideoBitrate | number | 500000 | Target webcam-recording bitrate (bps). |
| face.enabled | boolean | true | Face-presence analysis (needs camera). |
| face.lostFace | boolean | true | Flag when no face is detected. |
| face.multipleFaces | boolean | true | Flag when more than one face is present. |
| face.gaze | boolean | false | Flag sustained gaze off-screen. |
Recordings (screen + webcam) upload directly to the configured storage and are stitched into a seekable MP4 server-side; they appear under the session's Screen / Webcam tabs once processing finishes.
Derived rules (you don't wire these): preflight.camera (and its captured face photo) is derived from camera intent — webcam.snapshots, webcam.record, or face.enabled. If any is on the camera preflight is required; if all are off it's dropped — both directions, regardless of preset. So you never set preflight.camera yourself: turning webcam + face off removes the camera check even on a preset that enabled it. Likewise preflight.screenShare always follows screenShare.enabled.
When the session ends, the wrapper shows a built-in "Assessment submitted" screen by default. To own the post-session UI yourself, pass hide-ended (renders nothing on end — drive your own flow from the @session-ended event), or provide an #ended slot for a custom screen (it receives { attempt, sessionId, attemptNumber }).
A separate case: when a candidate loads an attempt that cannot start (e.g. they reopen an already-ended session), only @attempt-resolved fires — with canStart: false — and not @session-ended. By default the wrapper shows an "unavailable" box; pass hide-blocked to suppress it and handle that case yourself (branch on attempt-resolved → canStart === false).
To skip proctoring altogether and render only your #default slot — no attempt resolution, preflight, session, or SDK states — pass render-only. Useful for a non-proctored render of the same shell (e.g. a finished or preview test). In this mode proctoring is off, so the slot receives client: null, attempt: null, and a no-op endSession; no lifecycle events fire.
Microphone speech detection assets are served by the proctoring API, not copied into the customer assessment app. With api-base-url="https://proctoring.example.com",ProctoredAssessment loads VAD files from /sdk-assets/vad/ and ONNX runtime WASM files from /sdk-assets/ort/ on that same API origin. Use vad-assets only when you want to self-host those files on a CDN.
<ProctoredAssessment
api-base-url="https://proctoring.example.com"
:vad-assets="{
baseAssetPath: 'https://cdn.example.com/proctoring/vad/',
onnxWASMBasePath: 'https://cdn.example.com/proctoring/ort/'
}"
/>By default, the wizard handles runtime startup before the assessment slot renders: if screen recording is required, candidates see a dedicated screen-sharing step and click Begin screen share to grant and validate the browser stream. The final screen then enables Resume test, which enters fullscreen, starts the actual recording, and reveals your assessment once the SDK is ready. In that default wrapper flow, the separate screen-share acknowledgement step is skipped so candidates do not see duplicate screen-share statuses. Set start-session-on-preflight-pass to false only when you want to show a separate intro screen through the #ready slot.
<ProctoredAssessment
app-id="pk_live_xxx"
correlation-id="client-attempt-123"
:candidate="candidate"
preset="basic"
>
<template #default="{ client, endSession }">
<AssessmentQuestions
:proctoring-client="client"
@complete="endSession"
/>
</template>
</ProctoredAssessment>fullscreen-exit-modal also defaults to true. When runtime fullscreen monitoring is enabled and the candidate exits fullscreen, the wrapper shows a recovery modal whose button restores fullscreen from a fresh user click.
Advanced: initialise the raw client
Use the raw SDK constructor when you are building a custom framework wrapper or replacing the Vue preflight UI. Construct the client from the same user action that starts the candidate's proctored test. The constructor auto-starts the worker, observers, media capture, heartbeat, and upload queue. There is no public start() method.
import { ProctoringClient } from "@a4anthony/proctorkit-sdk";
const endController = new AbortController();
const client = new ProctoringClient({
sessionId: "sess_abc123",
ingestUrl: "https://api.yourapp.com/ingest",
appId: "pk_live_a8d2b…",
workerUrl: new URL("@a4anthony/proctorkit-sdk/worker", import.meta.url),
candidate: { id: "u_4821", email: "alex@uniwest.edu", name: "Alex Rivera" },
endSignal: endController.signal,
observers: {
network: true,
fullscreen: true,
clipboard: { captureContent: false },
keyboard: true,
screenShare: { enforceEntireScreen: true, timesliceMs: 10_000 },
webcam: {
photos: { minIntervalSeconds: 20, maxIntervalSeconds: 60 },
},
},
heartbeat: true,
onEvent: (msg) => {
if (msg.type === "ready") {
console.info("Proctoring ready");
}
},
});End cleanly
End the session with client.end() or by aborting the signal you passed to the client. The SDK first sends session.end_requested, then stops observers, drains screen/webcam/media uploaders, and emits session.ended with { reason: "stopped", drainStatus: "complete" }. If the final event never arrives, the backend auto-finalizes after the drain deadline and queues analysis with the media that arrived.
// Candidate submits the assessment.
await client.end();
// Candidate closes the tab or navigates away before submit.
// The SDK only flushes queued events; the backend infers abandonment
// from missing heartbeat/activity.client.recordAudioClip() or client.recordVideoClip(). Clip uploads are drained before the clean session.ended event is sent.The SDK exposes first-class media helpers on ProctoringClient for assessment media that is triggered by your app, not passively by an observer. Use these when a candidate listens to an audio prompt, answers a speaking question, records a video response, or when your own recorder needs the SDK to upload the final video blob. Every helper stamps the action onto the session timeline and associates it with the same internal session, app key, and device fingerprint.
Vue components
Vue integrations can use the first-party UI components instead of wiring recorder state by hand. Render them inside ProctoredAssessment, then save the returned clipNumber or playback state in your assessment answers. For Storybook or non-proctored previews, AudioPlayer and VideoPlayer can also run without a client; they use local browser playback with the same UI events but no SDK telemetry.
<script setup lang="ts">
import { AudioPlayer } from "@a4anthony/proctorkit-vue";
import "@a4anthony/proctorkit-vue/style.css";
</script>
<template>
<AudioPlayer
url="/audio/listening-01.mp3"
label="Question 5 listening audio"
:allowed-replays="2"
@ended="markPromptListened"
/>
</template><script setup lang="ts">
import {
AudioPlayer,
VideoPlayer,
AudioRecorder,
VideoRecorder,
} from "@a4anthony/proctorkit-vue";
import "@a4anthony/proctorkit-vue/style.css";
</script>
<template>
<ProctoredAssessment
app-id="pk_live_xxx"
correlation-id="client-attempt-123"
:candidate="candidate"
preset="standard"
>
<template #default="{ client }">
<AudioPlayer
:client="client"
:url="question.audioUrl"
label="Question 5 listening audio"
:allowed-replays="2"
:autoplay="false"
@ended="markPromptListened"
/>
<VideoPlayer
:client="client"
:url="question.videoUrl"
label="Question 7 viewing prompt"
:allowed-replays="1"
@ended="markPromptWatched"
/>
<AudioRecorder
:client="client"
title="Speaking answer"
:max-duration-ms="120000"
@uploaded="({ clipNumber }) => saveAnswer({ clipNumber })"
@dropped="({ reason }) => showUploadError(reason)"
/>
<VideoRecorder
:client="client"
title="Video answer"
:max-duration-ms="120000"
:stream="candidateCameraStream"
:video-bitrate="1500000"
@uploaded="({ clipNumber }) => saveAnswer({ clipNumber })"
@dropped="({ reason }) => showUploadError(reason)"
/>
</template>
</ProctoredAssessment>
</template>Pass stream when your assessment already owns microphone/camera access. The video component previews the stream but leaves track lifecycle ownership with your app.
For listening and viewing prompts, allowedReplays controls how many extra plays are allowed after the first playback. Both players render a custom progress bar, support autoplay attempts, and hide pause/stop controls by default. While a prompt is actively playing, the Play button is hidden; after playback ends, a Replay button appears only when another replay is allowed. Neither player exposes seeking, so candidates cannot skip through a prompt. Pass client in a live proctored session; omit it for isolated Storybook assessment screens. ProctoredAssessment carries the preflight speaker/headphone selection into the SDK session, and both players also accept sinkId for an explicit output override. VideoPlayer additionally accepts poster, muted, and playsInline (default true); pair autoplay with muted because browsers block un-muted video autoplay without a candidate gesture.
AudioPlayer props
| Prop | Type | Default | Description |
|---|---|---|---|
urlreq | string | URL | - | Audio file to play. Query strings are stripped from emitted events. |
client | ProctoringClient | - | SDK client. Omit for standalone local playback with no telemetry. |
label | string | - | Reviewer-facing label included in playback events. |
allowedReplays | number | null | null | Extra plays after the first. null = unlimited, 0 = one play only. |
autoplay | boolean | false | Attempt playback on mount. Browser autoplay policy still applies. |
loop | boolean | false | Loop the audio. |
volume | number | 1 | Playback volume, 0-1. |
playbackRate | number | 1 | Playback speed, 0.25-4. |
sinkId | string | null | - | Audio output device id. Defaults to the preflight speaker selection. |
showPauseButton | boolean | false | Show the Pause control. |
showStopButton | boolean | false | Show the Stop control. |
playLabel | string | "Play" | Play button label. |
replayLabel | string | "Replay" | Replay button label. |
pauseLabel | string | "Pause" | Pause button label. |
stopLabel | string | "Stop" | Stop button label. |
disabled | boolean | false | Disable all controls. |
| Event | Payload | Description |
|---|---|---|
started | { payload } | Playback started, including resume from pause. |
paused | { payload } | Playback paused. |
stopped | { payload } | Stopped via the Stop control and rewound to 0. |
ended | { payload } | Playback reached the end. |
error | { error, message } | Playback or media-load failure. |
replayLimitReached | { allowedReplays, playCount } | A play was attempted past the replay cap. |
VideoPlayer props
VideoPlayer uses playVideoFile and emits the same events as AudioPlayer. Its full prop list is the shared playback props plus three video-only props.
| Prop | Type | Default | Description |
|---|---|---|---|
urlreq | string | URL | - | Video file to play. Query strings are stripped from emitted events. |
client | ProctoringClient | - | SDK client. Omit for standalone local playback with no telemetry. |
label | string | - | Reviewer-facing label included in playback events. |
allowedReplays | number | null | null | Extra plays after the first. null = unlimited, 0 = one play only. |
autoplay | boolean | false | Attempt playback on mount. Pair with muted for reliable autoplay. |
loop | boolean | false | Loop the video. |
volume | number | 1 | Playback volume, 0-1. |
playbackRate | number | 1 | Playback speed, 0.25-4. |
sinkId | string | null | - | Audio output device id for the video's sound. Defaults to the preflight speaker selection. |
poster | string | - | Video-only. Poster image shown before first play. |
muted | boolean | false | Video-only. Mute the video; required for reliable autoplay. |
playsInline | boolean | true | Video-only. Keep playback inline on iOS instead of native fullscreen. |
showPauseButton | boolean | false | Show the Pause control. |
showStopButton | boolean | false | Show the Stop control. |
playLabel | string | "Play" | Play button label. |
replayLabel | string | "Replay" | Replay button label. |
pauseLabel | string | "Pause" | Pause button label. |
stopLabel | string | "Stop" | Stop button label. |
disabled | boolean | false | Disable all controls. |
AudioRecorder props
| Prop | Type | Default | Description |
|---|---|---|---|
client | ProctoringClient | - | SDK client used to record and upload the clip. |
maxDurationMs | number | 120000 | Hard recording cap; auto-stops at the limit. |
stream | MediaStream | - | Pre-acquired mic stream. Omit to let the recorder request one. |
micDeviceId | string | null | - | Microphone device id when the recorder acquires its own stream. |
prepareRecording | () => Promise<void> | void | - | Hook run before recording starts, for example to acquire a stream. |
showWaveform | boolean | true | Show the live input waveform. |
waveformBarCount | number | 12 | Number of waveform bars. |
title | string | "Audio answer" | Card heading. |
description | string | - | Optional sub-text under the title. |
recordLabel | string | "Record audio" | Record button label. |
stopLabel | string | "Stop" | Stop button label. |
hideStopButton | boolean | false | Hide the Stop control while recording so the candidate can't end it manually; recording runs until the maxDurationMs cap auto-stops it. The Record button still appears before recording. |
allowedRetakes | number | null | 0 | Re-records allowed after the first take. 0 = a single recording (Record disappears once it uploads). 2 = 3 takes total. null = unlimited. Only successful takes consume an attempt. |
unavailableMessage | string | - | Legacy. With the standalone fallback the recorder no longer blocks on a missing client, so this rarely shows. |
disabled | boolean | false | Disable the recorder. |
autoStart | boolean | false | Begin recording automatically when ready, without a Record press. Fires once (no re-arm after Stop) via the same prepare → acquire-stream → start path. Pass stream or micDeviceId so a source is resolvable. |
sendClip | (clip) => Promise<unknown> | - | Mirror each finalized clip to your own backend in addition to the proctoring server. Receives the audio Blob; resolve on success, throw on failure. Non-fatal — the clip still uploads and `uploaded` still fires; rejection surfaces on `clip-mirror-failed`. |
While a clip uploads (and during the brief pre-record preparation), the card shows a spinner with Uploading… / Preparing….
| Event | Payload | Description |
|---|---|---|
started | { kind, clipNumber } | Recording started. |
stopped | { kind, clipNumber } | Recording stopped and upload begins. |
uploaded | { kind, clipNumber, byteSize, durationMs, volumeAnalysis? } | Clip uploaded to the proctoring server; audio carries volume analysis when available. |
dropped | { kind, clipNumber, code, reason, volumeAnalysis? } | The recorded clip could NOT be stored (upload failed, or nothing captured). Branch on `code` — see Handling recorder errors below. |
error | { kind, code, error, message } | Recording could NOT start (mic blocked, no device…). Branch on `code` — see Handling recorder errors below. |
clip-mirror-failed | { kind, clipNumber, error, message } | A sendClip mirror rejected. Non-fatal: the clip still uploaded to the proctoring server. |
Upload audio clips to your own backend
Pass sendClip to mirror each finalized clip to your own storage or API in addition to the proctoring server. Your function receives the recorded audio as a Blob plus { mimeType, durationMs, clipNumber }; resolve when your upload succeeds and throw when it fails. The mirror runs in parallel with the proctoring-server upload and is best-effort: a failure never changes the clip outcome — uploaded still fires, and the rejection surfaces on clip-mirror-failed rather than dropped. It is attempted once per clip, so own any retry inside your function (use clipNumber as an idempotency key).
<script setup lang="ts">
import { AudioRecorder } from "@a4anthony/proctorkit-vue";
import type { MediaClipDataSink } from "@a4anthony/proctorkit-vue";
const sendClip: MediaClipDataSink = async ({ blob, durationMs, clipNumber }) => {
const form = new FormData();
form.append("audio", blob, `clip-${clipNumber}.webm`);
form.append("durationMs", String(durationMs));
const res = await fetch("https://api.example.com/answers", {
method: "POST",
headers: { authorization: `Bearer ${token}` },
body: form,
});
if (!res.ok) throw new Error(`http_${res.status}`); // throw = mirror failed
// resolve = mirror succeeded
};
function onMirrorFailed({ clipNumber, message }) {
// The clip is still safely stored on the proctoring server.
console.warn(`Clip #${clipNumber} mirror failed: ${message}`);
}
</script>
<template>
<AudioRecorder
:client="client"
:send-clip="sendClip"
@uploaded="onUploaded"
@clip-mirror-failed="onMirrorFailed"
/>
</template>Handling recorder errors
The recorder reports failure through two separate events, because they need different candidate messaging:
error— recording never produced a clip (the microphone couldn't be acquired or started). Nothing was captured.dropped— the clip recorded but couldn't be stored (the upload failed, or it captured nothing). Like a dropped network packet: it existed, then didn't reach its destination.
Both carry a stable code beside the human-readable message. Branch on the code, not the message — messages come from the browser and vary by locale. Codes are validated at the component boundary, so the value is always one of the closed sets below (anything unexpected is coerced to unknown / network-error).
error.code — recording could not start:
| Code | Retry? | Cause |
|---|---|---|
permission-denied | No | Mic permission blocked — the candidate must allow it. |
no-device | Yes | No microphone found; works once one is connected. |
device-in-use | Yes | Mic busy in another app (Zoom, Teams…); works once freed. |
no-audio-track | No | A supplied stream had no audio track. |
unsupported | No | No getUserMedia / MediaRecorder (insecure context, old browser). |
unknown | Maybe | Unclassified — show the message. |
dropped.code — clip recorded but not stored:
| Code | Retry? | Cause |
|---|---|---|
empty-recording | Yes | Zero bytes captured (instant stop, dead track). |
rejected | No | Upload 4xx (auth, quota, origin not allowed). A hard limit — surface to the proctor. |
server-error | Yes | Upload 5xx — transient. |
network-error | Yes | Upload threw (offline, CORS, DNS). |
<script setup lang="ts">
import { AudioRecorder } from "@a4anthony/proctorkit-vue";
import type {
MediaClipErrorPayload,
MediaClipDroppedPayload,
} from "@a4anthony/proctorkit-vue";
// Recording could not start.
function onError({ code, message }: MediaClipErrorPayload) {
switch (code) {
case "permission-denied":
notify("Microphone is blocked. Enable it, then press Record."); break;
case "no-device":
notify("No microphone found. Plug one in and try again."); break;
case "device-in-use":
notify("Your mic is in use by another app. Close it and retry."); break;
case "unsupported":
notify("This browser can't record. Use a recent Chrome/Edge/Safari over HTTPS."); break;
default:
notify(`Couldn't start recording: ${message}`);
}
}
// Clip recorded but was not stored.
function onDropped({ code }: MediaClipDroppedPayload) {
if (code === "rejected") {
// 4xx — auth / quota / origin. Retrying won't help.
notify("Your answer was refused by the server. Please contact your proctor.");
} else {
// empty-recording / server-error / network-error — retry is worth offering.
notify("Your answer wasn't saved. Please record again.");
}
}
</script>
<template>
<AudioRecorder :client="client" @error="onError" @dropped="onDropped" />
</template>The code unions MediaClipErrorCode / MediaClipDropCode are exported for exhaustive handling. In standalone mode there is no server upload, so the only dropped code that can occur is empty-recording.
VideoRecorder props
| Prop | Type | Default | Description |
|---|---|---|---|
clientreq | ProctoringClient | - | SDK client used to record and upload the clip. |
stream | MediaStream | - | Camera stream to preview and record. Pass when your app owns the camera. |
maxDurationMs | number | 120000 | Hard recording cap; auto-stops at the limit. |
audio | boolean | true | Include the microphone track in the recording. |
videoBitrate | number | - | Target video bitrate in bits per second. |
title | string | "Video answer" | Card heading. |
recordLabel | string | "Record video" | Record button label. |
stopLabel | string | "Stop" | Stop button label. |
disabled | boolean | false | Disable the recorder. |
autoStart | boolean | false | Begin recording automatically when ready, without a Record press. Fires once (no re-arm after Stop). When a stream is expected it waits for the stream to arrive before starting. |
VideoRecorder emits the same started / stopped / uploaded / dropped / error events as AudioRecorder, with kind: "video" in clip payloads. The sendClip mirror and clip-mirror-failed event are currently available on AudioRecorder only.
Listening audio
playAudioFile() plays a URL-backed audio prompt and emits audio-playback.started, audio-playback.paused, audio-playback.stopped, audio-playback.ended, or audio-playback.error. When a selected speaker/headphone is configured, output routing also emits audio-playback.sink-applied, audio-playback.sink-unsupported, or audio-playback.sink-failed. If the selected output disappears during playback, the SDK emits audio-playback.sink-disconnected and attempts to reroute the same audio element to the system default output. Event payloads remove query strings and hash fragments from the URL, so signed audio links are not written into the timeline.
function ListeningQuestion({ client }: { client: ProctoringClient }) {
const player = useRef<AudioFilePlaybackHandle | null>(null);
async function playPrompt() {
player.current = client.playAudioFile({
url: "https://cdn.example.com/prompts/q5.mp3?signature=...",
label: "Question 5 listening audio",
onEnded: () => markPromptListened(),
onError: (_error) => showPlaybackError(),
});
}
function stopPrompt() {
player.current?.stop();
}
return (
<div>
<button type="button" onClick={playPrompt}>Play prompt</button>
<button type="button" onClick={stopPrompt}>Stop</button>
</div>
);
}If you render your own native controls, pass the existing <audio> element and disable autoplay:
client.playAudioFile({
url: question.audioUrl,
label: question.audioLabel,
element: audioRef.current!,
autoplay: false,
sinkId: selectedSpeakerId,
});Viewing video
playVideoFile() is the video twin of playAudioFile(): it plays a URL-backed video prompt and emits video-playback.started, video-playback.paused, video-playback.stopped, video-playback.ended, or video-playback.error. The video's audio track routes through the same speaker/headphone selection as audio prompts, emitting the matching video-playback.sink-* events. Payload URLs are stripped of query strings and hash fragments, so signed video links are not written into the timeline.
On top of the shared options, video adds poster, muted, and playsInline. Each is applied only when set, so a host-supplied <video> element keeps its own configuration; playsInline defaults to true for elements the SDK creates so iOS Safari keeps playback inline.
const handle = client.playVideoFile({
url: "https://cdn.example.com/prompts/q7.mp4?signature=...",
label: "Question 7 viewing prompt",
poster: question.posterUrl,
autoplay: false,
onEnded: () => markPromptWatched(),
onError: (_error) => showPlaybackError(),
});
// Later, from a candidate gesture:
await handle.play();Audio clips
recordAudioClip() records microphone audio, uploads to the audio-clips endpoint, and emits audio-clip.started, audio-clip.stopped, and audio-clip.uploaded or audio-clip.dropped. The default safety cap is 2 minutes; pass maxDurationMs to make the limit explicit in your UI.
import type { ProctoringClient, VideoClipHandle } from "@a4anthony/proctorkit-sdk";
import { useRef, useState } from "react";
function SpeakingQuestion({ client }: { client: ProctoringClient }) {
const activeClip = useRef<VideoClipHandle | null>(null);
const [recording, setRecording] = useState(false);
const [answerClip, setAnswerClip] = useState<number | null>(null);
const [uploadError, setUploadError] = useState<string | null>(null);
async function startRecording() {
setUploadError(null);
activeClip.current = await client.recordAudioClip({
maxDurationMs: 120_000,
onUploaded: (clipNumber, _byteSize, durationMs) => {
setAnswerClip(clipNumber);
console.info("Audio answer uploaded", { clipNumber, durationMs });
},
onDropped: (_clipNumber, reason) => {
setUploadError(reason);
},
});
setRecording(true);
}
async function stopRecording() {
const clip = activeClip.current;
if (!clip) return;
await clip.stop();
activeClip.current = null;
setRecording(false);
}
return (
<div>
<button type="button" onClick={recording ? stopRecording : startRecording}>
{recording ? "Stop" : "Record answer"}
</button>
<button type="button" disabled={answerClip === null || uploadError !== null}>
Save answer
</button>
</div>
);
}Video clips
recordVideoClip() records a bounded video answer and uploads it when the returned handle is stopped. It can use a stream you pass in, reuse the SDK webcam observer's camera stream, or request a fresh camera/microphone stream. When it must request media itself, call it directly from the candidate's button click so the browser permission prompt has user activation.
const videoClip = await client.recordVideoClip({
maxDurationMs: 120_000,
audio: true,
videoBitrate: 1_500_000,
onUploaded: (clipNumber, byteSize, durationMs) => {
saveQuestionAnswer({
questionId: "q_video_1",
mediaKind: "video",
clipNumber,
byteSize,
durationMs,
});
},
onDropped: (_clipNumber, reason) => {
showRecordingError(reason);
},
});
// Candidate clicks "Done".
await videoClip.stop();Retakes
A retake should be treated as a new clip, not as an overwrite. Previous clips remain in the session evidence trail, while your assessment app decides which clipNumber is the submitted answer for that question.
let acceptedClipNumber: number | null = null;
async function recordAttempt(questionId: string) {
const clip = await client.recordVideoClip({
maxDurationMs: 120_000,
onUploaded: (clipNumber) => {
acceptedClipNumber = clipNumber;
saveQuestionAnswer({ questionId, clipNumber });
},
});
return clip;
}
// Retake flow:
// 1. Stop the current clip.
// 2. Let the candidate preview or discard it in your UI.
// 3. Start a new clip and store the new accepted clipNumber.
// The old clip stays visible to reviewers as evidence.Custom video upload
If your assessment app already owns the recorder and preview/retake UI, send the final video blob through uploadVideoClip(). It lands in the same dashboard surface as recordVideoClip() and emits the same uploaded/dropped timeline events.
const handle = await client.uploadVideoClip(recordedBlob, {
durationMs: measuredDurationMs,
onUploaded: (clipNumber, byteSize, durationMs) => {
saveQuestionAnswer({
questionId: "q_video_2",
mediaKind: "video",
clipNumber,
byteSize,
durationMs,
});
},
onDropped: (_clipNumber, reason) => {
showRecordingError(reason);
},
});
console.info("Uploaded video answer", handle.clipNumber);recordAudioClip() unless we add a first-class uploadAudioClip() API.Writing answers
ProctoredTextarea is a drop-in textarea for writing questions that captures how an answer was produced — paste, typing dynamics, content checkpoints, focus, and writing-assistant footprints — alongside the answer text. Fully proctored with no config; props only disable signals or enable enforcement.
Two destinations, two owners. The answer text is yours — it's v-model'd in your app and saved through a saveAnswer function you provide (the SDK never writes to your database). The integrity signals flow to the proctoring server automatically and power the dashboard's Writing tab. The field shows Saving… → Saved ✓ around your save, like the recorder's Uploaded ✓.
<script setup lang="ts">
import { ProctoredTextarea } from "@a4anthony/proctorkit-vue";
import type { SaveAnswerFn } from "@a4anthony/proctorkit-vue";
const saveAnswer: SaveAnswerFn = async ({ text, questionId, signal }) => {
const res = await fetch("https://api.example.com/answers", {
method: "POST",
body: JSON.stringify({ questionId, text }),
signal,
});
if (!res.ok) throw new Error(`http_${res.status}`); // throw = save failed
};
</script>
<template>
<ProctoredTextarea
v-model="answer"
question-id="q1"
:save-answer="saveAnswer"
@saved="onSaved"
@save-failed="onSaveFailed"
/>
</template>Signal and autosave props default on; the in-progress answer autosaves to localStorage and survives a refresh. In a sandboxed iframe (where local storage is denied) set persistAnswer so checkpoints carry the answer to the server. The writing-assistant detector (Grammarly etc.) records a footprint, not a guarantee, and is never blocked.
| Prop | Type | Default | Description |
|---|---|---|---|
modelValue | string | - | Answer text (v-model). Owned by you. |
questionIdreq | string | - | Scopes events, autosave, checkpoints. |
client | ProctoringClient | - | Omit → active session client → standalone. |
saveAnswer | (a) => Promise<unknown> | - | Your save fn. Resolve = Saved ✓, throw = error. Called debounced + on submit. |
textareaClass | string | - | Classes for the inner textarea, appended after the component's own so yours win. Restyle in your own design system; utilities you pass must exist in your CSS build. |
countMode | "characters" | "words" | "characters" | What the built-in counter counts. Word/length limits + the valid flag stay your logic (pass via submit({ metMinimum })). |
detectPaste | boolean | true | Capture paste signals. |
capturePasteContent | boolean | false | Store the actual clipboard text on paste, copy AND cut (PII-gated). Captures what was attempted even when the paste is blocked — surfaced in the dashboard Writing tab's Clipboard activity. |
pasteMaxBytes | number | 2000 | Byte cap on captured clipboard content; longer text is truncated. |
detectCopyCut | boolean | true | Capture copy & cut signals (text.copy / text.cut). |
trackTyping | boolean | true | Typing dynamics (sampled). |
checkpointEveryMs | number | 15000 | Content-snapshot interval. |
trackFocus | boolean | true | Track leaving the field. |
detectSyntheticInput | boolean | true | Writing-assistant / synthetic-input detection. |
blockPaste / blockCopy / blockCut / blockContextMenu / blockDrop | boolean | false | Enforcement — the attempt is still recorded. |
autosave | boolean | true | Local autosave + restore. |
autosaveDebounceMs / serverSaveDebounceMs | number | 500 / 2000 | Debounce for the cheap local write vs the networked saveAnswer — separate so a hot typing path doesn't hammer your backend. |
persistAnswer | boolean | false | Also send the answer in checkpoints to the proctoring server. |
localSaveStatus | boolean | false | Drive the Saving… / Saved ✓ status from the local autosave (the localStorage draft), not just a networked saveAnswer. For fields whose only persistence is the local draft. Ignored when saveAnswer is wired. |
localSaveDelayMs | number | 500 | With localSaveStatus, how long to hold Saving… before Saved ✓ — shown only after typing pauses and the debounced write runs (never mid-typing), so the flip lands ~autosaveDebounceMs + this delay after the last keystroke. Blur/submit snaps straight to Saved ✓. |
spellcheck | boolean | false | Allow native spellcheck. Off by default (avoids the autocorrect-replacement path the synthetic detector watches). Always on for Safari. |
mobileScrollLock | boolean | false | Lock page scroll while the soft keyboard is open (mobile), keeping the answer scrollable. Emits keyboard-open/close; Safari input flags applied automatically. |
maxLength / minLength / rows | number | - | Field bounds + height. |
inputId | string | - | id for the inner <textarea> (a bare id on the component lands on the wrapper). Also associates the built-in <label> via for. |
name | string | questionId | name for the inner <textarea> (native form submission; satisfies the 'form field should have an id or name' heuristic). Defaults to questionId, so the field is never nameless. |
Exposed via a template ref: submit({ metMinimum }) (fires submitted, forwards your word/char-limit result) and dismissKeyboard(). The component also sets anti-assistant attributes (data-gramm="false", translate="no") on its textarea.
| Event | Payload | Description |
|---|---|---|
update:modelValue | string | Every input. |
paste | { length, pastedRatio, blocked } | A paste occurred. |
copy / cut | { length, blocked } | A copy/cut occurred (length = selection size). |
synthetic-input | { source } | Writing-assistant / synthetic footprint seen. |
checkpoint | { savedAt, length } | A content snapshot was taken. |
focus-lost / focus-regained | { durationMs } | Left / returned to the field. |
saved | { savedAt } | Your saveAnswer resolved. |
save-failed | { code, message } | saveAnswer rejected; same stable codes as the recorder. |
restored | { savedAt, source } | A prior draft was rehydrated on mount. |
submitted | { length, pasteCount, typingMs, syntheticDetected, metMinimum? } | submit() was called (exposed via ref). |
keyboard-open / keyboard-close | — | Soft keyboard opened/closed (mobileScrollLock only). |
The preflight wizard runs before the candidate enters the exam. Six steps walk them through browser checks, device sanity, the speed test, microphone, speaker, camera, and a screen-share attestation. Failures surface candidate-actionable recovery steps in-line — no support ticket round-trip. Vue apps should normally use ProctoredAssessment, which wraps this wizard, handles runtime screen-sharing permission, and starts the runtime SDK before rendering the assessment slot.
ProctoringSystemCheck remains available for advanced custom UIs and non-Vue wrappers. If your test runner is React/Next, mount the wizard via a small Vue island or iframe; a first-party React wrapper is on the roadmap.Install & mount
The wizard ships in a separate package from the runtime SDK so non-Vue customers don't pay the Vue runtime cost. Use this direct wizard mount only when you are not using ProctoredAssessment.
pnpm add @a4anthony/proctorkit-vue vue
# stylesheet shipped sibling-to the component
Mount the wizard wherever you want the candidate to land before the test. The component handles every step internally — your host page only sees onPass when everything verifies.
import { createApp, h } from "vue";
import {
ProctoringSystemCheck,
createNestDetectFace,
} from "@a4anthony/proctorkit-vue";
import "@a4anthony/proctorkit-vue/style.css";
createApp({
setup() {
const detectFace = createNestDetectFace({
baseUrl: "https://api.proctor.app",
});
return () =>
h(ProctoringSystemCheck, {
mode: "boot",
engineOptions: {
media: {
microphone: true,
speaker: true,
camera: true,
screenShare: true,
},
deepCamera: { detectFace },
},
onPass: () => {
// hide the wizard, reveal your exam runner
document.dispatchEvent(new CustomEvent("preflight:pass"));
},
});
},
}).mount("#preflight-root");Direct ProctoringSystemCheck mounts do not infer an API base URL. Either serve the microphone VAD assets at /vad/ and /ort/, or pass vadAssets with CDN/backend paths. The normal ProctoredAssessment wrapper handles this automatically.
Configure which checks run
Every check is opt-in or opt-out. media gates the four candidate-facing checks (mic / speaker / camera / screen share); system gates the five synchronous ones (browser / device / layout / external monitor / connection). Each system check defaults to true — pass an explicit false to skip the row entirely.
engineOptions: {
developmentMode: true, // local dev only; leave false in production
media: {
microphone: true,
speaker: true,
camera: true,
screenShare: true,
},
system: {
browser: true, // default
device: true, // default
layout: true, // default
externalMonitor: false, // skip the multi-monitor check entirely
connection: false, // skip the speed test
},
}developmentMode is a local development escape hatch. It force-passes the external monitor row so a developer with a second display can test the wizard. Do not enable it for live assessments.connection means a candidate on a flaky network reaches your test runner before they can fail. Useful for low-stakes practice quizzes; never for a paid certification exam.Tune pass/fail thresholds
The on/off toggles decide which rows run. thresholds decides what counts as a pass. Every field is optional with a documented default; the table below is the full surface.
engineOptions: {
thresholds: {
minBandwidthMbps: 5, // raise from default 2 → 5
micMinSpeechMs: 3000, // 3s of speech (high-stakes)
maxFacesAllowed: 1, // exactly the candidate
allowExternalMonitor: false, // block multi-monitor
allowMobile: false, // block phones (incl. Android)
},
}| Field | Type | Default | Meaning |
|---|---|---|---|
minBandwidthMbps | number | 2 | Lower bound on the speed test. Below trips slow-connection. |
micMinSpeechMs | number | 1500 | Continuous speech required before mic verifies. Higher = stricter. |
maxFacesAllowed | number | 1 | Faces allowed in frame for camera pass. Above this fails as multiple-faces. |
allowExternalMonitor | boolean | false | Permit candidates with a second display attached. |
allowMobile | boolean | false | Permit candidates on Android phones / iPads (iOS gated by enableSafari). |
Face detection
The camera step's deep check requires a detectFace callback — it is mandatory. The engine enforces face count: zero faces fails as no-face (a covered lens or an off-camera candidate both yield zero faces); more than maxFacesAllowed fails as multiple-faces. Omitting the callback fails the check as deep-check-failed rather than silently passing.
Server-side (recommended)
The repo ships a Python sidecar at packages/face-detect/ running InsightFace SCRFD + ArcFace under FastAPI. The NestJS server proxies POST /preflight/detect-face to it; the Vue helper createNestDetectFace wires the client side.
import {
ProctoringSystemCheck,
createNestDetectFace,
} from "@a4anthony/proctorkit-vue";
const detectFace = createNestDetectFace({
baseUrl: "https://api.proctor.app",
timeoutMs: 5000,
// ?embeddings=1 forwards the 512-dim ArcFace vector
// for identity matching. Default off.
includeEmbeddings: false,
});
h(ProctoringSystemCheck, {
engineOptions: {
deepCamera: { detectFace },
},
});Bring the stack up locally:
docker compose up face-detect
pnpm --filter @a4anthony/proctorkit-server devincludeEmbeddings: true when you wire identity matching against an enrolled photo.Client-side (no network)
You can supply any function matching the SDK contract (jpegDataUrl) => Promise<{ faceCount }>. For maximum privacy, run MediaPipe Tasks Vision in the browser — no upload, no server, ~2 MB of WASM cached after first visit.
import { FilesetResolver, FaceDetector } from "@mediapipe/tasks-vision";
let detectorPromise: Promise<FaceDetector> | null = null;
const detectFace = async (jpegDataUrl: string) => {
detectorPromise ??= (async () => {
const vision = await FilesetResolver.forVisionTasks("/mediapipe/wasm");
return FaceDetector.createFromOptions(vision, {
baseOptions: {
modelAssetPath: "/mediapipe/blaze_face_short_range.tflite",
},
runningMode: "IMAGE",
minDetectionConfidence: 0.5,
});
})();
const detector = await detectorPromise;
const img = await loadImage(jpegDataUrl);
return { faceCount: detector.detect(img).detections.length };
};No detector at all
Omit detectFace entirely and the deep check passes on a bright frame alone. Useful for low-stakes contexts where you trust the candidate but want the wizard's permissions+device-pick flow.
Customisation
Copy (i18n)
Every string the candidate reads lives in a typed PreflightMessages bundle. The default is English. Override any subset:
import {
DEFAULT_MESSAGES,
type PreflightMessages,
} from "@a4anthony/proctorkit-vue";
const messages: PreflightMessages = {
...DEFAULT_MESSAGES,
title: "Verificación previa",
buttonGetStarted: "Comenzar",
wizard: {
...DEFAULT_MESSAGES.wizard,
speaker: {
...DEFAULT_MESSAGES.wizard.speaker,
heardButton: "Puedo escuchar el audio",
},
},
};
h(ProctoringSystemCheck, { messages });Theme + dark mode
All wizard styles are scoped to the .pct-system-check root so they never collide with the host's own Tailwind config or plain CSS. The bundled stylesheet does not require Tailwind in the host app and does not emit global html, body, :root, :host, or --font-sans rules. Override tokens via CSS variables at the wizard root. ProctoredAssessment scopes only package-owned UI; your assessment slots render outside that root and keep your app's own font and layout.
.pct-system-check {
--color-pct-brand: oklch(0.6 0.2 250);
--color-pct-success: oklch(0.7 0.15 145);
--font-pct: "Your Brand Font", system-ui;
}For dark mode, pass dark as a prop and the wizard inverts ink + surface tokens.
h(ProctoringSystemCheck, { dark: true });Auto-resume countdown
On pass in boot mode, the final step counts down before firing onPass. Set autoResumeSeconds: 0 to require an explicit click instead.
Candidate intake
An optional schema-driven step (rendered after the camera check) that collects identity details before the test starts — date of birth, ID document uploads, a live headshot, and any custom fields. Enable it with two props on ProctoredAssessment (or ProctoringSystemCheck): candidateIntake (the field config) and submitCandidateIntake (your save handler). Omit the config and the step disappears entirely.
Configuring the fields
candidateIntake is a plain, serialisable object, so you can drive it from your backend's per-test settings. Field types: text (with uk_postcode or a custom regex pattern), date, select, checkbox, file (accept list, per-file and total size caps), and headshot (a live camera capture, verified to contain exactly one face). Mark any field pii: true so your backend knows what to treat as sensitive.
import type { CandidateIntakeConfig } from "@a4anthony/proctorkit-vue";
const candidateIntake: CandidateIntakeConfig = {
title: "Confirm your details",
description: "Required by this assessment provider before the test starts.",
fields: [
{ key: "dob", type: "date", label: "Date of birth", required: true, pii: true },
{
key: "postcode", type: "text", label: "Postcode", required: true, pii: true,
validation: { pattern: "uk_postcode" },
},
{
key: "documents", type: "file", label: "Upload ID", required: true, pii: true,
accept: ["application/pdf", "image/jpeg", "image/png"],
maxFiles: 2, maxFileSizeBytes: 10_000_000,
},
{ key: "headshot", type: "headshot", label: "Take a headshot", required: true, pii: true },
],
};The submitCandidateIntake contract
submitCandidateIntake is an async function you supply. The wizard calls it with the validated submission, awaits it, and reads success or failure from the result. You signal failure in whichever way fits:
- Return
true/false— the simplest form.falsefails with a generic message;true(or returning nothing) succeeds. - Return
{ ok: false, message }— fail and set the candidate-facing error text. throwanError— also fails; the error'smessageis shown. Use this for exceptional paths (network errors, thrown by anawait).
On any failure the wizard stays on the step, keeps the candidate's entered values and uploaded files, shows the message, and lets them retry. On success it advances and fires candidate-intake-submitted. The data goes to your backend — the proctoring platform never receives or stores intake PII; you own the endpoint, storage, retention, and encryption.
The simplest version — return the result of your save:
async function submitCandidateIntake(submission) {
const res = await saveToYourBackend(submission); // your store action / fetch wrapper
return res.ok; // true → success, false → failure (generic message)
}The full version, with per-failure messages and the one gotcha to watch — fetch() does not throw on 4xx / 5xx, it resolves with res.ok === false, so a failed request would otherwise look like success:
import type { CandidateIntakeSubmission } from "@a4anthony/proctorkit-vue";
async function submitCandidateIntake(submission: CandidateIntakeSubmission) {
// submission.values is keyed by your field keys. File / headshot values
// are arrays of { file: File, name, size, type, lastModified }, so you
// can stream the real File objects into FormData.
const form = new FormData();
for (const [key, value] of Object.entries(submission.values)) {
if (Array.isArray(value)) value.forEach((f) => form.append(`${key}[]`, f.file, f.name));
else form.append(key, String(value));
}
form.append("submittedAt", submission.submittedAt);
let res: Response;
try {
res = await fetch("/your-backend/intake", { method: "POST", body: form });
} catch {
// Network / timeout — exceptional, so throw a retryable message.
throw new Error("We couldn't reach the server. Check your connection and try again.");
}
if (!res.ok) {
// Server rejected it — return a failure outcome with your message.
const body = await res.json().catch(() => null);
return { ok: false, message: body?.message ?? "We couldn't save your details. Please try again." };
}
return { ok: true };
}You do not manage loading or error UI: the wizard shows a “Saving…” state, disables the button while awaiting, re-enables on failure, and preserves the form. You only report the outcome.
Wiring it up
<ProctoredAssessment
app-id="pk_live_xxx"
:correlation-id="attemptId"
:candidate="candidate"
preset="standard"
:candidate-intake="candidateIntake"
:submit-candidate-intake="submitCandidateIntake"
@candidate-intake-submitted="({ attempt, submission }) => markIntakeComplete(attempt)"
>
<template #default="{ client }">
<!-- your exam UI -->
</template>
</ProctoredAssessment>candidate-intake-submitted fires only on success (after your callback resolves), with the resolved attempt — the place to mark the attempt intake-complete on your backend.
Failure surfaces
Every failure code maps to a tailored recovery panel. Candidates fix the issue inline and retry without leaving the wizard. Codes appear in the engine's CheckRow.state.code and on onStepFail callbacks.
| FailCode | Row | Recovery |
|---|---|---|
unsupported-browser | browser | Switch to a supported browser. |
outdated-browser | browser | Update to a recent version. |
ios-device | device | Use desktop (gated separately by enableSafari). |
incompatible-device | device | Mobile not allowed by policy. Use a desktop. |
low-memory | device | Free RAM or switch machine. |
bad-layout | layout | Rotate to portrait on mobile; ignore on desktop. |
external-monitor | monitor | Disconnect external displays (override with allowExternalMonitor). |
offline | connection | Reconnect to a network. |
slow-connection | connection | Switch networks or move closer to the router. |
speed-test-failed | connection | Measurement endpoint unreachable; candidate passes with a flagged detail. |
permission-denied | microphone / camera | Per-browser recovery panel with exact steps to re-enable. |
permission-required | microphone / camera | Candidate has not yet granted. Wizard prompts. |
device-disconnected | microphone / camera | Reconnect device or pick another from the dropdown. |
no-device-found | microphone / camera | Connect a device. |
no-face | camera | Uncover lens, sit in front of the camera, face it. |
multiple-faces | camera | Ask anyone else out, check posters/screens behind, mirrors. |
deep-check-failed | camera | Detector errored. Candidate retries; admin watches the rate. |
screen-share-not-supported | screen-share | Switch to Chrome / Edge / Firefox; not available on mobile. |
Every observer is opt-in. Each one runs in the SDK's dedicated Worker and emits typed events to your dashboard. You can mix and match — most teams start with focus + clipboard, then add screen share and webcam once the basics work.
Focus & visibility
focus.lostfocus.gainedtab.hiddentab.visiblefullscreen.enteredfullscreen.exitedDetects when the candidate switches windows, hides the tab, or leaves fullscreen. Always on — no permissions needed.
observers: {
focus: true,
// or fine-grained:
focus: { reportTabHidden: true, reportFullscreen: true },
}Clipboard
clipboard.copyclipboard.cutclipboard.pastecontextmenu.openedCaptures clipboard actions. Set captureContent: true to record the actual text (with consent only). Add data-proctoring-allow-clipboard to fields that should opt back in to copy/paste — useful for the answer textarea.
observers: {
clipboard: {
captureContent: true,
block: true, // disable copy/cut/paste/contextmenu
maxBytes: 4_000, // truncate captured content
},
}captureContent is privacy-sensitive. Surface the policy in your test runner's UI before the candidate starts.Keyboard shortcuts
keyboard.blockedscreenshot.attemptedBlocks common print, save, find, devtools, and view-source shortcuts at the JS layer, and emits a keyboard.blocked event. Screenshot attempts (PrtSc on Windows, Cmd-Shift-3/4 on macOS) emit a dedicated event when detectable.
observers: {
keyboard: true,
screenshot: { blurOnSuspicion: true },
}Webcam
webcam.startedwebcam.stoppedwebcam.photo.capturedwebcam.recording.chunk-uploadedTwo modes: random snapshots (lightweight, ~30 KB each) or continuous recording (~225 MB/hour). The dashboard shows the snapshot grid by default and the stitched video on demand.
observers: {
webcam: {
photos: { minIntervalSeconds: 20, maxIntervalSeconds: 60 },
recording: { timesliceMs: 10_000 },
},
}Face detection
face.lostface.returnedface.multipleface.multiple-clearedface.gaze-off-screenface.gaze-restoredface.detector-readyface.detector-failedV1 face checks are handled through the preflight deep-camera check and post-session/media analysis. The demo test builder can require a face photo in preflight and can enable face-related review signals in the policy, but the current browser SDK does not expose a separate runtime face observer option.
engineOptions: {
media: { camera: true },
deepCamera: { detectFace },
}
// During-test webcam evidence is controlled separately:
observers: {
webcam: { photos: true },
}Every observer emits typed events to the same ingest endpoint. Each event has a stable id, a monotonic timestamp, and an optional payload. The dashboard groups them by category, but every line below is queryable raw. Customer webhooks intentionally expose a smaller lifecycle subset; see the webhooks section for that delivery contract.
| Event kind | Meaning | Payload |
|---|---|---|
session.started | First accepted batch for a newly resolved internal session. | — |
session.heartbeat | Activity ping while the SDK session is live. | intervalMs: number · visibilityState?: string |
session.fingerprint | Device fingerprint stamped onto the attempt for continuity checks. | fingerprintId: string |
session.ended | Terminal SDK event. Clean stops trigger analysis; abandoned stops do not. | reason: "stopped" | "abandoned" |
sdk.initialized | Privacy-safe SDK telemetry that the client boot path started. | version: string · origin?: string |
sdk.ready | Worker, observers, heartbeat, and queues are ready. | heartbeatIntervalMs: number |
sdk.error | Privacy-safe SDK failure telemetry. | code: string · phase: string · recoverable: boolean |
| Event kind | Meaning | Payload |
|---|---|---|
focus.lost | Window lost OS focus. | — |
focus.gained | Window regained focus. | — |
tab.hidden | Tab no longer visible. | — |
tab.visible | Tab visible again. | — |
fullscreen.entered | Browser fullscreen engaged. | — |
fullscreen.exited | Fullscreen exited (often a flag). | — |
| Event kind | Meaning | Payload |
|---|---|---|
clipboard.copy | Candidate copied selection. | content?: string · truncated?: boolean |
clipboard.cut | Candidate cut selection. | — |
clipboard.paste | Candidate pasted into a field. | — |
contextmenu.opened | Right-click menu opened. | — |
keyboard.blocked | Blocked shortcut intercepted. | shortcut: string |
screenshot.attempted | Likely OS-level screenshot. | trigger: "keyboard" | "hotkey" |
| Event kind | Meaning | Payload |
|---|---|---|
screen-share.started | Screen stream is active. | surface: "monitor" | "window" | "browser" |
screen-share.recording.started | Screen recording began. | timesliceMs: number · videoBitrate: number |
screen-share.stopped | Screen share ended. | reason: string |
screen-share.chunk-uploaded | A screen recording chunk uploaded. | — |
screen.window-switched | Post-session screen analysis saw a window or context switch. | — |
webcam.photo.captured | Snapshot uploaded. | — |
webcam.recording.chunk-uploaded | A continuous webcam recording chunk uploaded. | — |
video-clip.uploaded | Ad-hoc video answer uploaded. | clipNumber: number · durationMs: number |
audio-clip.uploaded | Ad-hoc speaking answer uploaded. | clipNumber: number · durationMs: number |
| Event kind | Meaning | Payload |
|---|---|---|
face.lost | No face in frame for the threshold window. | — |
face.multiple | More than one face detected. | — |
face.gaze-off-screen | Gaze direction outside the viewport for > N ms. | — |
face.lost-at-snapshot | Post-session snapshot analysis saw no face. | — |
webcam.phone-detected | Post-session webcam analysis detected a phone. | — |
| Event kind | Meaning | Payload |
|---|---|---|
network.offline | Browser reports offline. | — |
network.online | Network restored; queue flushes. | — |
App keys
App keys (prefix pk_live_) are public — they ship in your candidate's browser. They are tied to an organisation, and the server tags every incoming event with that organisation.
The full secret is shown only once at creation time. Store it in your password manager or a 1Password vault. The dashboard always shows the prefix only, after creation.
Origin allow-list
Each key has a per-origin allow-list. When origins are configured, public SDK requests from any other origin are rejected with 403. Use * only for local demos, or leave the list empty when you intentionally accept every deployed origin for that key.
# In Settings → Keys → edit
allowedOrigins: ["https://exam.acme-uni.edu", "https://staging.exam.acme-uni.edu"]Rotating & revoking
Revocation is immediate for public SDK traffic. The moment you click "Revoke", the next request using that app key is rejected. Rotate by creating a replacement key, switching your test runner to the new value, confirming events land, then revoking the old key.
Overview
Webhooks push session lifecycle events from the proctor server into your platform. Configure a destination URL in Settings → Webhooks and pick the events you care about — your endpoint receives a signed JSON POST whenever one fires.
Each delivery is signed with HMAC-SHA256 over {timestamp}.{body}. The verifier ships in the SDK so you don't need to roll your own.
Event payloads
Seven event types are deliverable. Every payload shares the envelope below; the data field carries the event-specific fields.
{
"type": "session.ended",
"emittedAt": "2026-05-26T10:32:18.014Z",
"data": { "sessionId": "sess_abc123", "reason": "stopped" }
}| Event | Fires when… |
|---|---|
| session.started | First batch lands for a previously-unseen session id. |
| session.ended | The SDK emits a terminal session event after a clean media drain or a pagehide fallback. |
| session.abandoned | A session ends with an abandoned reason, or a stale active attempt is closed before creating the next internal attempt. |
| preflight.completed | The wizard reports a final preflight verdict with passed in the payload. |
| preflight.passed | Compatibility event fired when the wizard reports a passing verdict. |
| preflight.failed | Compatibility event fired when the wizard reports a failing verdict. |
| reverification.required | A later preflight attempt landed from a different device fingerprint. |
analysis.completed is intentionally not emitted in V1. Webcam, screen, and snapshot analysis currently finish as separate stream statuses; the aggregate customer webhook should wait until the pipeline has one customer-ready completion gate.Verifying signatures
Every POST carries three identification headers:
X-Proctoring-Signature—t=<unix>,v1=<hex>wherehexisHMAC-SHA256(secret, "{t}.{body}").X-Proctoring-Event— the event type, mirroring the body'stypefield.Idempotency-Key— stable across retries. Use it to dedupe in your handler.
Use the SDK helper to verify — it's constant-time and rejects stale timestamps (default tolerance 5 minutes).
import { verifyWebhookSignature } from "@a4anthony/proctorkit-sdk";
const ok = await verifyWebhookSignature({
body: rawBodyString,
header: req.headers["x-proctoring-signature"] ?? "",
secret: process.env.PROCTOR_WEBHOOK_SECRET,
});
if (!ok) {
return res.status(401).end();
}JSON.parse and re-stringify, even a key-order change breaks the signature.Retries & replay
Non-2xx responses (or network errors) re-enqueue the delivery at the next backoff step. The schedule:
| Attempt | Wait before next try |
|---|---|
| 1 (initial) | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 12 hours |
| 6 (final) | Marked failed permanently. |
Replay window: the signed timestamp must be within 5 minutes of your server's clock. Reject older signatures even if they verify — they may be captured replays.
Sample handlers
Express:
import express from "express";
import { verifyWebhookSignature } from "@a4anthony/proctorkit-sdk";
const app = express();
app.post(
"/webhooks/proctor",
express.raw({ type: "application/json" }),
async (req, res) => {
const body = req.body.toString("utf8");
const ok = await verifyWebhookSignature({
body,
header: (req.header("x-proctoring-signature") ?? ""),
secret: process.env.PROCTOR_WEBHOOK_SECRET,
});
if (!ok) return res.status(401).end();
const event = JSON.parse(body);
// event.type, event.data.sessionId, event.emittedAt
res.status(200).end();
},
);Next.js (App Router):
// app/api/webhooks/proctor/route.ts
import { verifyWebhookSignature } from "@a4anthony/proctorkit-sdk";
export async function POST(req: Request) {
const body = await req.text();
const ok = await verifyWebhookSignature({
body,
header: req.headers.get("x-proctoring-signature") ?? "",
secret: process.env.PROCTOR_WEBHOOK_SECRET,
});
if (!ok) return new Response("unauthorized", { status: 401 });
const event = JSON.parse(body);
// event.type, event.data.sessionId, event.emittedAt
return new Response("ok", { status: 200 });
}The server API lets your backend read proctoring results and manage attempts — pull a session's outcome into your LMS, fetch the report, or let a candidate retake. It is a REST API authenticated by a secret key, and it is the pull side that complements webhooks (the push side). Base path /v1.
Two keys: publishable vs secret
Your app key has two halves, like Stripe. The publishable pk_… goes in the candidate's browser (SDK config); the secret sk_… goes only in your backend and authorizes this API. The secret can read and change your data, so it must never reach the browser — a candidate who had it could reset their own exam.
Issue one in Settings → Keys → Issue API secret. The full value is shown once — copy it into your backend secrets. Only a hash is stored; if you lose it, re-issue (the old one stops working).
Authentication
Send the secret as a Bearer token. Responses use a stable envelope.
curl https://api.example.com/v1/sessions/sess_123 \
-H "Authorization: Bearer sk_live_…"
# success → { "data": … }
# failure → { "error": "…" } (with the matching HTTP status)404, identical to one that doesn't exist (no existence leak).The model: correlationId → attempts
You issue a correlationId per exam attempt and pass it to the SDK. The system may create several sessions under one correlationId — one per attempt (a retake, a recovered abandon, a reset). So a correlationId is the attempt group (what your LMS knows); a sessionId is one attempt (what webhooks hand you). The API is correlationId-first, with sessions underneath — look up either way.
Endpoints
| GET | /v1/attempts/:correlationId | All attempts (sessions), newest first |
| GET | /v1/attempts/:correlationId/latest | Just the latest attempt — the usual “did they pass?” |
| GET | /v1/sessions | List your sessions (cursor-paginated, ?status= filter) |
| GET | /v1/sessions/search?q= | Find by id / correlationId / candidate name+email |
| GET | /v1/sessions/:id | One session: status, attempt, candidate, preflight, analysis |
| GET | /v1/sessions/:id/analysis-status | Poll whether post-session analysis finished |
| GET | /v1/sessions/:id/preflight | Preflight outcome + the full per-attempt history |
| GET | /v1/sessions/:id/report | The proctoring report PDF (binary) |
| POST | /v1/sessions/:id/reset | Reset → abandoned so the candidate can re-attempt |
| POST | /v1/attempts/:correlationId/reset-latest | Same, but resolves the latest attempt for you |
| POST | /v1/sessions/:id/reset-preflight | Clear preflight so it re-runs |
| POST | /v1/sessions/:id/retry-analysis | Re-run post-session analysis |
| DELETE | /v1/sessions/:id | Delete the session + media. Body { confirm: “<sessionId>” } |
ended session to abandoned is how a retake works: next time the candidate loads that same correlationId, a fresh attempt (n+1) is created. The original attempt's record is kept.Examples
Did my candidate pass their latest attempt?
curl https://api.example.com/v1/attempts/exam-attempt-123/latest \
-H "Authorization: Bearer sk_live_…"{
"data": {
"sessionId": "sess_…",
"correlationId": "exam-attempt-123",
"status": "ended",
"attemptNumber": 1,
"preflight": { "passed": true, "requiresReverification": false },
"analysis": { "webcam": "completed", "screen": "completed", "photo": "completed" }
}
}Let them retake, then pull the report PDF:
curl -X POST https://api.example.com/v1/attempts/exam-attempt-123/reset-latest \
-H "Authorization: Bearer sk_live_…"
curl https://api.example.com/v1/sessions/sess_…/report \
-H "Authorization: Bearer sk_live_…" -o report.pdfThe dashboard is where operators review live and historical sessions. It now includes attempt-aware session grouping, recent activity state, preflight history, media tabs, the raw event timeline, demo test builder, app-key controls, billing usage, and webhook delivery logs.
Demo test builder
Demo tests lets an owner or admin create client-demo assessments without hardcoding content in the SDK host. A demo test can include written, multiple-choice, listening multiple-choice, speaking, and video questions. Publishing a test produces a candidate link that the SDK host loads by testId.
http://127.0.0.1:5733/?appId=pk_demo_seed01&testId=customer-success-readiness&correlationId=client-demo-001The local :5733 candidate host is a Vue app that uses ProctoredAssessment. It resolves the internal attempt, runs or skips preflight from the saved policy, then renders the backend question list one question at a time. The core SDK stays framework-neutral; Vue is only the demo host and customer wrapper layer.
Candidate links created from demo tests resolve to normal internal proctoring sessions, but those sessions are marked demo. The Sessions page defaults to live sessions only; use the session-source filter to inspect demo runs or include both live and demo rows.
The builder also owns the demo assessment's proctoring policy. The policy is saved on the backend and returned by the public SDK assessment endpoint. Candidate-link query params can choose appId, testId, correlationId, and candidate identity, but they cannot turn required preflight or runtime proctoring checks off.
Listening questions are multiple-choice questions with an attached audioUrl in the question config. The SDK host plays that URL through client.playAudioFile(), so prompt playback is visible in the session timeline without storing signed URL query strings. The seeded demo uses local MP3 files under /audio/.
Speaking questions call client.recordAudioClip(). Video questions call client.recordVideoClip() with the question's configured max duration. These answer clips are separate from passive webcam or screen-share proctoring recordings.
Session states
The sessions table shows the stored session status plus a recent activity state derived from SDK heartbeats and incoming events. Demo sessions show a Demo badge when included in the list, are excluded from live usage counters, and are automatically purged after 24 hours.
| Label | Meaning |
|---|---|
| Live | The session is active and has recent activity. |
| Inactive | The session is still stored as active, but no heartbeat or event has arrived recently. |
| Abandoned | The SDK sent an abandoned terminal event, or the backend closed a stale active attempt before creating a new internal attempt for the same client correlation id. |
| Ended | The SDK ended cleanly after draining media. This is the path that queues post-session media analysis. |
Attempts & client correlation
The client-owned correlationId stays stable across retries. The backend maps that value to an internal sess_... id for each attempt. If the candidate returns after the inactivity window, the old internal attempt is marked abandoned and the same client correlation id resolves to the next attempt number.
Session detail shows the internal session id, attempt number, app-key prefix, preflight state, and Last active. When a retry chain has multiple attempts, the detail page can show the active attempt or merge all attempts into one timeline.
Integrity score
The score is a transparent formula — no vendor heuristic. Each flag category contributes a fixed penalty (focus 3, clipboard 4, face 5, exit 8) and the score is clamped to 0-100. Tune the formula in your self-hosted deploy if you want.
Filtering the timeline
Use the chips above the timeline to filter by category, or type into the search box to filter by event kind. Every event has a permalink: click the event id to share a deep link with a teammate.
Billing & quotas
Settings → Billing shows the live V1 usage model: sessions this month, event volume, media/storage usage, monthly event quota, monthly media quota, and SDK rate limit. These counters are tied to the same usage guards used by public SDK ingest and count only live sessions. Demo assessment runs stay available for review for 24 hours but do not consume monthly event or media quota.
Webhook delivery log
Settings → Webhooks lets an organisation create endpoints, select subscribed lifecycle events, pause or resume delivery, and inspect delivery attempts. The endpoint detail page shows status, attempt number, response code, response snippet, next retry time, and resend controls.
Exporting evidence
The sessions list can export the filtered result set as CSV. A session detail page can export the event timeline as CSV, download a clean PDF report, or download a PDF report that includes internal reviewer notes.
Use the production-style preview stack for demos and smoke testing.pnpm build compiles the repo, but it does not serve the dashboard on localhost:3000.
Built stack
pnpm preview builds the workspace, starts Docker, applies migrations, clears the preview host ports, starts the built server/worker/dashboard, starts Vue Storybook on :6007, and serves the Vue SDK demo host on :5733. After a successful build, use pnpm start:built to restart the same built stack without rebuilding.
pnpm preview
# after a successful build
pnpm start:built
# skip Vue SDK demo host
pnpm preview --no-sdk-host
# skip Storybook
pnpm preview --no-storybook
# inspect port conflicts manually instead of clearing them
pnpm preview --no-clear-portspnpm build while an old built dashboard is already running, restart the built stack. Next can keep serving HTML that points at chunks from the previous build.Smoke test
The V1 smoke test verifies the surfaces used in a client demo: API health, dashboard login, overview, sessions, session detail with Last active, billing quotas, app-key create/update/rotate/revoke, SDK demo launch, preflight skip, Begin test, question progression, and SDK ingest.
pnpm smoke:v1
# optional overrides
DASHBOARD_URL=http://127.0.0.1:3000 \
API_URL=http://127.0.0.1:3001 \
SDK_HOST_URL=http://127.0.0.1:5733 \
SMOKE_APP_ID=pk_demo_seed01 \
pnpm smoke:v1Client demo
The seeded demo account is demo@proctor.app / demo1234. The seeded SDK app key is pk_demo_seed01. Open the Vue SDK demo host with the seeded demo test and a client-owned correlation id to show realistic retries.
http://127.0.0.1:5733/?appId=pk_demo_seed01&testId=customer-success-readiness&correlationId=client-demo-001&email=demo.candidate@example.com&name=Demo%20CandidateThe seeded demo assessment is published from dashboard-owned seed data and has 10 questions: written, multiple-choice, speaking, and video. For client-specific demos, create or edit a test in Demo tests, choose the proctoring preset and advanced toggles, publish, then copy the candidate link. The fuller walkthrough lives in docs/V1_CLIENT_DEMO_RUNBOOK.md.
The whole stack — SDK, server, dashboard, ML worker — is open source. Run it on Hetzner CCX13 for €15/mo (the same hardware we use in production for the SaaS) or anywhere K3s runs.
git clone https://github.com/a4anthony/proctoring
cd proctoring
pnpm install
docker compose up -d --wait postgres
pnpm db:migrate
pnpm dev # dashboard, server, Vue SDK demo host, workers, Storybook
# production-style local preview
pnpm preview
pnpm smoke:v1For production, see the Terraform modules in infra/ — they provision K3s on Hetzner, a Cloudflare zone, and AWS S3 + CloudFront for media.
Why does screen share keep failing on the first attempt?
getDisplayMedia. Construct new ProctoringClient(...) from the candidate's button click when screen share is enabled. The Vue wrapper handles this by opening the picker from the dedicated Begin screen share step and, after a mid-test stop, from the "Share screen again" recovery button. If you create it from an effect or timer, the user gesture has expired.Why is my candidate getting a Safari-specific webcam error?
new ProctoringClient(...) directly from the user's click.Events arrive in the wrong order — what's happening?
The IndexedDB queue is full — does the SDK drop events silently?
queue.dropped with a count so you can alert on it.Read enough — go build something.
Sign up, drop the SDK into your test runner, and watch the first event land in your dashboard within seconds.