In InCruiter Proctoring Docs
◆ Browser SDK

SDK Reference

One factory, one session object, and a set of events you subscribe to. The SDK handles token refresh, batching, retries and reconnection on its own.

Install

npm install @incproctor/proctoring-sdk
yarn add @incproctor/proctoring-sdk
pnpm add @incproctor/proctoring-sdk
<script src="https://cdn.jsdelivr.net/npm/@incproctor/proctoring-sdk"></script>
<!-- exposes window.InCruiterProctoring -->

The package ships TypeScript types. Named exports only — there is no default export.

import {
  createProctoringSession,   // the factory you call
  ProctoringSession,         // the class it returns
  ProctoringEvent,           // event name constants
  ProctoringError,           // the error type
  checkSupport,              // browser capability probe
  VERSION,
} from '@incproctor/proctoring-sdk'

Lifecycle

Four steps, and only the first happens on your server.

  1. Your backend mints a session

    Your server calls the mint endpoint and receives session_id and session_token. See the quickstart.

    Your API key and secret never enter the browser. Only the minted session belongs client-side — it expires, and it works for one candidate on one origin.

  2. Create the session in the browser

    Creating it does not start monitoring or request any permission.

    const proctoring = createProctoringSession({
      baseUrl: INCPROC_BASE,   // the same base URL your server uses
      session,                 // exactly what your backend received
      camera:   { enabled: true, required: false },
      evidence: { enabled: true, intervalSec: 15, sources: ['camera'], maxDimension: 640 },
    })
  3. Start it

    start() is where permissions are requested and monitoring begins. Call it from a user gesture — browsers refuse camera access otherwise.

    const status = await proctoring.start()
    console.log(status.state)   // 'active'
  4. Stop it when the candidate finishes

    Flushes anything queued and closes the session cleanly.

    await proctoring.stop()

    If the tab simply closes, the session is detected and closed server-side — but the explicit call is cleaner and settles the credit sooner.

Browser support

Check before you create a session, so you can tell the candidate what will not work before the exam rather than during it.

import { checkSupport, UNSUPPORTED_DETECTIONS } from '@incproctor/proctoring-sdk'

const support = checkSupport()
if (!support.supported) {
  // Tell the candidate to switch browser before they begin.
}

The SDK requires a modern browser with getUserMedia, fetch and the Page Visibility API. It runs only in a secure context — HTTPS, or localhost. On plain http:// the browser blocks camera access outright.

Configuration

createProctoringSession(config)

OptionTypeNotes
baseUrlstringRequired. The base URL issued to you. No trailing path.
sessionobjectRequired. Exactly what your backend received from the mint endpoint.
cameraCameraOptionsSee below. Enabled unless you set enabled: false.
microphoneMicrophoneOptionsSame shape as camera. Off unless you set enabled: true.
screenScreenShareOptionsScreen sharing. The candidate chooses what to share; the SDK reports which.
evidenceEvidenceOptionsPeriodic stills. Off unless you set enabled: true.
monitorsMonitorOptionsWhich browser activity to watch. See the defaults below — they are not all the same.
environmentEnvironmentOptionsOne system/device snapshot at start(). On by default.
flushIntervalSecnumberHow often the queue is drained. Default 5, minimum 1. High-severity events flush immediately regardless.
maxQueueSizenumberCap on unsent events held in memory; past it the oldest are dropped. Default 500.
heartbeatSecnumberLiveness ping interval. Default 60, minimum 30.
logLevelstringwarn (default), error, debug, silent.
fetchImplfunctionCustom fetch, for test harnesses and instrumented clients.

The SDK never logs the session token, a media frame, or event detail at any log level. debug adds timing and transport information only.

session — SessionCredentials

Pass the mint response through unchanged. The field names are the API's, in snake_case; do not rename them to camelCase on the way.

FieldTypeNotes
session_idstringRequired.
session_tokenstringRequired. Scoped to this one session. Never send your API key to the browser.
token_expires_atstringISO 8601.
session_expires_atstringISO 8601. After this the session is expired and cannot be resumed.
capabilitiesstring[]What this session is permitted to do.
reference_idstring | nullYour own identifier, echoed back on the report.
ingest_base_urlstring | nullWhere events go, when it differs from baseUrl.

