Developers

ProctorLink API reference

Integrate proctoring into your own assessment platform: mint a session, start the browser SDK, and read the integrity report. Full request, response, and event reference.

Customer-facing reference for integrating ProctorLink into your assessment platform. Covers the two server-to-server calls you make, the one browser call, and the full shape of the proctoring report.

Base URL — https://api.proctorlink.com

Sign in at app.proctorlink.com to get your API keys (Developers → SDK applications). The dashboard and the API are separate hosts: you sign in at app., you call api.

A separate evaluation host is available on request; if you are using one, only the host changes and every path below stays the same.


1. How the integration fits together#

Three moving parts, in order:

Step Where it runs What it does
1. Mint a session Your backend POST /v1/sessions with your API key → returns a short-lived session token
2. Start proctoring Candidate's browser ProctorLink.createSession({ jwt, sessionId }).start()
3. Fetch the report Your backend GET /v1/sessions/:id with your API key → integrity score + evidence

Your API key never enters the browser. The browser only receives a session token that is scoped to one attempt and expires with it.


2. Authentication#

Server-to-server calls authenticate with two headers:

access-token: <YOUR_ACCESS_TOKEN>
secret-token: <YOUR_SECRET_TOKEN>

Find both in the dashboard under Developers → SDK applications.

These are server-side credentials. Do not put them in browser JavaScript, a mobile app, or any client the candidate controls. Anyone holding them can mint sessions billed to your account and read your reports.


3. Mint a session#

One session per quiz attempt.

POST /v1/sessions
access-token: <YOUR_ACCESS_TOKEN>
secret-token: <YOUR_SECRET_TOKEN>
content-type: application/json

{
  "external_user_id": "candidate-123",
  "exam_id": "math-101-final",
  "attempt_id": "attempt-789",
  "allowed_origins": ["https://exams.yourcompany.com"]
}

Request fields#

Field Type Required Description
external_user_id string yes Your identifier for the candidate. Opaque to us — use your own user ID, not an email, if you want to minimise personal data.
exam_id string no Your identifier for the exam/quiz. Used for grouping in reports.
attempt_id string no Your identifier for this attempt. Strongly recommended — it is what enables resume (see below).
allowed_origins string[] no Origins permitted to run this session. Requests from other origins are rejected.
reference_image_base64 string no A known-good photo of the candidate (e.g. an ID card) sent inline. Bare base64 or a data: URI. Preferred over the URL form. See below.
verify_reference_face boolean no Check the photo before accepting it, and refuse the mint if it is unusable. Applies to reference_image_base64 only. Off by default. See Checking the photo is usable.
ttl_seconds number no Desired session-token lifetime. Clamped to the allowed range; the effective value is returned as expires_at.

Response#

{
  "session_id": "6a7b18df70e4f8ecf2597b6f",
  "session_jwt": "eyJhbGciOi...",
  "expires_at": 1786443201
}
Field Description
session_id Store this. You need it to fetch the report.
session_jwt Pass to the browser SDK. Safe to expose — scoped to this one attempt.
expires_at Unix seconds. Token lifetime is 2 hours by default.
resumed Present and true only when an existing session was resumed.

Identity matching — the reference photo#

Every exam frame is matched against a reference photo. Which image plays that role decides what impersonation detection can actually catch:

Reference Catches
Photo taken before the exam (recommended) An impostor sitting the exam, and a mid-exam swap
First captured frame (fallback) Only a mid-exam swap — an impostor present from frame one becomes their own baseline

Recommended: capture it with the SDK. Add a "take your photo" step before the exam starts and call captureIdentity() — see §4 for both flows. The image goes straight from the browser to ProctorLink storage; it never passes through your servers, and no photo of yours needs to be publicly reachable. Entirely optional: skip it and the first exam frame is used instead.

If you already hold the photo — send it inline#

reference_image_base64 takes the image itself, so nothing of yours needs to be publicly reachable and we never make an outbound request:

{
  "external_user_id": "candidate-123",
  "attempt_id": "attempt-789",
  "reference_image_base64": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ..."
}
  • Bare base64 or a full data: URI — both accepted
  • JPEG, PNG or WebP, max 1 MB decoded
  • The format is read from the file's own bytes, not from anything the payload claims, so a mislabelled or non-image payload is rejected
  • Stored immediately at mint. An invalid payload returns 400, so you find out straight away rather than after the exam

