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.comSign 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 callapi.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_base64only. Areference_image_urlis 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:
- A photo captured via
captureIdentity()— it happens last and is deliberate reference_image_base64supplied at mint- 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
errorevent reading "enclave protocol vN does not match loader vM" instead of failing silently. Check your installed version withnpm 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:
- Mint with the same
attempt_id→ the response hasresumed: trueand the samesession_id 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 baredestroy()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.