camera / microphone — CameraOptions, MicrophoneOptions

FieldTypeDefaultNotes
enabledbooleancamera true, microphone falseThe two differ. Set microphone.enabled: true explicitly if you want audio.
requiredbooleanfalsetrue makes start() reject if the candidate denies the permission.
constraintsMediaTrackConstraintsStandard DOM constraints, passed to getUserMedia as-is.

screen — ScreenShareOptions

start() never asks for screen sharing. You must call requestScreenShare() yourself, from a click handler. Every browser requires a user gesture for getDisplayMedia, and start() can be called from anywhere, so the SDK cannot do it for you. Configuring screen and never calling requestScreenShare() means screen sharing simply never happens — with no error. This is the single most common integration mistake with this SDK.

FieldTypeDefaultNotes
enabledbooleanHas no effect. It exists on the type, but screen sharing starts only via requestScreenShare(). Setting it does not start anything.
requiredbooleanfalsetrue makes requestScreenShare() throw if the candidate refuses, instead of resolving false.
displaySurface'monitor' | 'window' | 'browser''monitor'Which surface the picker offers first. A hint — the candidate still chooses.
preferCurrentTabbooleanSet false to exclude the exam's own tab from the picker, pushing the candidate toward the whole screen. Chromium-only; ignored elsewhere. Any other value does nothing.

You cannot force a candidate to share their whole screen, and you cannot stop them pressing the browser's own "Stop sharing" button. Both are browser-enforced. Read getStatus().screen.displaySurface to see what they actually picked, and handle SCREEN_SHARE_STOPPED rather than assuming sharing lasts the whole exam.

evidence — EvidenceOptions

FieldTypeDefaultNotes
enabledbooleanfalseMust be true for any still to be captured.
intervalSecnumber30Seconds between periodic stills.
sources('screen' | 'camera')[]screen if sharing, else cameraWhat to capture from.
maxDimensionnumber1280Longest edge in pixels; the still is scaled down to fit.
qualitynumber0.6JPEG quality, 01.
captureOnEventbooleantrueAlso capture a still when a significant event fires, not only on the timer.

monitors — MonitorOptions

FieldDefaultWatches
activityonTab switches, window focus, page unload.
fullscreenonEntering and leaving fullscreen.
networkonConnection lost and restored.
devicesonCameras and microphones attached or removed.
clipboardoffCopy, paste and right-click. Opt in with clipboard: true.
printoffPrint attempts. Needs clipboard: true as well — it is carried by the same monitor.

Clipboard and print are off by default on purpose. They are the most privacy-sensitive signals the SDK can collect, so turning them on is your decision to make and to disclose to candidates — not a default they inherit.

environment — EnvironmentOptions

FieldTypeDefaultNotes
enabledbooleantrueThe one-off OS / browser / screen / device-count snapshot at start().
deviceLabelsbooleantrueInclude human-readable device names. Set false to record counts only.

To switch the snapshot off, write environment: { enabled: false } — an object, not a boolean. environment: false does not disable it; the SDK reads environment.enabled, and a boolean has no such property, so the snapshot still fires.

Methods

Lifecycle

MethodReturnsWhat it does
start()Promise<ProctoringStatus>Requests camera/microphone permissions and begins monitoring. Call from a user gesture. Does not request screen sharing or fullscreen.
stop(reason?)Promise<void>Flushes the queue and ends the session. reason defaults to 'completed'. Terminal — a stopped session cannot be restarted.
pause()Promise<void>Suspends capture. Events still record — a paused session still explains why it was paused.
resume()Promise<void>Resumes capture.
destroy()voidReleases media tracks and listeners. Call on unmount. Does not end the session server-side — call stop() first.

pause() suppresses capture, not the timeline. If a candidate is allowed a break, pause rather than stop — stopping ends the session and it cannot be resumed.

Permissions you must ask for yourself