This also suits capturing the photo in your own UI: take it however you like and send us the base64.

Checking the photo is usable#

A reference photo we cannot find a face in — covered, too dark, badly framed — disables impersonation detection for the whole attempt. Matching needs a baseline; without one there is nothing to compare each frame against. The attempt still runs, frames are still captured and face counts still work, but no similarity score is ever produced.

By default that is silent. The only trace is identity_match: "inconclusive" in the report afterwards, which is easy to read as a clean result when it actually means nothing was checked — and by then the exam is over.

Set verify_reference_face to have us look before accepting it:

{
  "external_user_id": "candidate-123",
  "attempt_id": "attempt-789",
  "reference_image_base64": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ...",
  "verify_reference_face": true
}

We run face detection on the photo and require exactly one face. Anything else fails the mint, so no session is created and nothing is billed:

Response Meaning What to do
400 — No face detected in reference_image_base64 Covered, too dark, or not a photo of a person Ask the candidate to retake. Retrying the same image will not help.
400 — N faces detected More than one person in frame; we cannot tell which is the candidate Retake with only the candidate visible.
503 We could not run the check Retry. This is not a pass — see below.

The best place for it is a pre-exam photo step, where the candidate is still present and can retake immediately.

503 is deliberate. If you asked us to verify and we could not, we refuse the mint rather than letting it through unchecked — "could not verify" must never be delivered as "verified". Treat it as retryable, not as a rejection of the photo.

Two limits worth knowing:

  • It applies to reference_image_base64 only. A reference_image_url is not checked, and setting the flag alongside one has no effect.
  • It costs a synchronous call to our face service during mint, adding latency to the moment a candidate starts. That is why it is off by default rather than always on.

Which one wins#

Highest first, if both are present:

  1. A photo captured via captureIdentity() — it happens last and is deliberate
  2. reference_image_base64 supplied at mint
  3. The first frame captured during the exam

Session token lifetime — ttl_seconds#

Defaults to 2 hours. Set it to your exam length plus a buffer so the token does not expire mid-attempt.

Minimum 300 s (5 min)
Maximum 43200 s (12 h)

Values outside the range are clamped, not rejected — always read the returned expires_at rather than assuming your requested value was used. On resume, the lifetime the session was minted with is reused unless you pass a new one.

Resume behaviour (important)#

If you send an attempt_id that already has an active session, we do not create a second session. We return the existing session_id with a fresh token and "resumed": true.

This means a candidate who refreshes the browser, loses connectivity, or reconnects continues the same proctoring record instead of splitting into two partial ones — and you are billed once for the attempt, not twice.

To make this work, attempt_id must be stable for the attempt. Derive it from your own attempt record. Do not generate a random value on page load, or every refresh starts a new session.


4. Start proctoring in the browser#

npm install @proctorlink/sdk@^1.0.0

1.0.0 is the current release. If your exam runs inside a single-page app you almost certainly need destroy({ endSession: false }) — see Leaving the exam page below.

The loader and the enclave it loads must be the same version. The SDK requests an enclave pinned to its own version and checks the protocol on connect, so a mismatch raises an error event reading "enclave protocol vN does not match loader vM" instead of failing silently. Check your installed version with npm ls @proctorlink/sdk.

Pick one of two flows. Neither is enforced — autoStartCapture defaults to true, so the simple one needs no extra options.

Flow A — start straight into the exam (default)#

One call. Recording begins as soon as the camera is granted, and the first captured frame becomes the identity reference.

import { ProctorLink } from '@proctorlink/sdk';

const session = ProctorLink.createSession({
  jwt: sessionJwt,   // session_jwt from step 3
  sessionId,         // session_id from step 3
});

session.on('permission', ({ camera }) => {
  if (camera === 'denied') { /* block or warn — your policy */ }
});
session.onEvent((event) => console.log(event.type, event));

// Safety net: the token outlived the exam. Re-mint for the SAME attempt_id
// (which resumes the same session) and hand the new token back.
session.on('token-expired', async () => {
  const { session_jwt } = await mintSession({ attempt_id: attemptId });
  session.updateToken(session_jwt);
});

await session.start();

// when the attempt finishes:
session.stop();
session.destroy();

That is the entire browser integration.

Flow B — identity photo first, then the exam#

Use this when you want matching against a deliberate, well-framed photo rather than whatever the first frame happened to catch.

