@incproctor/proctoring-sdk
v1.0.4
Published
Browser SDK for InCruiter proctoring. Drop it into your exam page, point it at a session your backend created, and it reports browser activity, camera/microphone/screen state and evidence to the InCruiter proctoring API.
Downloads
546
Readme
@incproctor/proctoring-sdk
Browser SDK for InCruiter proctoring. Add it to your exam page, point it at a session your backend created, and it reports browser activity, camera/microphone/screen state, and evidence to the InCruiter proctoring API.
- Framework-agnostic — plain JavaScript. Works with React, Vue, Angular, Svelte, or no framework.
- No database required on your side. Events, evidence and reports are stored and served by us.
- REST over HTTPS only. No WebSocket, no persistent connection, no socket library.
- ~15 KB gzipped (13.6 KB brotli), zero runtime dependencies.
The five-minute version
Throughout these examples,
https://api.example.comis a placeholder for the base URL InCruiter issued to you. Substitute your own —example.comis reserved for documentation and resolves to nothing, so a copied snippet fails immediately rather than half-working.
1. Your backend creates a session (never the browser — see Security):
const response = await fetch('https://api.example.com/api/v1/proctoring/browser/sessions', {
method: 'POST',
headers: {
'X-API-Key': process.env.INCRUITER_API_KEY,
'X-API-Secret': process.env.INCRUITER_API_SECRET,
'Content-Type': 'application/json',
},
body: JSON.stringify({
origin: 'https://exams.acme.com', // where your exam page is served
reference_id: 'candidate-4821', // your own id, echoed on every event
capabilities: ['events', 'evidence'],
}),
})
const { data } = await response.json() // { session_id, session_token, ... }Send data to your frontend. It is safe there: short-lived, origin-bound, single-session.
2. Your frontend starts proctoring:
import { createProctoringSession, ProctoringEvent } from '@incproctor/proctoring-sdk'
const proctoring = createProctoringSession({
baseUrl: 'https://api.example.com',
session: sessionFromYourBackend,
})
proctoring.on(ProctoringEvent.TAB_SWITCH, () => showWarning('Return to the exam'))
proctoring.onError((error) => console.warn(error.code, error.message))
await proctoring.start()3. When the exam ends:
await proctoring.stop()4. Your backend fetches the report:
GET /api/v1/proctoring/browser/sessions/{session_id}/reportThat is the whole integration. You store nothing.
Installation
npm install @incproctor/proctoring-sdkPin the version you install. It is what stops your live exam page changing under you — a release of ours should reach your candidates when you deploy, not when we publish.
Ships ESM, CJS, an IIFE browser bundle, and TypeScript definitions. No runtime dependencies.
Configuration
createProctoringSession({
baseUrl: 'https://api.example.com',
session: sessionFromYourBackend, // required
camera: { enabled: true, required: false },
microphone: { enabled: false, required: false },
screen: { enabled: false, required: false },
evidence: {
enabled: false, // off by default — nothing is captured unless you ask
intervalSec: 30,
sources: ['screen'], // or ['camera'], or both
maxDimension: 1280,
quality: 0.6,
captureOnEvent: true, // an extra frame when something high-severity happens
},
monitors: {
activity: true, // tab switching, focus, unload
fullscreen: true,
network: true,
devices: true,
clipboard: false, // OFF by default — a privacy decision, see below
print: false,
},
environment: {
enabled: true, // one snapshot at start(): OS, browser, screen, device counts
deviceLabels: true, // include device NAMES — read the note below before leaving this on
},
flushIntervalSec: 5,
maxQueueSize: 500,
heartbeatSec: 60,
logLevel: 'warn', // 'debug' | 'warn' | 'error' | 'silent'
})The environment snapshot, and what deviceLabels collects
At start() the SDK sends one environment_reported event describing the machine: operating
system, browser and version, screen and window size, and how many cameras, microphones and speakers
the browser reports. It appears in your report as an environment block, and it is what lets a
reviewer answer "was a microphone even attached, and what was this candidate sitting at?" without
reading the timeline.
deviceLabels: true collects device names, and a device name is personal data. Operating
systems name hardware after its owner, so "Jane's MacBook Pro Microphone" is an ordinary label —
it carries a name your exam never asked for, and it lands in your report and your logs. Set
deviceLabels: false to receive counts only ("2 cameras, 1 microphone"), which still answers
whether hardware was present. If you process EU personal data, this is a decision to make
deliberately rather than inherit from a default.
Two fields in the payload exist to stop a wrong reading, and you should check them before concluding anything:
labels_available— browsers blank every device name until the candidate grants media permission.falsemeans we were not allowed to look, not the hardware has no name.counts_provisional—truewhen the counts were taken before permission was granted. Amicrophones: 0alongsidecounts_provisional: truemeans we could not see, never the candidate had no microphone.
Everything in the block is marked trust: "self_reported". A user agent is trivially spoofed and
deliberately reduced by modern browsers, so this says what the machine claimed to be — useful as
context and for support, never as proof of what a candidate used. Set environment: { enabled:
false } and no snapshot is generated at all.
required: false is the default, deliberately
If a candidate denies the camera, the session continues and the refusal is recorded as a
high-severity event. Set required: true only if you genuinely want a broken webcam to block the
exam — that is an availability problem far more common than the cheating it prevents. With the
default, the decision moves to whoever reviews the report, which is where it belongs.
Clipboard monitoring is off by default
Watching a candidate's clipboard is a privacy decision that belongs to you, not to us. Turn it on deliberately. Clipboard contents are never read — only that a copy or paste happened, and roughly how many characters.
The API
| Method | What it does |
|---|---|
| start() | Requests permissions, attaches monitors, begins reporting. Returns the status. |
| stop(reason?) | Ends the session, flushes, releases everything. Idempotent. |
| pause() / resume() | Suspends capture without ending the session. |
| destroy() | Synchronous teardown for framework cleanup paths. |
| getStatus() | Current state, media status, event and evidence counters. |
| on(event, handler) | Subscribe. Returns an unsubscribe function. |
| once / off / onError | The usual companions. |
| requestScreenShare() | Must be called from a click handler. See below. |
| requestFullscreen(el?) | Same. |
| requestMicrophone() | Start the microphone later. |
| captureEvidence(reason?) | Capture and upload one frame now. |
| getCameraStream() | The live stream, if you want to show a self-view. |
| reportCustomEvent(type, detail?) | Record something your app noticed. Namespaced app.*. |
Screen sharing and fullscreen need a user gesture
Every browser requires a click or key press for getDisplayMedia() and requestFullscreen().
The SDK cannot start them for you. Wire them to your own button:
<button id="begin">Begin exam</button>
<script>
document.getElementById('begin').addEventListener('click', async () => {
await proctoring.requestFullscreen()
await proctoring.requestScreenShare()
})
</script>Events
proctoring.on(ProctoringEvent.TAB_SWITCH, (payload) => {
payload.id // unique event id
payload.timestamp // ISO-8601 wall clock
payload.elapsedMs // monotonic ms since start — immune to a clock change
payload.sessionId
payload.referenceId
payload.detail // event-specific context
})| Event | Meaning |
|---|---|
| TAB_SWITCH / TAB_RETURN | The exam tab stopped/started being displayed. Strong signal. |
| WINDOW_BLUR / WINDOW_FOCUS | Focus moved, page may still be visible. Weak signal — see below. |
| FULLSCREEN_ENTER / FULLSCREEN_EXIT | |
| PAGE_UNLOAD | Best effort — see Browser support. |
| CAMERA_STARTED / STOPPED / INTERRUPTED | Interrupted = it went away without us asking. |
| MICROPHONE_STARTED / STOPPED / INTERRUPTED | |
| SCREEN_SHARE_STARTED / STOPPED / INTERRUPTED | |
| DEVICES_CHANGED | A camera or microphone was plugged in or removed. Counts only, never labels. |
| ENVIRONMENT_REPORTED | Fires once at start: OS, browser, screen, device counts and names. See the snapshot. |
| CONNECTION_LOST / CONNECTION_RESTORED | |
| SESSION_STARTED / PAUSED / RESUMED / STOPPED / EXPIRED | |
| PERMISSION_DENIED | detail.device says which. |
| PROCTORING_ERROR | payload.error is always a ProctoringError. |
| EVIDENCE_CAPTURED / EVIDENCE_UPLOADED | |
| COPY / PASTE / CONTEXT_MENU / PRINT | Opt-in only. |
| '*' | Fires for every event above. |
Blur is not a tab switch
They are separate events because conflating them produces false accusations. A candidate on two
monitors generates WINDOW_BLUR constantly without ever leaving the exam. TAB_SWITCH is scored
high severity; WINDOW_BLUR is medium.
Error handling
proctoring.onError((error) => {
error.code // stable, branch on this
error.message // human-readable, may change — do not parse
error.recoverable // is retrying meaningful?
error.requestId // quote this in a support ticket
})Codes: INVALID_CONFIG, ALREADY_INITIALIZED, NOT_STARTED, SESSION_ALREADY_STOPPED,
BROWSER_UNSUPPORTED, INSECURE_CONTEXT, FEATURE_UNSUPPORTED, PERMISSION_DENIED,
PERMISSION_DISMISSED, DEVICE_NOT_FOUND, DEVICE_IN_USE, MEDIA_FAILED, CAPTURE_FAILED,
NETWORK_ERROR, UNAUTHORISED, SESSION_EXPIRED, SESSION_ENDED, RATE_LIMITED, SERVER_ERROR,
UPLOAD_FAILED.
Handle SESSION_EXPIRED by having your backend mint a new session — it is terminal.
Reliability
The candidate's network will fail during some exams. The SDK is built for that:
- Events are queued in memory and retried with exponential backoff and full jitter.
- Every event carries a UUID. The server de-duplicates on it, so a retry after a lost response can never turn one tab switch into two violations. This is what makes retrying safe.
- The queue is bounded (default 500). At the cap it drops the oldest low-severity event first,
so a
WINDOW_FOCUSfrom four minutes ago is discarded before aFULLSCREEN_EXITfrom just now. - High-severity events skip the batch timer and are sent immediately.
navigator.sendBeaconcarries the final flush when the tab closes — the one delivery channel the browser guarantees after a page is gone.- Nothing is written to
localStorage. A candidate's activity log must not survive on a shared machine. A refresh is itself reported, so the gap is visible rather than silently filled.
Check the service is reachable before the interview (1.0.4)
Brave with Shields up, and some ad/tracker-blocking extensions, block the SDK's requests
(net::ERR_BLOCKED_BY_CLIENT). Retrying cannot help — the request never leaves the browser — so
check first and tell the candidate:
import { checkReachable } from '@incproctor/proctoring-sdk'
const reach = await checkReachable(session.ingest_base_url || 'https://api.example.com')
if (reach !== 'ok') {
const isBrave = !!(navigator.brave && (await navigator.brave.isBrave()))
showMessage(reach === 'offline'
? 'You appear to be offline. Check your connection and try again.'
: isBrave
? 'Brave Shields is blocking the proctoring service. Turn Shields off for this site and reload, or use Chrome, Edge or Firefox.'
: 'A browser extension is blocking the proctoring service. Disable it for this site or use another browser, then reload.')
return // do not start the interview
}'ok' / 'blocked' / 'offline'. No session needed, nothing is charged. The browser console may
show a 405 for the check itself — that is the expected answer and means the service was reached.
Security
Never put your API key or secret in the browser. The SDK refuses to start if it finds one in its config, because anything your JavaScript can read, the candidate can read — and a leaked key can spend your credits and read every report you have ever run.
The session token the SDK does hold is deliberately weak by comparison:
| Property | Effect | |---|---| | Short-lived (15 min, auto-refreshed) | A stolen token dies on its own | | Bound to one origin | Cannot be replayed from another site | | Scoped to one session and one tenant | Cannot reach another candidate's or customer's data | | Limited to the capabilities you named | An events-only token cannot upload screenshots | | Revocable | Ending the session kills it before expiry |
The SDK also: sends the token in a header (never a URL, which would land in access logs and
Referer), never sends cookies, redacts tokens and image data from every log line at every level,
and requires a secure context.
More detail: docs/SECURITY.md, included in the installed package.
What a browser genuinely cannot do
Read this before promising anything to a customer. The SDK ships the list programmatically:
import { UNSUPPORTED_DETECTIONS } from '@incproctor/proctoring-sdk'A normal web page cannot detect a second physical monitor, which application the candidate switched to, a virtual machine, remote-desktop software, or screen recording. It cannot block a new tab, a copy, or a print. It cannot capture the screen without permission, and cannot force a candidate to share their whole screen rather than one window.
Browser events are advisory, not forensic — they are produced by JavaScript on the candidate's
own machine and a determined candidate can suppress or fabricate them. They are excellent against
casual cheating and worthless against a prepared adversary. The authoritative signal is the
recorded-video analysis (POST /api/v1/proctoring/analyze), which runs on our compute over media
we hold. Every stored browser event is stamped trust: "advisory" so no downstream consumer can
forget.
Full detail, per browser: docs/BROWSER_SUPPORT.md, included in the installed package.
Framework integration
React — note the cleanup, which is what stops StrictMode double-rendering from doubling events:
useEffect(() => {
const proctoring = createProctoringSession({ baseUrl, session })
proctoring.start().catch(console.error)
return () => proctoring.destroy()
}, [session.session_id])Vue: onMounted / onUnmounted. Angular: ngOnInit / ngOnDestroy. Runnable examples for
React, vanilla JS and a Node backend are in the examples/ folder of the installed package.
Browser support
| | Chrome/Edge 90+ | Firefox 90+ | Safari 15+ | iOS Safari | |---|---|---|---|---| | Activity monitoring | ✅ | ✅ | ✅ | ✅ | | Camera / microphone | ✅ | ✅ | ✅ | ✅ | | Screen sharing | ✅ | ✅ | ✅ | ❌ not available | | Permission revocation | ✅ | ✅ | ⚠️ no Permissions API | ⚠️ | | Fast frame capture | ✅ | ⚠️ fallback | ⚠️ fallback | ⚠️ |
Testing
Three layers, because none of them is sufficient alone. Each one catches what the layer below cannot.
Unit — 134 tests. State machine, retry, de-duplication, queue eviction, token lifetime, error
mapping, log redaction. The fakes model behaviour, not just shape: a fake media track really
transitions state and really fires ended / mute / unmute, and the fake transport implements
the real response envelope including its error codes. A stub that always resolved would let the
tests pass while every failure path went untested — and those paths are the product.
Browser — 12 tests, real Chromium. Only the things a simulated DOM cannot answer:
- the shipped
dist/bundle loads and works — every unit test imports the source, so a broken build would pass all 134 of them - a real
getUserMediatrack opens, andstop()really ends it (the camera light goes out) - a real JPEG is produced — asserted on its
ffd8ffmagic bytes and on size staying in budget - the session token never appears in any real request URL
- a real page reload does not double-count events
- duplicate initialisation is refused in a real browser
Live — 4 tests, against a running proctoring API. The layers above prove the SDK sends the right shape; this one proves the shape survives the journey. Every assertion reads the report endpoint, so an event counts only once it has been authenticated with a real session token, de-duplicated, severity-stamped server-side and stored. That difference is not theoretical — two bugs lived happily against a mock backend and died here: the final unload beacon never arriving cross-origin, and a 60-second outage silently discarding seven queued events, one of them a tab switch.
What no automated layer covers
A genuine browser-initiated tab switch. Headless Chromium cannot background a tab for real, so the test drives the transition from inside the page. That still proves the listener is attached, that the high-severity path skips the batch timer, and that a real request lands — but that the browser fires the event when a person really switches tabs is behaviour we depend on rather than implement. Confirm it with a headed run before a release.
Real permission prompts, and Safari/Firefox, need a human. The checklist we run:
- Deny the camera at the prompt → the session continues,
PERMISSION_DENIEDfires, event recorded. - Revoke the camera mid-exam in site settings →
CAMERA_INTERRUPTED. - Press the browser's own "Stop sharing" →
SCREEN_SHARE_STOPPED, high severity. - Unplug the webcam →
CAMERA_INTERRUPTED. - Run the whole thing in Safari → no Permissions API, so check revocation still surfaces via the track ending.
Support
Every API response carries X-Request-Id, surfaced as error.requestId. Quote it — it finds the
exact server-side log line.