These need a user gesture — call them from a click or key-press handler, never on page load or inside a useEffect. The browser silently refuses otherwise.

MethodReturnsWhat it does
requestScreenShare()Promise<boolean>Opens the screen picker. false if the candidate refuses — or throws, if screen.required is set.
requestFullscreen(element?)Promise<boolean>Puts the page into fullscreen. Defaults to the whole document.
requestMicrophone()Promise<boolean>Starts the microphone later, if you did not enable it at start().

Evidence & media

MethodReturnsWhat it does
captureEvidence(reason?)Promise<void>Takes one still now, outside the schedule. reason defaults to 'manual'. Throws INVALID_CONFIG if evidence.enabled is not set.
getCameraStream()MediaStream | undefinedThe live camera stream, for showing the candidate their own preview.

Events & state

MethodReturnsWhat it does
on(name, handler)UnsubscribeSubscribe. Returns a function that removes the handler.
once(name, handler)UnsubscribeSubscribe for one firing only, then auto-unsubscribe.
off(name, handler?)voidUnsubscribe. Omit the handler to remove all for that event.
onError(handler)UnsubscribeShorthand for PROCTORING_ERROR. The handler receives (error, payload) with the ProctoringError already unwrapped.
reportCustomEvent(type, detail?)voidRecord your own event on the session timeline — "question 4 opened", "calculator used". See the note below.
getStatus()ProctoringStatusThe full current state, synchronously.
sessionStateSessionStateGetter. Just the state string, when you do not need the whole status object.

requestScreenShare(), requestMicrophone() and captureEvidence() throw if the session is not running. Check sessionState === 'active' first if you call them from UI that can outlive the session. requestFullscreen() does not check, because fullscreen is a property of your page rather than of the session.

Custom events are deliberately constrained. type is prefixed with app., lower-cased to a–z 0–9 _ and truncated to 48 characters, so your event can never collide with or impersonate one of the SDK's own. The server accepts unknown types — an older gateway must not reject a newer SDK — but always scores them low severity. You cannot raise a candidate's risk score from the browser, by design. reportCustomEvent also does nothing at all unless the session is active or paused; it returns silently rather than throwing.

Events

Subscribe with the constant, never a raw string — the constants are what your editor autocompletes and your compiler checks.

import { ProctoringEvent } from '@incproctor/proctoring-sdk'

const off = proctoring.on(ProctoringEvent.TAB_SWITCH, (payload) => {
  showWarning('Please stay on the exam tab.')
})

// Everything, for logging or a live activity feed:
proctoring.on(ProctoringEvent.ANY, (payload) => console.debug(payload))

off()   // unsubscribe

Activity

ConstantFires when
TAB_SWITCHThe exam stopped being the visible tab. The core signal.
TAB_RETURNThey came back. Carries how long they were away.
WINDOW_BLURFocus lost, but the page may still be visible — a second monitor, a notification.
WINDOW_FOCUSFocus returned.
FULLSCREEN_ENTER / FULLSCREEN_EXITFullscreen changed.
PAGE_UNLOADThe page is closing. A final flush is attempted.
COPY / PASTE / CONTEXT_MENU / PRINTThe corresponding action occurred.

Blur and tab-switch are scored differently on purpose. A candidate on two monitors generates blur constantly without ever leaving the exam. Treating that as "left the test" would flag every one of them.

Media

ConstantFires when
CAMERA_STARTED / CAMERA_STOPPEDThe camera track began or ended.
CAMERA_INTERRUPTEDThe track died unexpectedly — unplugged, taken by another app.
MICROPHONE_STARTED / _STOPPED / _INTERRUPTEDThe same, for audio.
SCREEN_SHARE_STARTED / _STOPPED / _INTERRUPTEDThe same, for screen sharing.
DEVICES_CHANGEDA camera or microphone was attached or removed.
PERMISSION_DENIEDThe candidate refused a permission.

Session & connection