const session = ProctorLink.createSession({
  jwt: sessionJwt,
  sessionId,
  autoStartCapture: false,   // camera on, but nothing recorded yet
});

session.on('token-expired', async () => {
  const { session_jwt } = await mintSession({ attempt_id: attemptId });
  session.updateToken(session_jwt);
});

await session.start();       // preview is live; no keyframes taken

// your "look at the camera" step — retake as often as you like
await session.captureIdentity();

session.beginCapture();      // exam starts; keyframes at the normal cadence

mintSession here is your own backend call to POST /v1/sessions — the same one you used to start. Passing the same attempt_id resumes, so you get a fresh token for the session already running rather than a new one.

Handling token expiry#

The session token is short-lived. If it expires mid-attempt the SDK cannot deliver data, so it tells you rather than failing silently:

session.on('token-expired', async () => {
  // Re-mint for the SAME attempt_id — this resumes, returning the same
  // session_id with a fresh token.
  const { session_jwt } = await mintOnYourBackend(attemptId);
  session.updateToken(session_jwt);   // capture continues uninterrupted
});

Events recorded while the token was dead are queued and flushed as soon as the new token lands, so nothing from the gap is lost.

A re-mint only resumes while the session is still active. If the session has already ended — most likely the 3-hour abandonment sweep closed it after a long outage — the mint creates a new session instead, and its token belongs to that one. Passing it to updateToken() would make every upload fail.

The SDK rejects that case rather than looping, but check it yourself so you can handle it deliberately:

session.on('token-expired', async () => {
  const res = await mintSession({ attempt_id: attemptId });

  if (res.resumed) {
    session.updateToken(res.session_jwt);      // same session — carry on
  } else {
    // The old session is gone. This is a fresh attempt with its own report.
    session.destroy();
    session = ProctorLink.createSession({
      jwt: res.session_jwt,
      sessionId: res.session_id,
    });
    await session.start();
  }
});

Do not call stop()/destroy() and start a new session to refresh a token. That ends the attempt server-side, and the next mint creates a separate session — two reports for one attempt, billed twice.

The simplest approach is to avoid expiry altogether: set ttl_seconds to your exam duration plus a buffer (see §3). Use token-expired as the safety net for exams that run longer than planned.

Session methods#

Method Description
start(): Promise<void> Mounts the enclave and requests the camera. Also begins recording unless autoStartCapture is false.
captureIdentity(): Promise<void> Takes the identity photo and stores it as the reference. Needs the camera live, so call after start() resolves. Rejects with a reason (camera not ready, upload failed) so you can drive a retake loop; calling it again replaces the previous photo. Works in either flow.
beginCapture(): void Starts recording exam keyframes. Only needed with autoStartCapture: false; otherwise a no-op. Idempotent.
updateToken(jwt): void Replaces the session token without interrupting the attempt. Queued data is flushed immediately with the new token.
on(type, cb): () => void Subscribe to 'ready', 'permission', 'event', 'error' or 'token-expired'. Returns an unsubscribe function.
onEvent(cb): () => void Shorthand for on('event', …).
pause(): void Suspends capture without ending the attempt, releasing the camera and hiding the preview. The session stays active.
resume(): void Resumes capture after pause(), re-opening the camera.
isPaused: boolean Whether the session is currently paused.
stop(): void Ends capture and flushes pending data. Ends the attempt server-side — not reversible.
destroy(opts?): void Tears down the camera, preview and iframe. Ends the attempt by default. destroy({ endSession: false }) keeps the attempt active so you can resume it later.

Leaving the exam page#

If your exam lives in a single-page app, the candidate can navigate off the exam route — a back button, a link, a modal — without the page ever unloading. The camera preview is attached to document.body, outside your router outlet, so it survives the route change and keeps capturing: frames of an empty chair, and tab or clipboard events from whatever they do next.

Those frames are scored. Every one with no face in it counts toward no_face and deducts points. Left unhandled, a candidate is marked down for using your back button.

The fix is to tear everything down without ending the attempt:

ngOnDestroy(): void {
  // Leaving the route is not finishing the exam.
  session.destroy({ endSession: false });
}

Camera released, preview and iframe removed, queued data flushed — but the session stays active. When the candidate returns, start again exactly as you did the first time:

  1. Mint with the same attempt_id → the response has resumed: true and the same session_id
  2. createSession() + start() → the SDK rejoins and continues where it left off

