Proctoring that stands up to scrutiny.
Watch a candidate's browser live, analyse the recording afterwards, and get one report where every finding links to the evidence behind it.
Overview
There are two halves, and most customers use both for the same candidate.
Live browser proctoring
Our SDK runs on your exam page and reports tab switches, focus loss and camera evidence while the candidate is sitting the test.
Recorded analysis
Send a finished recording. We return looking-away, face and device findings, each with a proof frame.
They join on one identifier. You mint a session before the exam, pass its id to the SDK, and send the same session id when you submit the recording. That is what puts both halves in one report — and it is the step people most often miss.
session_id + tokenthe SAME
session_idcall_back_urlStep 4 is the one that gets missed. Send a different identifier — or none — and you get two unrelated records instead of one report.
One interview costs one credit, whether you use one half or both. Nothing is charged if the analysis never completes. See Credits.
What a finding is, and what it is not
Read this before you build anything that acts on a report automatically.
Browser signals are produced by JavaScript running on the candidate's own machine.
A determined candidate can suppress or fabricate them. Every browser event we return is
stamped trust: "advisory" for exactly this reason.
They are supporting context. They are never proof that a person cheated.
The same caution applies, more weakly, to the recorded analysis: a looking-away interval means
the model measured sustained gaze away from the screen, not that the candidate was reading
something. That is why every finding carries a proof_frame — so a human can look at
the image and decide.
Design your flow so a finding routes an interview to a reviewer, rather than failing a candidate on its own. Anything else is a decision you cannot defend if it is challenged.
Quickstart
A working integration is three calls. Everything else is detail.
-
Mint a session on your server
This is the only endpoint you must build against. It returns a short-lived token scoped to one candidate and one page.
curl -X POST $INCPROC_BASE/api/v1/proctoring/browser/sessions \ -H "X-API-Key: $INCPROC_KEY" \ -H "X-API-Secret: $INCPROC_SECRET" \ -H "Content-Type: application/json" \ -d '{ "origin": "https://exams.yourcompany.com", "reference_id": "candidate-4821", "capabilities": ["events", "evidence"], "ttl_seconds": 7200 }'const BASE = process.env.INCPROC_BASE // given to you with your keys const res = await fetch( `${BASE}/api/v1/proctoring/browser/sessions`, { method: 'POST', headers: { 'X-API-Key': process.env.INCPROC_KEY, 'X-API-Secret': process.env.INCPROC_SECRET, 'Content-Type': 'application/json', }, body: JSON.stringify({ origin: 'https://exams.yourcompany.com', reference_id: 'candidate-4821', capabilities: ['events', 'evidence'], ttl_seconds: 7200, }), } ) const { data } = await res.json() // Hand data.session_id + data.session_token to the browser. Nothing else.import os, requests BASE = os.environ["INCPROC_BASE"] # given to you with your keys res = requests.post( f"{BASE}/api/v1/proctoring/browser/sessions", headers={ "X-API-Key": os.environ["INCPROC_KEY"], "X-API-Secret": os.environ["INCPROC_SECRET"], }, json={ "origin": "https://exams.yourcompany.com", "reference_id": "candidate-4821", "capabilities": ["events", "evidence"], "ttl_seconds": 7200, }, timeout=30, ) session = res.json()["data"]$base = getenv('INCPROC_BASE'); // given to you with your keys $ch = curl_init($base . '/api/v1/proctoring/browser/sessions'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'X-API-Key: ' . getenv('INCPROC_KEY'), 'X-API-Secret: ' . getenv('INCPROC_SECRET'), 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'origin' => 'https://exams.yourcompany.com', 'reference_id' => 'candidate-4821', 'capabilities' => ['events', 'evidence'], 'ttl_seconds' => 7200, ]), ]); $session = json_decode(curl_exec($ch), true)['data'];var body = """ {"origin":"https://exams.yourcompany.com", "reference_id":"candidate-4821", "capabilities":["events","evidence"], "ttl_seconds":7200} """; var request = HttpRequest.newBuilder() .uri(URI.create(System.getenv("INCPROC_BASE") + "/api/v1/proctoring/browser/sessions")) .header("X-API-Key", System.getenv("INCPROC_KEY")) .header("X-API-Secret", System.getenv("INCPROC_SECRET")) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); var response = HttpClient.newHttpClient() .send(request, HttpResponse.BodyHandlers.ofString());originmust exactly match the page the exam is served from. The token is bound to it and refused anywhere else — and the401the browser gets does not explain why.https://exams.yourcompany.comandhttps://exams.yourcompany.com/are the same;http://and a different subdomain are not.If your account is restricted to specific domains, mint fails with
422until the origin is on that list. The message says so:{ "error": { "code": "VALIDATION_FAILED", "message": "Origin https://exams.yourcompany.com is not permitted to host a proctoring session on this deployment. Contact support to have it added." } }This is opt-in per account, not a gate everyone passes through. Most accounts have no restriction and any origin works from the moment your keys are issued. If yours has one — worth asking for, since it means a leaked key cannot mint sessions from somebody else's page and spend your balance — then every domain you mint from has to be registered first, including staging and preview URLs.
Ask your account manager which applies to you before you start, and send the full list of domains at once. It is the one thing that can stop the very first call working, and the error is the only place it is visible.
-
Start the SDK in the candidate's browser
Pass the minted session straight through. The SDK handles token refresh, batching and reconnection on its own.
npm install @incproctor/proctoring-sdkimport { createProctoringSession } from '@incproctor/proctoring-sdk' // `session` is exactly what your server received in step 1. const proctoring = createProctoringSession({ baseUrl: INCPROC_BASE, // the same base URL your server uses session, camera: { enabled: true, required: false }, evidence: { enabled: true, intervalSec: 15, sources: ['camera'], maxDimension: 640 }, }) await proctoring.start() // When the candidate clicks "Finish" — this is what closes the session cleanly. await proctoring.stop()<script src="https://cdn.jsdelivr.net/npm/@incproctor/proctoring-sdk"></script> <script> const proctoring = InCruiterProctoring.createProctoringSession({ baseUrl: INCPROC_BASE, session: MINTED_SESSION, camera: { enabled: true, required: false }, }) proctoring.start() </script>Never put your API key or secret in the browser. That credential reads every report your account has ever produced. Only the minted
session_tokenbelongs client-side — it expires, and it only works for one candidate on one origin. -
Submit the recording when the interview ends
Send the same
session_idfrom step 1. That is what merges the live signals and the recorded analysis into one report.curl -X POST $INCPROC_BASE/api/v1/proctoring/video/analyze \ -H "X-API-Key: $INCPROC_KEY" \ -H "X-API-Secret: $INCPROC_SECRET" \ -H "Content-Type: application/json" \ -d '{ "file_url": "https://your-storage/recordings/abc.mp4", "start_time": "2026-09-14T10:00:00", "end_time": "2026-09-14T10:45:00", "room_id": "room-4821", "interview_id": "interview-4821", "session_id": "pbs_XXXXXXXXXXXXXXXX", "call_back_url": "https://yourcompany.com/hooks/proctoring" }'await fetch(`${BASE}/api/v1/proctoring/video/analyze`, { method: 'POST', headers: { 'X-API-Key': process.env.INCPROC_KEY, 'X-API-Secret': process.env.INCPROC_SECRET, 'Content-Type': 'application/json', }, body: JSON.stringify({ file_url: recordingUrl, // must be reachable by us start_time: '2026-09-14T10:00:00', end_time: '2026-09-14T10:45:00', room_id: 'room-4821', interview_id: 'interview-4821', session_id: session.session_id, // ← the id from step 1 call_back_url: 'https://yourcompany.com/hooks/proctoring', }), })requests.post( f"{BASE}/api/v1/proctoring/video/analyze", headers={ "X-API-Key": os.environ["INCPROC_KEY"], "X-API-Secret": os.environ["INCPROC_SECRET"], }, json={ "file_url": recording_url, "start_time": "2026-09-14T10:00:00", "end_time": "2026-09-14T10:45:00", "room_id": "room-4821", "interview_id": "interview-4821", "session_id": session["session_id"], # the id from step 1 "call_back_url": "https://yourcompany.com/hooks/proctoring", }, timeout=30, )We call your
call_back_urlwhen the report is ready. Do not poll — see Webhooks.
Authentication
Base URL
Every example on this page reads the base URL from INCPROC_BASE. There is one
per environment, and your account manager gives it to you with your keys. Keep
it in configuration rather than hardcoding it — the value differs between your test and
production deployments, and a literal in the source is how test traffic ends up in production.
export INCPROC_BASE="https://…" # from your account manager
export INCPROC_KEY="pk_…"
export INCPROC_SECRET="sk_…"
Your keys
You receive a key pair when your account is set up. There is nothing to request and nothing to configure — the pair arrives with your base URL, from your InCruiter account manager.
Every server-to-server call carries both, as headers.
| Header | Value |
|---|---|
X-API-Key | Identifies your account. Not secret on its own. |
X-API-Secret | The matching secret. Put it in your secret manager, never in source control. |
You are given a separate pair per environment. A key issued for one will not work against another — that separation is deliberate, and it is what stops a test integration reaching production data.
Both values are server-side only. The secret reads every report your account has ever produced, so it must never reach a browser, a mobile app, or anything a candidate can open. The only thing that belongs client-side is the short-lived session token your backend mints per candidate.
Lost the secret, or need it changed? Ask your account manager. Only a hash of it is kept, so it cannot be read back to you — a fresh pair is issued and the old one stops working.
Live browser proctoring
Once start() is called, the SDK reports these on its own. You do not wire them up.
| Signal | Fires when | Severity |
|---|---|---|
tab_hidden | The exam stopped being the visible tab — switching tabs, minimising, another app covering it. | High |
tab_visible | They came back. Carries away_ms. | Info |
window_blur | Focus was lost but the page may still be visible — a second monitor, a notification. | Medium |
window_focus | Focus returned. | Info |
Blur and tab-hidden 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.
Ending the session
Call stop() when the candidate finishes. It flushes anything queued and closes
the session. If the tab simply closes, we detect it and close the session ourselves — but the
explicit call is cleaner and settles the credit sooner.
Recorded interview analysis
Submit a finished recording and we return findings grouped into three kinds, each with a proof frame — the actual image the finding was made from.
| Finding | Meaning |
|---|---|
looking_away_intervals | Sustained gaze away from the screen, measured against the candidate's own neutral pose. |
face_issue_intervals | No face, more than one face, or a partially detected face. |
device_intervals | A phone or second device detected in frame. |
Required fields
| Field | Notes |
|---|---|
file_url | Must be reachable by us. A presigned URL is fine and preferred — see Retention. |
session_id | Required. The session from step 1. Without it the recording cannot be joined to the live signals. |
interview_id | Stable for the life of the interview. We deduplicate on it, so a retry returns the existing job instead of paying for the analysis twice. |
call_back_url | Where we deliver the result. Must be HTTPS and publicly reachable. |
Retries are safe. Submitting the same interview_id twice
returns the original job_id with "deduplicated": true. You are
charged once. Pass "regenerate": true if you genuinely want it re-run.
Fetching the report
When the webhook tells you a job is completed, fetch it with your API key:
curl "$INCPROC_BASE/api/v1/proctoring/video/results/$JOB_ID" \
-H "X-API-Key: $INCPROC_KEY" -H "X-API-Secret: $INCPROC_SECRET"
Fetch it promptly, and store your own copy. A completed report is retained
for a limited window — on the default storage mode that is three hours, after
which this endpoint answers 202 pending for that job and the report is gone. It is
not recoverable, and re-running the analysis costs another credit.
Fetch inside your webhook handler, write it to your own database, and never rely on us as your system of record. If you need long-term retention on our side, ask your account manager about durable storage for your account.
What comes back
Findings are grouped by kind. Each interval carries the window, the proof frame, and how strong the signal was.
{
"roomId": "room-4821",
"frames": [ /* per-frame analysis, one entry per sampled frame */ ],
"proctoring_summary": {
"looking_away_intervals": [
{
"start": "2026-09-14T10:04:12",
"end": "2026-09-14T10:04:19",
"proof_frame": "data:image/jpeg;base64,…", // or an https URL
"proof_timestamp": "2026-09-14T10:04:15",
"evidence_score": 0.7412
}
],
"face_issue_intervals": [ /* no face, multiple faces, partial face */ ],
"device_intervals": [ /* phone or second screen in frame */ ]
}
}
| Field | Meaning |
|---|---|
proof_frame | The single image the finding was made from. A data: URI or an https URL depending on your account's storage mode — render both the same way. null means no usable frame was captured for that interval. |
proof_timestamp | When within the interval that frame was taken. |
evidence_score | How far past the threshold the signal was. Useful for ranking which interviews a human should look at first — not a probability of cheating. |
Webhooks
We call you when a report is ready. This is the part worth reading twice.
What we send
The body is deliberately thin — identifiers and a status, never candidate data. A webhook body lands in log stores that are read far more widely than a database.
POST https://yourcompany.com/hooks/proctoring
webhook-id: msg_2f8a91c4e7
webhook-timestamp: 1789385412
webhook-signature: v1,K8xW2pQ...
{
"jobId": "ea62cfe5-0b75-44c4-badf-27da5d8aa95e",
"sessionId": "pbs_XXXXXXXXXXXXXXXX",
"roomId": "room-4821",
"interviewId": "interview-4821",
"status": "completed"
}
Then fetch the full report with your API key, over a channel you control.
Every status you can receive
Branch on status. A handler written for only the happy path will
sit waiting for a report that is never coming.
status | Means | What your handler should do |
|---|---|---|
completed | The analysis finished. The report is ready. | Fetch the report and store it. |
failed | The analysis could not be produced — the recording could not be downloaded, or processing died. Carries a reason. | Treat as final, not as a retry. Mark the interview as un-analysed and surface it for a human. Re-submitting the same recording will usually fail the same way. |
{
"jobId": "ea62cfe5-0b75-44c4-badf-27da5d8aa95e",
"sessionId": "pbs_XXXXXXXXXXXXXXXX",
"roomId": "room-4821",
"interviewId": "interview-4821",
"status": "failed",
"reason": "could not download the recording"
}
A failed analysis costs you nothing. The credit held for that interview is released rather than charged. See Credits.
Verifying the signature
We follow the Standard Webhooks specification. Verify every request before you act on it — the endpoint is public, and anyone can POST to it.
Three things people get wrong, in order of frequency:
1. Signing the parsed-and-re-serialised JSON instead of the
raw request body. Re-serialising changes key order and whitespace, and the
signature will never match.
2. Using the secret as-is. Strip the whsec_ prefix and
base64-decode the rest — that is the key.
3. Comparing with ==. Use a constant-time compare.
The signed string is exactly {webhook-id}.{webhook-timestamp}.{raw body}.
import crypto from 'node:crypto'
export function verify(rawBody, headers, secret) {
const id = headers['webhook-id']
const ts = headers['webhook-timestamp']
const sigs = headers['webhook-signature']
if (!id || !ts || !sigs) return false
// Reject anything older than 5 minutes — this is what stops a captured
// request being replayed at you later.
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
const expected = 'v1,' + crypto
.createHmac('sha256', key)
.update(`${id}.${ts}.${rawBody}`) // rawBody — NOT JSON.stringify(parsed)
.digest('base64')
// The header may carry several space-separated signatures during a rotation.
return sigs.split(' ').some((candidate) =>
crypto.timingSafeEqual(
crypto.createHash('sha256').update(candidate).digest(),
crypto.createHash('sha256').update(expected).digest()
)
)
}import base64, hashlib, hmac, time
def verify(raw_body: bytes, headers, secret: str) -> bool:
wid = headers.get("webhook-id")
ts = headers.get("webhook-timestamp")
sigs = headers.get("webhook-signature")
if not (wid and ts and sigs):
return False
# Reject anything older than 5 minutes.
if abs(time.time() - int(ts)) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{wid}.{ts}.{raw_body.decode()}".encode()
expected = "v1," + base64.b64encode(
hmac.new(key, signed, hashlib.sha256).digest()
).decode()
return any(hmac.compare_digest(c, expected) for c in sigs.split(" "))function verify(string $rawBody, array $headers, string $secret): bool {
$id = $headers['webhook-id'] ?? null;
$ts = $headers['webhook-timestamp'] ?? null;
$sig = $headers['webhook-signature'] ?? null;
if (!$id || !$ts || !$sig) return false;
if (abs(time() - (int) $ts) > 300) return false;
$key = base64_decode(preg_replace('/^whsec_/', '', $secret));
$expected = 'v1,' . base64_encode(
hash_hmac('sha256', "$id.$ts.$rawBody", $key, true)
);
foreach (explode(' ', $sig) as $candidate) {
if (hash_equals($expected, $candidate)) return true;
}
return false;
}boolean verify(String rawBody, Map<String, String> headers, String secret)
throws Exception {
String id = headers.get("webhook-id");
String ts = headers.get("webhook-timestamp");
String sigs = headers.get("webhook-signature");
if (id == null || ts == null || sigs == null) return false;
if (Math.abs(System.currentTimeMillis() / 1000 - Long.parseLong(ts)) > 300)
return false;
byte[] key = Base64.getDecoder()
.decode(secret.replaceFirst("^whsec_", ""));
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key, "HmacSHA256"));
String expected = "v1," + Base64.getEncoder().encodeToString(
mac.doFinal((id + "." + ts + "." + rawBody).getBytes(UTF_8)));
for (String candidate : sigs.split(" ")) {
if (MessageDigest.isEqual(
expected.getBytes(UTF_8), candidate.getBytes(UTF_8))) return true;
}
return false;
}Responding
Return 2xx as soon as you have stored the message. Do the work afterwards — a
slow handler looks like a failure and will be retried. Anything non-2xx is retried with
exponential backoff.
Treat delivery as at-least-once: the same webhook-id can arrive
twice. Store it and ignore repeats.
Credits & billing
One interview costs one credit — and only if it actually happened.
| When | What happens | Your balance |
|---|---|---|
| You mint a session | One credit is held, not charged. | Unchanged. Spendable drops by 1. |
| The analysis completes | The hold becomes the charge. | −1 |
| The analysis fails, or nothing is ever submitted | The hold is released. | Unchanged — you are not charged. |
Why a hold, and not a charge-then-refund
Two reasons. A hold fails at the door: a client with one credit cannot start a thousand exams and find out a day later that the work is unbillable. And nothing appears on your statement for an interview that never completed — no 1000 → 999 → 1000.
A held credit is not a lost credit. If an interview is abandoned — the candidate closes the tab, the network drops — the hold is released automatically once we are satisfied no recording is coming. Until then it is reported as held rather than spent.
Check your balance any time:
curl $INCPROC_BASE/api/v1/credits/me \
-H "X-API-Key: $INCPROC_KEY" -H "X-API-Secret: $INCPROC_SECRET"
| Field | Meaning |
|---|---|
balance_credits | What you own — granted minus charged. |
held_credits | Reserved by interviews in flight. |
available_credits | What you can start new interviews with. This is the number that matters. |
Errors
Every error carries a machine-readable code and a request_id.
Log the request_id — quoting it to support is the difference
between a minute and an afternoon.
{
"error": {
"code": "MISSING_CREDENTIALS",
"message": "X-API-Key and X-API-Secret are required."
},
"request_id": "req_491581f1a4034fb2a9a5e5dc306eff05"
}
| Status | Means | Do this |
|---|---|---|
| 400 | The request is malformed, or a required field is missing. | Fix it. Retrying will not help. |
| 401 | Missing or wrong credentials — or, in the browser, an origin that does not match. | Check the key pair and the origin. |
| 402 | Out of credits. Every candidate is being turned away. | Top up. Alert on this one. |
| 404 | No such session, job or report — or it belongs to another account. | Check the id. |
| 429 | Rate limited. | Back off and retry. |
| 5xx | Our problem. | Retry with backoff. Send us the request_id if it persists. |
Alert on 402 separately from other errors. It is not a bug and
it is not transient — it means every candidate is being refused at the door until someone
tops up the account.
Rate limits
Three separate ceilings. You will only ever design against the first.
| Limit | Applies to | Scope |
|---|---|---|
| 300 requests / minute | Your server-to-server API calls | per account |
| 240 events / minute | Browser activity events | per session — the SDK manages this |
| 30 evidence uploads / minute | Camera stills | per session — the SDK manages this |
A short burst above the limit is tolerated before 429 is returned. These are
defaults; if your volume needs more, ask your account manager rather than working around them.
Minting is one call per candidate, so the 300/minute ceiling only becomes
real in a bulk job. If you are submitting a backlog of recordings, space them — and back off on
429 rather than retrying immediately.
The two per-session limits exist so one candidate's browser cannot flood the account. The SDK queues and backs off on its own; you do not handle them.
Pagination
The list endpoints take a limit, newest first.
| Endpoint | Default | Maximum | Also accepts |
|---|---|---|---|
GET /proctoring/browser/sessions | 50 | 200 | — |
GET /proctoring/video/jobs | 50 | 200 | room_id, interview_id |
GET /credits/me (ledger) | 50 | 500 | offset |
Filter rather than page. The job list accepts room_id and
interview_id — if you are looking for one interview, ask for it by identifier. It
is one request instead of walking a list, and it stays correct as your volume grows.
For reconciliation, the ledger is the one endpoint with a true offset.
Versioning & changes
The REST API is versioned in the path: /api/v1/…. The browser SDK follows
semantic versioning.
| We may do this without notice | We will not, within v1 |
|---|---|
| Add a new endpoint | Remove or rename an endpoint |
| Add an optional request field | Make an optional field required |
| Add a field to a response | Remove or rename a response field |
Add a new error.code or event type | Change the meaning of an existing one |
Build for additive change. Ignore response fields you do not recognise
rather than rejecting them, and treat an unfamiliar error.code or event type as its
category rather than failing. Both are things we will add inside v1.
A breaking change means a new path version, and v1 would be supported alongside it
for an agreed period — confirm the notice period in your contract; it is not set
by this page.
For the SDK, pin a major version. A minor upgrade will not change the shape of anything you already use.
Limits & retention
| Session token lifetime | 15 minutes, refreshed silently by the SDK for as long as the session is alive. You never handle this. |
| Session lifetime | Set by ttl_seconds at mint — minimum 60 seconds, capped by a deployment maximum. Set it to your exam length plus a margin; the session ends when it expires whether or not the candidate has finished. |
| Analysis turnaround | Typically one to two minutes for a 30-minute recording. Wait for the webhook rather than assuming a duration. |
| Recording | We never store your video. It is downloaded, analysed, and deleted with the working files. |
| Proof frames | Single frames extracted at the moment of a finding. Retention depends on your plan's storage mode. |
| Reports | Fetch via the API with your key. Not public, not guessable. |
A presigned URL with a short expiry is the right way to send us a recording. We only need to read it once. If your compliance position is that recordings must not leave your storage for long, issue a URL valid for an hour — that is enough.
Next
API Reference →
Every endpoint, parameter and response, with a live request builder and snippets in your language.
Browser SDK Reference →
Every option, method and event, with React and error-handling patterns.
Webhook checklist →
Raw body, decoded key, constant-time compare, replay window. Get these four right and you are done.