ConstantFires when
SESSION_STARTED / _PAUSED / _RESUMED / _STOPPEDThe session changed state.
SESSION_EXPIREDThe session outlived its TTL. Monitoring has stopped — mint a new one to continue.
CONNECTION_LOST / CONNECTION_RESTOREDThe network dropped or returned. Events queue meanwhile and are resent.
ENVIRONMENT_REPORTEDThe one-off system snapshot was sent.
EVIDENCE_CAPTURED / EVIDENCE_UPLOADEDA still was taken, then stored.
PROCTORING_ERRORSomething failed. Carries a ProctoringError.
ANYWildcard — receives every event above.

These are advisory signals, not proof. They are produced by JavaScript on the candidate's own machine and can be suppressed or fabricated. Use them to route an interview to a human reviewer — never to fail a candidate automatically. See What a finding means.

Status & state

const status = proctoring.getStatus()
// {
//   state:       'idle' | 'starting' | 'active' | 'paused' | 'stopping' | 'stopped' | 'expired',
//   sessionId:   'pbs_…',
//   referenceId: 'your-own-id' | null,
//   connection:  'online' | 'offline' | 'degraded',
//   elapsedSec:  412,
//
//   camera:      { active: true,  permission: 'granted', deviceLabel: 'FaceTime HD Camera' },
//   microphone:  { active: false, permission: 'prompt' },
//   screen:      { active: true,  permission: 'granted', displaySurface: 'monitor' },
//
//   events:      { generated: 37, sent: 35, queued: 2, dropped: 0 },
//   evidence:    { captured: 12, uploaded: 12, failed: 0 },
//
//   hidden:      false,   // is the exam tab currently hidden?
//   fullscreen:  true,
// }

The three media entries are MediaStatus objects, each carrying active, permission (granted | denied | prompt | unknown), an optional deviceLabel, an error string when something went wrong, and — for screen only — displaySurface, which is what the candidate actually chose to share.

sessionState is a shorthand getter returning just the state string.

dropped is the number you should alert on. Events are dropped only when the queue hit maxQueueSize while offline — meaning some of this session's activity was never recorded and never will be. A non-zero dropped is a reason to treat the report as incomplete, not merely a performance note.

Errors

Everything the SDK throws is a ProctoringError carrying a stable code. Branch on the code, never the message — messages get reworded, codes do not.

import { ProctoringError } from '@incproctor/proctoring-sdk'

try {
  await proctoring.start()
} catch (err) {
  if (err instanceof ProctoringError) {
    console.error(err.code, err.message, err.requestId)
  }
}

What a ProctoringError carries

PropertyTypeNotes
codeProctoringErrorCodeStable. Branch on this.
messagestringHuman-readable. Never branch on it — it gets reworded.
recoverablebooleanIs retrying meaningful? false for a denied permission or an ended session.
requestIdstringQuote this in a support ticket. It locates the exact server-side log for this failure. Present whenever the error came from an API response.
serverCodestringThe API's own error code, when this error came from a response. Finer-grained than code.
contextstringWhat the SDK was doing, e.g. 'camera.start'.
reachedServerbooleanDid the request arrive at all? See below.
causeunknownThe underlying DOMException or TypeError, kept for diagnosis. Never logged by the SDK.

reachedServer: false means you learned nothing about your request — only about the network. Offline, DNS failure, connection refused, timeout. Retrying is reasonable. true means the server saw it and rejected it, so retrying the same request unchanged will fail the same way. It is left undefined for errors that never involved a request, such as a denied permission or a bad config.

Log requestId on every failure. It is the difference between a support conversation that starts with "this exact request, here is what the server did" and one that starts with "some time yesterday afternoon".

Every code

Grouped by who has to do something about it.

Your integration — fix these in code

CodeMeans
INVALID_CONFIGA required option is missing or malformed. Usually baseUrl or a session that was not passed through verbatim.
ALREADY_INITIALIZEDstart() called on a session that is already running.
NOT_STARTEDA method that needs a live session was called before start().
SESSION_ALREADY_STOPPEDThe session was stopped. Create a new one — a stopped session cannot restart.

The candidate's environment — tell them what to change