The identity reference and every frame recorded earlier survive. One attempt produces one report and one billed credit.

Do not use stop() or a bare destroy() for this. Both end the attempt server-side. Minting again afterwards creates a new session with its own report, its own identity photo and its own billed credit — one attempt fragments into several.

You do not need any of this for a full page navigation or a refresh. The SDK flushes on pagehide and deliberately does not end the session, and returning to the exam rejoins the same attempt automatically.

Pausing without tearing down#

pause() is the lighter alternative when you want to stop capturing but keep the SDK mounted — a modal over the exam, a scheduled break:

// You choose the trigger — the SDK never pauses itself.
session.pause();
session.resume();

It stops keyframe capture, releases the camera, hides the preview, and stops collecting host-page signals — so nothing is recorded and nothing is scored while paused. The attempt stays active. resume() re-opens the camera and starts capturing again.

A session.paused event is recorded, and session.resumed on return, so the gap in the frame timeline is explained rather than reading as interference with the SDK. Neither event affects the integrity score.

One behaviour to know: pausing before capture has started keeps it stopped. With autoStartCapture: false, pausing during the identity step and resuming restores camera-on-but-not-recording. Call beginCapture() to start recording, as usual.

For an SPA route change prefer destroy({ endSession: false }) above — it leaves nothing mounted on a page the candidate is no longer on.

Options#

Option Default Description
jwt required The session_jwt from POST /v1/sessions.
sessionId decoded from jwt The session_id from the same response.
ingestBaseUrl https://api.proctorlink.com Where proctoring data is sent. Defaults to production — see below.
autoStartCapture true false brings the camera up without recording, for Flow B.
frameIntervalMs 60000 Keyframe cadence — one per minute.
heartbeatIntervalMs 15000 Liveness cadence. Rarely worth changing — see below.
mount floating pip Element to place the camera preview in.
showPreview true false hides the preview entirely.
draggable true Candidate can drag the preview around the page. false fixes it in place.

On ingestBaseUrl. It defaults to production, so a normal integration sets nothing. To point at an evaluation host, read your own environment variable at build time and pass it through — the SDK is a browser bundle and cannot read environment variables itself:

ProctorLink.createSession({
  jwt, sessionId,
  ingestBaseUrl: process.env.PROCTORLINK_API_URL,  // unset -> production
});

Leaving it unset falls back to the default, so one build serves both. It must be the same instance that minted the session: the session token is signed by whichever environment issued it and ingest verifies that signature, so minting on one and ingesting on another fails every call with 401.

On heartbeatIntervalMs. Heartbeats are the liveness signal behind the liveness block in the report. Lowering the interval does not improve detection — how small a gap counts is decided server-side, not by this cadence — and it multiplies the telemetry your candidates upload. The default of 15000 was chosen for that reason: a 3-hour attempt sends about 720 heartbeats rather than the 2,160 a 5-second cadence would produce. Sessions score identically at either cadence.

Content-Security-Policy#

If your page sets a CSP, allow the enclave:

frame-src https://enclave.proctorlink.com;

This host is not your API base URL. The SDK loads https://enclave.proctorlink.com/1.0.0/enclave.html from a fixed host that does not vary with the base URL you were issued, so the same frame-src line works in evaluation and in production. The version sits in the path rather than the host, so this policy line stays correct across future SDK releases.

Passing your own enclaveUrl overrides all of this — then allow that origin instead.


5. Retrieve the proctoring report#

GET /v1/sessions/6a7b18df70e4f8ecf2597b6f
access-token: <YOUR_ACCESS_TOKEN>
secret-token: <YOUR_SECRET_TOKEN>

Returns everything known about the attempt: the integrity verdict, face analysis, browser activity, evidence images, and a timeline.

Example response#

