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. It is also the step people miss most often.
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 candidate who wants to can turn them off or fake them. That is why every browser event we
return is stamped trust: "advisory".
They are supporting context. They are never proof that a person cheated.
The same care is needed with the recorded analysis, though a little less. A looking-away
interval means our model measured that the candidate looked away from the screen for a while. It
does not mean they were reading something. That is why every finding carries a
proof_frame, so a person can look at the image and decide.
Build your flow so that a finding sends the interview to a human reviewer, instead of failing the candidate on its own. Anything else is a decision you will not be able to defend if someone challenges it.
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.It must be
https://. The only exception islocalhostand127.0.0.1, so local development still works. This is not our rule. The browser's own camera and screen-share APIs (getUserMedia,getDisplayMedia) only work on a secure page, so the SDK could never have run on plain http. So anhttp://origin fails straight away with422 VALIDATION_FAILED, instead of working here and then breaking later in the browser. A staging site on plain http will not work — put a certificate on it, or pointoriginathttp://localhostwhile developing.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 switched on per account. Most accounts do not have it, and any origin works as soon as your keys are issued. It is worth asking for, because it means a leaked key cannot create sessions from someone else's page and spend your credits. If your account does have it, every domain you mint from must 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 your very first call from working, and the error message is the only place you will see it.
-
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 your configuration instead of writing it into the code. The value is different for test
and for production, and a fixed value in the source is how test traffic ends up hitting
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 for each environment. A key made for one will not work on another. That is on purpose: it is what stops a test integration from 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. We only keep a hash of it, so it cannot be shown to you again. You are given a new pair, 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.
Brave Shields and ad blockers can block the SDK. Before starting the
interview, call checkReachable() (SDK 1.0.4+) and ask the candidate to turn the
blocker off or switch browser if it does not return 'ok'. See
Ad blockers & Brave Shields.
What the log looks like
One session produces one report. Fetch it with your API key once the exam has ended:
curl -sS "$INCPROC_BASE/api/v1/proctoring/browser/sessions/$SESSION_ID/report" \
-H "X-API-Key: $INCPROC_KEY" -H "X-API-Secret: $INCPROC_SECRET"
An 18-minute sitting, abbreviated — the candidate settled in, lost focus twice, left the exam tab twice and dropped out of fullscreen once:
{
"session": {
"session_id": "pbs_XXXXXXXXXXXXXXXX",
"reference_id": "candidate-4821-attempt-1", // your id, not ours
"state": "ended",
"end_reason": "ended_by_client",
"metadata": { "exam": "backend-engineer-l3" },
"events_received": 18, "evidence_received": 4
},
"counts": {
"events": 18, "evidence": 4,
"by_type": { "tab_hidden": 2, "window_blur": 3, "fullscreen_exited": 1, ... },
"by_severity": { "high": 3, "medium": 3, "low": 12 }
},
"events": [
{ "type": "session_started", "severity": "low", "elapsed_ms": 0, ... },
{ "type": "environment_reported", "severity": "low", "elapsed_ms": 1000,
"detail": { "system": { "os": "Windows", "browser": "chrome",
"browser_version": "140", "screen": "1920x1080",
"trust": "self_reported" } } },
{ "type": "screen_share_started", "severity": "low", "elapsed_ms": 4000, ... },
{ "event_id": "evt_008185uej",
"type": "tab_hidden", // left the exam tab
"severity": "high", // assigned by us, never by the browser
"occurred_at": "2026-09-21T11:34:31.122Z", // the candidate's clock
"received_at": "2026-09-21T11:51:39.510Z", // ours
"elapsed_ms": 292000, // 04:52 into the exam
"detail": { "reason": "visibilitychange" },
"trust": "advisory" },
{ "type": "tab_visible", "severity": "low", "elapsed_ms": 305000,
"detail": { "away_ms": 13000 } }, // how long they were gone
{ "type": "fullscreen_exited", "severity": "high", "elapsed_ms": 688000, ... },
{ "type": "session_stopped", "severity": "low", "elapsed_ms": 1080000,
"detail": { "reason": "completed" } }
],
"evidence": [
{ "event_id": "shot_019a7h8qe",
"kind": "screen",
"captured_at": "2026-09-21T11:30:09.122Z",
"content_type": "image/jpeg",
"size_bytes": 10457,
"sha256": "22d64404fcb6f8e44e486d149b6d6d62…" }
],
"trust": "advisory",
"trust_note": "Browser events and screenshots are produced by JavaScript on the candidate's
own machine and can be suppressed or fabricated…",
"generated_at": "2026-09-21T11:51:40.363Z"
}
Each frame in evidence is fetched by its event_id, and comes back as
an image rather than JSON:
GET $INCPROC_BASE/api/v1/proctoring/browser/sessions/$SESSION_ID/evidence/$EVENT_ID
→ 200 image/jpeg
| Field | Meaning |
|---|---|
severity | high / medium / low, assigned by us. The browser never sends it, so it cannot be tampered with. |
reference_id | Your own identifier, echoed on every event — join our data to yours without storing our ids. |
elapsed_ms | Milliseconds since the session started. Only ever moves forward — use this to order events and measure durations. |
occurred_at | When it happened in the candidate's browser. Their clock, which can be wrong or deliberately changed. |
received_at | When our server received it. Use this when you need a time you can defend; it differs from the above if the candidate went offline mid-exam. |
counts | Pre-aggregated, so a dashboard does not have to walk the whole timeline to show a summary. |
known_type | false means a newer SDK sent a type this deployment does not recognise yet. It is still stored, never dropped. |
Three timestamps, and only one of them is safe to measure with. A
candidate's wall clock can be wrong, or changed on purpose. elapsed_ms cannot —
it is monotonic. Sort and measure with elapsed_ms; quote
received_at when the time itself has to stand up.
Ending the session
Call stop() when the candidate finishes. It sends anything still waiting in the
queue and closes the session. If the tab just closes, we notice and close the session ourselves.
Calling stop() is still better: it is cleaner, and it settles the credit sooner.
Recorded interview analysis
Send a finished recording and we return findings in three groups. Each one comes 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 quickly, and keep your own copy. A finished report is only kept
for a short time. On the default storage mode that is three hours. After that,
this endpoint answers 202 pending for that job and the report is gone. It cannot
be recovered, and running the analysis again costs another credit.
Fetch it inside your webhook handler, save it to your own database, and never treat us as your system of record. If you need us to keep reports for longer, 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.
{
"job_id": "db8a2b36-8808-4135-aefb-c33b0102a498",
"roomId": "room-4821",
"proctoring_summary": {
"looking_away_intervals": [
{
"start": "2026-09-14T10:04:12",
"end": "2026-09-14T10:04:19",
"duration_sec": 7,
"proof_frame": "data:image/jpeg;base64,…", // or an https URL
"proof_timestamp": "2026-09-14T10:04:15",
"evidence_score": 0.7412,
"source": "AI"
}
],
"face_issue_intervals": [
{
"start": "2026-09-14T10:00:24",
"end": "2026-09-14T10:00:29",
"duration_sec": 5,
"faces": 0, // 0 = nobody, 2+ = more than one person
"face_status": "absent", // absent | multiple | partial
"proof_frame": "…",
"proof_timestamp": "2026-09-14T10:00:28",
"evidence_score": 1.0,
"source": "AI"
}
],
"device_intervals": [
{
"start": "2026-09-14T10:02:41",
"end": "2026-09-14T10:02:49",
"duration_sec": 8,
"item": "additional screen", // or "mobile phone"
"proof_frame": "…",
"proof_timestamp": "2026-09-14T10:02:46",
"evidence_score": 0.7188,
"source": "AI"
}
],
"drowsiness_percent": 6.79
},
// One row per analysed frame — the raw measurements the intervals above are
// summarised from. Thousands of rows on a real interview.
"frames": [
{ "frame": "frame_00001.jpg", "timestamp": "2026-09-14T10:00:00",
"faces": 1, "gaze_h": 0.0, "gaze_v": 0.0, "looking_away_abs": false,
"face_partial": false, "person_present": true,
"objects": [], "drowsiness_yawn": { "is_drowsy": false, "is_yawning": false } }
]
}
| 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. |
duration_sec | How long the interval lasted. A two-second glance away and a ninety-second one should not be treated alike. |
faces / face_status | On a face finding: how many faces were seen, and which case it was — absent, multiple or partial. |
item | On a device finding: what was recognised, e.g. mobile phone or additional screen. |
source | AI — the finding came from the model, not from a person. |
drowsiness_percent | Share of the interview showing drowsiness or yawning, across the whole recording rather than as intervals. |
frames | The raw per-frame measurements the intervals are summarised from. Most integrations only read proctoring_summary and ignore this. |
A proof_frame https URL is short-lived and signed. Download
the image inside your webhook handler and store your own copy. Do not save the URL and fetch it
later, and do not paste one into an email or a ticket — it grants access to the image on its
own.
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. |
| 422 | A field failed validation — a bad origin, a missing required field, an unknown capability, a ttl_seconds below the minimum. error.details.fields[] names each one. | Read details.fields and fix that field. Retrying will not help. |
| 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.
Every code you can receive
Branch on code, not on the message. A message gets reworded; a
code does not. These are stable — a new one may be added, but an existing one is never renamed
or repurposed.
| Code | Status | Means |
|---|---|---|
MISSING_CREDENTIALS | 401 | No X-API-Key / X-API-Secret on the request. |
INVALID_API_KEY | 401 | The key is not recognised, or has been revoked. |
INVALID_SECRET | 401 | The key exists; the secret does not match it. |
INVALID_SIGNATURE | 401 | Signed-request mode: the signature did not verify. |
SIGNATURE_EXPIRED | 401 | Signed-request mode: the timestamp is outside the accepted window. Check your clock. |
REPLAY_DETECTED | 401 | Signed-request mode: that nonce has already been used. |
AUTH_MODE_NOT_ALLOWED | 401 | Your key cannot use signed-request mode. Use the header pair. |
CLIENT_DISABLED | 403 | The account is suspended. Contact your account manager. |
SCOPE_DENIED | 403 | The key is not permitted to use this product. |
NOT_FOUND | 404 | No such session, job or report — or it belongs to another account. Both answer identically on purpose. |
INSUFFICIENT_CREDITS | 402 | Not enough available credits to start another interview. |
QUOTA_EXCEEDED | 402 | An account limit other than balance was reached. |
VALIDATION_FAILED | 422 | A field failed validation. details.fields[] names each one. |
MEDIA_ERROR | 422 | The uploaded image or recording could not be read as the type it claimed. |
PAYLOAD_TOO_LARGE | 413 | The body exceeded the size limit for that endpoint. |
RATE_LIMITED | 429 | Your account exceeded its requests-per-minute ceiling. |
SDK_RATE_LIMITED | 429 | One session exceeded its per-session event or evidence ceiling. The SDK backs off on its own. |
FEATURE_NOT_ENABLED | 503 | That product is not switched on for your account. |
UPSTREAM_TIMEOUT | 504 | The analysis engine did not answer in time. Retry with backoff. |
UPSTREAM_UNAVAILABLE | 503 | The analysis engine is down. Retry with backoff. |
UPSTREAM_ERROR | 502 | The analysis engine returned something we could not use. |
INTERNAL_ERROR | 500 | Our problem. Send us the request_id. |
A validation failure carries the offending fields, which is what you map back to your own form or job record:
{
"success": false,
"request_id": "req_b38bad220b2d42899533663685070582",
"error": {
"code": "VALIDATION_FAILED",
"message": "The request body did not validate.",
"details": {
"fields": [
{ "field": "origin", "error": "origin must be https:// — getUserMedia and getDisplayMedia require a secure context, so the SDK cannot run on plain http outside localhost." }
]
}
}
}
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 length | Up to 1 hour. That is the longest single recording the analysis is supported for. If your exams run longer, talk to your account manager before you build against it rather than after. |
| Recording size | No fixed limit in megabytes — length is the constraint, not file size. A 512 MB recording analysed in about two and a half minutes in our own testing. Size affects how long the download and analysis take, so budget for the webhook arriving later on a long exam, and do not build a timeout around the one-to-two-minute figure above. |
| 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.