CodeMeans
BROWSER_UNSUPPORTEDMissing an API the SDK needs. Call checkSupport() first and steer them to a supported browser.
INSECURE_CONTEXTThe page is plain http://. Camera access is impossible — serve over HTTPS.
FEATURE_UNSUPPORTEDOne capability is unavailable here, e.g. screen share on some mobile browsers.

Permissions and devices — recoverable by the candidate

CodeMeansDo
PERMISSION_DENIEDThey refused, or the browser has a stored block.Explain how to re-enable it in the address bar.
PERMISSION_DISMISSEDThey closed the prompt without answering.Offer a retry button — this one is just a re-ask.
DEVICE_NOT_FOUNDNo camera or microphone is attached.Ask them to connect one and retry.
DEVICE_IN_USEAnother application holds the device.Ask them to close the video call and retry.

Media

CodeMeans
MEDIA_FAILEDA track could not be acquired or died unexpectedly.
CAPTURE_FAILEDAn evidence still could not be taken. Monitoring continues.

Transport and session

CodeMeansDo
NETWORK_ERRORA request could not be delivered.Nothing — events queue and resend. Show a reconnecting banner.
UNAUTHORISEDThe token was rejected. Most often an origin mismatch.Check origin at mint matches the page exactly.
SESSION_EXPIREDPast its TTL. Monitoring has stopped.Mint a fresh session and start again.
SESSION_ENDEDEnded from your backend while the browser was still running.Treat as final.
RATE_LIMITEDThe per-session event or evidence ceiling was hit.Nothing — the SDK backs off. See limits.
SERVER_ERROROur side failed.The SDK retries. Persisting means an incident — quote the time.
UPLOAD_FAILEDAn evidence image could not be stored. The event itself was recorded.Nothing.

The single most common failure is UNAUTHORISED from an origin mismatch — the session was minted for a different origin than the page it runs on. The token is bound to one origin and refused anywhere else, and the message does not say so. https://exams.example.com and https://exams.example.com/ are the same; a different subdomain, port or scheme is not.

Permissions

Three rules decide whether camera access will work at all, and none of them are ours:

Secure contextHTTPS, or localhost. Plain http:// is blocked by the browser.
User gestureCall start() from a click. A prompt on page load is suppressed by most browsers.
Iframe permissionsIf your exam runs in an iframe, it needs allow="camera; microphone; display-capture" — otherwise access is denied with no prompt shown.

Decide what a denied camera means to you. With required: false the session continues and the refusal is recorded as a finding. With required: true, start() rejects and you must handle it. Most customers use false and let a reviewer judge.

Patterns

React

useEffect(() => {
  let session
  let cancelled = false

  createExamSession().then((minted) => {        // your backend
    if (cancelled) return
    session = createProctoringSession({ baseUrl: INCPROC_BASE, session: minted })
    session.on(ProctoringEvent.TAB_SWITCH, () => setWarning(true))
    return session.start()
  })

  return () => {
    cancelled = true
    // destroy() releases camera tracks — without it the light stays on.
    session?.destroy()
  }
}, [])

Warn, don't punish

let awayCount = 0
proctoring.on(ProctoringEvent.TAB_SWITCH, () => {
  awayCount += 1
  if (awayCount === 1) showBanner('Please stay on this tab.')
  if (awayCount === 3) showBanner('Repeated tab switching is recorded in your report.')
  // Do NOT end the exam here. The signal is advisory.
})

Survive a dropped network

proctoring.on(ProctoringEvent.CONNECTION_LOST, () => setBanner('Reconnecting…'))
proctoring.on(ProctoringEvent.CONNECTION_RESTORED, () => setBanner(null))

Nothing else is needed — events queue while offline and are resent on reconnect. Delivery is at-least-once and de-duplicated server-side.

Handle an expired session

proctoring.on(ProctoringEvent.SESSION_EXPIRED, async () => {
  // Monitoring has stopped. Mint a fresh session on your backend and start again.
  const minted = await createExamSession()
  proctoring = createProctoringSession({ baseUrl: INCPROC_BASE, session: minted })
  await proctoring.start()
})