{
  "session_id": "6a7b18df70e4f8ecf2597b6f",
  "status": "validated",
  "external_user_id": "candidate-123",
  "exam_id": "math-101-final",
  "attempt_id": "attempt-789",
  "source": "sdk",
  "started_at": "2026-08-11T12:43:11.175Z",
  "ended_at": "2026-08-11T12:43:55.648Z",
  "validated_at": "2026-08-11T12:44:04.473Z",
  "duration_seconds": 44,

  "integrity": {
    "score": 100,
    "level": "low",
    "flagged": false,
    "analysis_complete": true,
    "identity_match": "pass",
    "reasons": []
  },

  "face_analysis": {
    "frames_checked": 2,
    "no_face_frames": 0,
    "multi_face_frames": 0,
    "impersonation_frames": 0,
    "min_match_score": 72.02
  },

  "liveness": {
    "coverage": 1,
    "unobserved_seconds": 0,
    "longest_gap_seconds": 15,
    "heartbeats_received": 40,
    "heartbeats_expected": 40
  },

  "activity": {
    "total_frames": 2,
    "total_events": 11,
    "flagged_events": 0,
    "by_type": { "tab.visible": 2 }
  },

  "evidence": [
    {
      "seq": -1,
      "captured_at": null,
      "is_reference": true,
      "source": "supplied",
      "image_url": "https://…signed…",
      "result": null
    },
    {
      "seq": 6,
      "captured_at": "2026-08-11T12:43:20.000Z",
      "is_reference": false,
      "source": "captured",
      "image_url": "https://…signed…",
      "result": {
        "faces": 1,
        "no_face": false,
        "multi_face": false,
        "match_score": 99.99,
        "impersonation": false
      }
    }
  ],

  "timeline": [
    { "type": "session.started", "seq": 1, "at": "2026-08-11T12:43:11.175Z", "source": "enclave" },
    { "type": "tab.visible",     "seq": 4, "at": "2026-08-11T12:43:29.000Z", "source": "enclave" }
  ]
}

integrity#

The headline verdict. Computed entirely server-side — the browser cannot influence it.

Field Type Description
score 0–100 100 is clean. Penalties are subtracted for each finding.
level low | medium | high Risk band. low ≥ 80, medium ≥ 50, otherwise high.
flagged boolean True when the score is below 80 or analysis flagged the session.
analysis_complete boolean False until face analysis has finished. See §6.
identity_match pending | pass | fail | inconclusive Identity verdict. inconclusive means no frame could be compared — see below.
reasons array Why points were deducted — each with code, human-readable detail, and negative weight.

Reading identity_match. fail means at least one frame scored below the match threshold. pass means frames were compared and none did — but it does not say who the candidate matched: without a pre-exam photo the baseline is the first captured frame, so an impostor present from frame one still passes (§3). inconclusive means analysis finished but nothing could be compared — either no frame had exactly one face, or no reference image was usable — so identity was never checked either way. Treat inconclusive as "unknown", not as "clean".

If you are seeing inconclusive often, the reference photo is the usual cause. verify_reference_face at mint (§3) catches an unusable one while the candidate can still retake, instead of surfacing it here after the exam.

reasons[].code is one of impersonation, multiple_faces, no_face, liveness_gap, browser_activity. Show detail to reviewers; branch on code.

A score alone should not fail a candidate. Use reasons and evidence for human review of anything flagged.

face_analysis#

Field Description
frames_checked Frames that completed analysis.
no_face_frames Frames with no face visible.
multi_face_frames Frames with more than one face.
impersonation_frames Frames that did not match the reference.
min_match_score Lowest similarity (0–100) against the reference; null if never compared.

liveness#

How much of the attempt we could actually observe. The SDK sends a heartbeat on a fixed cadence for the whole attempt; a gap in that stream means we lost sight of the candidate.

Field Description
coverage Fraction of expected heartbeats received, 0–1.
unobserved_seconds Total time the candidate was not observable. Short jitter is not counted.
longest_gap_seconds The single longest interruption, including before the first heartbeat and after the last.
heartbeats_received / heartbeats_expected Raw counts behind coverage.

null for sessions recorded before this measure existed — which is not the same as a measured zero.

What produces a gap: the tab or browser being closed, the machine sleeping, the network dropping, the SDK being torn down on a route change (including a deliberate destroy({ endSession: false })), or the enclave being interfered with. All of them count — the measure is supervision coverage, not intent.

A backgrounded tab does not normally produce one: browsers throttle timers in hidden tabs rather than stopping them, and that is absorbed deliberately so a tab switch is not penalised twice (tab.hidden already scores it).

Gaps deduct points under the liveness_gap reason, and are enough to flag a session on their own.

activity#

Browser-side integrity signals. by_type counts meaningful events only — routine noise (heartbeat, frame.captured, session.started, session.stopped, camera.granted) is excluded.

