Proctoring Docs
◆ REST API & Browser SDK

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.

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.

Your backend Candidate's browser InCruiter
1
POST /browser/sessionsyour API key
→
mints a session1 credit held
2
hand over session_id + token
→
SDK startstab switches, camera stills
3
candidate finishesstop()
→
session closed
4
POST /video/analyze
the SAME session_id
→
joins both halves1 credit settled
5
webhook → your call_back_url
→
verify signature, fetch report

Step 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.

  1. 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());

    origin must exactly match the page the exam is served from. The token is bound to it and refused anywhere else — and the 401 the browser gets does not explain why. https://exams.yourcompany.com and https://exams.yourcompany.com/ are the same; http:// and a different subdomain are not.

    It must be https://. The only exception is localhost and 127.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 an http:// origin fails straight away with 422 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 point origin at http://localhost while developing.

    If your account is restricted to specific domains, mint fails with 422 until 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.

  2. 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-sdk
    import { 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_token belongs client-side — it expires, and it only works for one candidate on one origin.

  3. Submit the recording when the interview ends

    Send the same session_id from 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_url when 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.

HeaderValue
X-API-KeyIdentifies your account. Not secret on its own.
X-API-SecretThe 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.

SignalFires whenSeverity
tab_hiddenThe exam stopped being the visible tab — switching tabs, minimising, another app covering it.High
tab_visibleThey came back. Carries away_ms.Info
window_blurFocus was lost but the page may still be visible — a second monitor, a notification.Medium
window_focusFocus 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
FieldMeaning
severityhigh / medium / low, assigned by us. The browser never sends it, so it cannot be tampered with.
reference_idYour own identifier, echoed on every event — join our data to yours without storing our ids.
elapsed_msMilliseconds since the session started. Only ever moves forward — use this to order events and measure durations.
occurred_atWhen it happened in the candidate's browser. Their clock, which can be wrong or deliberately changed.
received_atWhen 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.
countsPre-aggregated, so a dashboard does not have to walk the whole timeline to show a summary.
known_typefalse 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.

FindingMeaning
looking_away_intervalsSustained gaze away from the screen, measured against the candidate's own neutral pose.
face_issue_intervalsNo face, more than one face, or a partially detected face.
device_intervalsA phone or second device detected in frame.

Required fields

FieldNotes
file_urlMust be reachable by us. A presigned URL is fine and preferred — see Retention.
session_idRequired. The session from step 1. Without it the recording cannot be joined to the live signals.
interview_idStable 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_urlWhere 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 } }
  ]
}
FieldMeaning
proof_frameThe 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_timestampWhen within the interval that frame was taken.
evidence_scoreHow far past the threshold the signal was. Useful for ranking which interviews a human should look at first — not a probability of cheating.
duration_secHow long the interval lasted. A two-second glance away and a ninety-second one should not be treated alike.
faces / face_statusOn a face finding: how many faces were seen, and which case it was — absent, multiple or partial.
itemOn a device finding: what was recognised, e.g. mobile phone or additional screen.
sourceAI — the finding came from the model, not from a person.
drowsiness_percentShare of the interview showing drowsiness or yawning, across the whole recording rather than as intervals.
framesThe 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.

statusMeansWhat your handler should do
completedThe analysis finished. The report is ready.Fetch the report and store it.
failedThe 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.

WhenWhat happensYour balance
You mint a sessionOne credit is held, not charged.Unchanged. Spendable drops by 1.
The analysis completesThe hold becomes the charge.−1
The analysis fails, or nothing is ever submittedThe 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"
FieldMeaning
balance_creditsWhat you own — granted minus charged.
held_creditsReserved by interviews in flight.
available_creditsWhat 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"
}
StatusMeansDo this
400The request is malformed, or a required field is missing.Fix it. Retrying will not help.
401Missing or wrong credentials — or, in the browser, an origin that does not match.Check the key pair and the origin.
402Out of credits. Every candidate is being turned away.Top up. Alert on this one.
404No such session, job or report — or it belongs to another account.Check the id.
422A 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.
429Rate limited.Back off and retry.
5xxOur 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.

CodeStatusMeans
MISSING_CREDENTIALS401No X-API-Key / X-API-Secret on the request.
INVALID_API_KEY401The key is not recognised, or has been revoked.
INVALID_SECRET401The key exists; the secret does not match it.
INVALID_SIGNATURE401Signed-request mode: the signature did not verify.
SIGNATURE_EXPIRED401Signed-request mode: the timestamp is outside the accepted window. Check your clock.
REPLAY_DETECTED401Signed-request mode: that nonce has already been used.
AUTH_MODE_NOT_ALLOWED401Your key cannot use signed-request mode. Use the header pair.
CLIENT_DISABLED403The account is suspended. Contact your account manager.
SCOPE_DENIED403The key is not permitted to use this product.
NOT_FOUND404No such session, job or report — or it belongs to another account. Both answer identically on purpose.
INSUFFICIENT_CREDITS402Not enough available credits to start another interview.
QUOTA_EXCEEDED402An account limit other than balance was reached.
VALIDATION_FAILED422A field failed validation. details.fields[] names each one.
MEDIA_ERROR422The uploaded image or recording could not be read as the type it claimed.
PAYLOAD_TOO_LARGE413The body exceeded the size limit for that endpoint.
RATE_LIMITED429Your account exceeded its requests-per-minute ceiling.
SDK_RATE_LIMITED429One session exceeded its per-session event or evidence ceiling. The SDK backs off on its own.
FEATURE_NOT_ENABLED503That product is not switched on for your account.
UPSTREAM_TIMEOUT504The analysis engine did not answer in time. Retry with backoff.
UPSTREAM_UNAVAILABLE503The analysis engine is down. Retry with backoff.
UPSTREAM_ERROR502The analysis engine returned something we could not use.
INTERNAL_ERROR500Our 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.

LimitApplies toScope
300 requests / minuteYour server-to-server API callsper account
240 events / minuteBrowser activity eventsper session — the SDK manages this
30 evidence uploads / minuteCamera stillsper 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.

EndpointDefaultMaximumAlso accepts
GET /proctoring/browser/sessions50200—
GET /proctoring/video/jobs50200room_id, interview_id
GET /credits/me (ledger)50500offset

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 noticeWe will not, within v1
Add a new endpointRemove or rename an endpoint
Add an optional request fieldMake an optional field required
Add a field to a responseRemove or rename a response field
Add a new error.code or event typeChange 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 lifetime15 minutes, refreshed silently by the SDK for as long as the session is alive. You never handle this.
Session lifetimeSet 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 turnaroundTypically one to two minutes for a 30-minute recording. Wait for the webhook rather than assuming a duration.
Recording lengthUp 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 sizeNo 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.
RecordingWe never store your video. It is downloaded, analysed, and deleted with the working files.
Proof framesSingle frames extracted at the moment of a finding. Retention depends on your plan's storage mode.
ReportsFetch 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