evidence#

One entry per image, oldest first. image_url is a short-lived signed URL — fetch or copy the image promptly rather than storing the URL. It is null while a frame is still pending upload.

Field Description
source captured — a keyframe from during the attempt. identity — the pre-exam photo taken via captureIdentity(). supplied — a photo provided at mint.
is_reference The image every other frame was matched against. Exactly one entry per session has this.
seq Monotonic capture order. The supplied reference uses -1 so it sorts first.
captured_at null for a supplied reference — it was not captured during the attempt.
result null for a supplied reference — it is the baseline, not a frame under test.

So the evidence list shows a reviewer both who the candidate was supposed to be (the supplied reference, when you provide one) and what was actually seen during the attempt.

timeline#

Chronological events, capped at 1000 entries. Each has type, seq (monotonic), at, and source.


6. Session lifecycle — when is the report ready?#

Face analysis is deferred, not real-time. A report fetched the instant an exam ends will not yet contain face results.

status Meaning
active Attempt in progress.
ended Attempt finished; queued for analysis.
validating Analysis running.
validated Analysis complete — final.
failed Analysis could not complete. Browser activity is still valid.

Fetch the report when status is validated, or equivalently when integrity.analysis_complete is true. Before that, face_analysis counts are zero and identity_match is pending — that is not a clean result, it is an unfinished one.

Analysis typically completes within a few minutes of the attempt ending. Poll on a sensible interval (e.g. every 30s, backing off) or fetch on demand when a reviewer opens the attempt. Do not poll in a tight loop.

A session with no explicit stop() (browser closed mid-exam) is closed automatically after 3 hours of silence and then analysed as normal.


7. Event types#

Delivered live to onEvent in the browser and present in timeline.

Lifecycle & media — session.started, session.stopped, session.paused, session.resumed, camera.granted, camera.denied, frame.captured, heartbeat

Integrity signals — tab.hidden, tab.visible, fullscreen.entered, fullscreen.exited, window.resized, clipboard.copy, clipboard.cut, clipboard.paste, context.menu, device.changed, page.unload

Face findings are not browser events — they appear only in the report after analysis.

Which events affect the score#

Only these deduct points, and they are pooled — each costs the same, capped at a combined −20:

Event Why it counts
tab.hidden Candidate left the exam tab.
fullscreen.exited Left fullscreen (meaningful if your flow requires it).
clipboard.copy / .cut / .paste Copying exam content out, or pasting answers in.
camera.denied Without a camera there is no face analysis, so the attempt is materially less supervised.

Every other event is captured and returned, but does not change the score — including tab.visible, fullscreen.entered, window.resized, context.menu, device.changed, page.unload, session.paused and session.resumed. They fire often for innocent reasons (a rotated phone, a Bluetooth headset connecting, a normal exam submission, a candidate stepping off the exam route), so they are recorded for reviewers rather than scored.

session.paused / session.resumed are worth surfacing to whoever reviews a report: they mark a deliberate gap in the frame timeline, which is what distinguishes a pause your app requested from a candidate interfering with the enclave.

This means activity.by_type and activity.flagged_events will not reconcile. That is expected: by_type shows what happened, flagged_events shows what counted.


8. Errors#

Errors return a JSON body with a message:

{ "message": "Invalid API key" }
Status Message Cause
400 external_user_id is required Missing required field when minting.
400 Invalid session id Malformed session ID.
401 access-token and secret-token headers are required Headers missing.
401 Invalid API key Credentials wrong or revoked.
403 API key is not linked to an organization Key not attached to a site.
404 Session not found Unknown ID, or it belongs to another account.

Session IDs are scoped to the account that minted them — you can only read your own sessions.


9. Limits and behaviour#

Session token lifetime 2 hours
Frame capture rate 1 image per minute
Abandoned session timeout 3 hours of silence
Timeline entries returned 1000 (most recent analysis retained)
Evidence URL lifetime Short-lived; re-fetch the report for fresh URLs

No continuous video or audio is recorded. Only periodic still frames are captured, for identity verification.


10. Privacy note#

Facial images are biometric data under India's DPDP Act, GDPR Article 9, and similar laws. You must obtain the candidate's explicit consent before starting a session. ProctorLink provides configurable retention and deletion on request.


Support#

Questions or an integration issue: [email protected] — include your session_id and the timestamp.