You add proctoring to an assessment platform you built by wiring three integration points into what you already have: your backend mints a session with POST /v1/sessions when an attempt starts, your exam runner calls ProctorLink.createSession({ jwt, sessionId }).start() in the browser, and your backend reads the verdict with GET /v1/sessions/:id after the attempt. You do not rebuild your item bank, timing, or grading. You map your own identifiers onto a session and route the result into your review workflow. Every call below comes from the ProctorLink API reference.
A purpose-built assessment platform already owns the hard parts: a user model, an exam or item bank, an attempt state machine, timing, and grading. Proctoring does not replace any of that. It attaches to the moments you already have, when an attempt starts, while it runs, and when it ends, and hands you back a server-side integrity verdict to fold into results. The integration is small on purpose, so the risk stays contained to three calls rather than spreading through your codebase.
This is the same three-part split described in How to add proctoring to a web application, viewed from the angle of a platform with its own domain model. If you are still deciding between the SDK, the REST API, and a drop-in LMS plugin, start with Proctoring SDK vs API vs LMS plugin. To set expectations for what a browser can and cannot see before you design your review rules, read what browser-based proctoring can and cannot detect.
The only design decision unique to a custom platform is which of your identifiers become which session fields. All of them are opaque to ProctorLink and flow straight back to you in the report, so pick the keys you already query by. The one that matters most is attempt_id: it must be the stable primary key of your attempt record, not a value generated on page load, because it is what lets a refresh or reconnect resume the same session.
| In your platform | Session field | Notes |
|---|---|---|
| User / candidate record | external_user_id | Use your PK, not an email, to minimise personal data. Opaque to ProctorLink. |
| Exam / quiz definition | exam_id | Optional. Groups attempts of the same exam in reports. |
| Attempt record | attempt_id | Must be stable for the attempt. This is what enables resume. |
| Tenant / exam domain | allowed_origins | Origins allowed to run the session. Others are rejected. |
| Stored ID / KYC photo | reference_image_base64 | Optional. Reuse a photo you already hold for identity matching. |
| Exam duration | ttl_seconds | Set to your exam length plus a buffer. Read back expires_at. |
Hook the mint into the moment your platform already creates an attempt. Because it carries your access-token and secret-token, minting stays on your backend, never in the exam page. Store the returned session_id on the attempt row, keyed exactly like everything else you persist, and hand only session_jwt and session_id to the browser.
// Your backend, when a candidate starts an attempt.
// access-token and secret-token never leave the server.
POST /v1/sessions
access-token: <YOUR_ACCESS_TOKEN>
secret-token: <YOUR_SECRET_TOKEN>
content-type: application/json
{
"external_user_id": "your-user-pk", // your users table PK, not an email
"exam_id": "your-exam-pk", // your exam/quiz identifier
"attempt_id": "your-attempt-pk", // your attempt record PK — enables resume
"allowed_origins": ["https://exams.yourcompany.com"],
"reference_image_base64": "data:image/jpeg;base64,/9j/4AAQ...", // optional: a photo you already hold
"ttl_seconds": 7200 // exam length plus a buffer
}
// Response — persist session_id on the attempt, pass the rest to the browser
{
"session_id": "6a7b18df70e4f8ecf2597b6f",
"session_jwt": "eyJhbGciOi...",
"expires_at": 1786443201
}If your onboarding already captured a known-good photo of the candidate, reuse it: pass it as reference_image_base64 (bare base64 or a data: URI, JPEG, PNG, or WebP up to 1 MB decoded). It is stored at mint, so a bad payload fails fast with a 400, and because you send the bytes, nothing of yours needs to be publicly reachable. Set ttl_seconds to your exam length plus a buffer and read the returned expires_at, since values outside the allowed range are clamped rather than rejected.
The browser SDK is framework-agnostic and imperative. Call it when your exam screen opens and tear it down when the attempt ends, however your platform models that. The camera lives in a cross-origin iframe enclave the SDK mounts itself, so it does not have to live in your component tree.
npm install @proctorlink/sdkThe loader is published on npm as @proctorlink/sdk, so it installs like any other dependency. You get your API keys when you sign up at app.proctorlink.com. If your platform is built in React or Angular, the framework-specific lifecycle is covered in React proctoring integration and Angular proctoring integration.
// Your exam runner, in the browser. Call this when the exam screen opens.
import { ProctorLink } from '@proctorlink/sdk';
const session = ProctorLink.createSession({
jwt: sessionJwt, // session_jwt handed down from your backend
sessionId, // session_id handed down from your backend
});
session.on('permission', ({ camera }) => {
if (camera === 'denied') { /* enforce your own policy */ }
});
session.onEvent((event) => console.log(event.type, event));
// Safety net for exams that outlast the token: re-mint the SAME attempt_id.
session.on('token-expired', async () => {
const res = await mintOnYourBackend(attemptId); // POST /v1/sessions again
if (res.resumed) {
session.updateToken(res.session_jwt); // same session, carry on
}
});
await session.start();
// when the attempt finishes (submit, timer, navigation away):
session.stop();
session.destroy();By default recording begins as soon as the camera is granted and the first captured frame becomes the identity reference. If you did not supply a photo at mint and want matching against a deliberate one, create the session with autoStartCapture: false, call captureIdentity() after start() resolves, then call beginCapture(). If your exam page sets a Content-Security-Policy, allow the enclave origin so the iframe can mount:
frame-src https://enclave.proctorlink.com;After the attempt, your backend calls GET /v1/sessions/:id with the session_id you stored. Face analysis is deferred, not real-time, so fetch the report once status is validated, or equivalently when integrity.analysis_complete is true. Before that, face counts are zero and identity_match is pending, which is an unfinished result rather than a clean one. Poll on a sensible interval such as every 30 seconds with backoff, or fetch on demand when a reviewer opens the attempt.
// Your backend, once status is "validated"
// (or integrity.analysis_complete is true).
GET /v1/sessions/6a7b18df70e4f8ecf2597b6f
access-token: <YOUR_ACCESS_TOKEN>
secret-token: <YOUR_SECRET_TOKEN>
// Response (trimmed) — store this against your attempt
{
"status": "validated",
"integrity": {
"score": 100,
"level": "low",
"flagged": false,
"identity_match": "pass",
"reasons": []
}
}The integrity.score starts at 100 and drops as penalties apply. The level is low at 80 or above, medium from 50 to 79, and high below 50, and flagged turns true below 80. The verdict is computed server-side only, so a candidate cannot influence it from your exam page no matter what they do in the browser.
The point of owning the platform is that you also own the follow-up. Branch on the report rather than on a single number: auto-pass clean attempts and send anything flagged to a human queue. Give reviewers integrity.reasons, where each entry has a code (impersonation, multiple_faces, no_face, or browser_activity), a human-readable detail, and a negative weight, next to the evidence images.
integrity.level | Score | flagged | Suggested action |
|---|---|---|---|
low | 80 and above | false | Auto-pass. Keep the report for audit. |
medium | 50 to 79 | true | Send to a reviewer with reasons and evidence. |
high | below 50 | true | Prioritise for review. Do not auto-fail on the score alone. |
// Turn one report into a review decision, keyed by your attempt.
const report = await getSessionReport(attempt.sessionId); // GET /v1/sessions/:id
if (report.status !== 'validated') {
return scheduleRetry(attempt); // analysis is not finished yet
}
if (report.integrity.flagged) {
// Route to a human. Show reasons + evidence, do not auto-fail.
enqueueForReview(attempt, {
level: report.integrity.level, // 'medium' | 'high'
reasons: report.integrity.reasons, // [{ code, detail, weight }]
});
} else {
markClean(attempt); // level 'low', score >= 80
}Face analysis drives the verdict; browser events are context for a reviewer. A score alone should never fail a candidate, so keep a human in the loop for anything flagged and use reasons and evidence to decide. That restraint is deliberate: ordinary behaviour like a rotated phone or a normal exam submission does not manufacture a false accusation.
attempt_id per page load. A random value created when the exam screen opens defeats resume, so every refresh mints a new session and bills the attempt twice. Fix: derive attempt_id from your own attempt record so it is stable for the whole attempt.access-token and secret-token in client code hands anyone the ability to mint sessions billed to you and read your reports. Fix: mint only on your backend and pass the browser just session_jwt and session_id.status is validated, face counts are zero and identity_match is pending. Treating that as a clean pass hides real findings. Fix: gate your results pipeline on validated and poll with backoff.reasons and evidence, and let a human make the call.allowed_origins and ttl_seconds. Without allowed_origins any page can run the session; without a sensible ttl_seconds the token can die mid-exam. Fix: pin the origins your exam runs on and set the token lifetime to your exam length plus a buffer, with token-expired as the safety net.The split matters most when you control the whole stack and could, in principle, put everything in one place. Your API key stays server-side. The browser receives a short-lived, origin-bound session token, and that token lives inside the cross-origin iframe enclave served from ProctorLink’s own domain rather than in your application state. Because the camera permission binds to the enclave origin, a candidate who has already granted it on one exam is not prompted again on another attempt that embeds ProctorLink. The integrity score is computed server-side only, which is the core anti-tamper property: a candidate cannot edit their own verdict from your exam page.
Keyframes upload from the browser straight to object storage through presigned URLs, so image bytes never transit the API, and only periodic stills are captured rather than continuous video, which keeps bandwidth predictable at scale. If some of your exams run inside Moodle rather than your own engine, you can skip this integration there and use the drop-in plugin covered in Best Moodle proctoring plugin. Across published deployments, ProctorLink has supported more than one million proctored exam sessions (methodology note below). Full cohort sizes and outcomes are on the case studies page.
Institutions evaluating proctoring tools often look for independent feedback outside vendor case studies. ProctorLink is listed on G2, where Moodle administrators and training teams share verified product reviews.
Read ProctorLink reviews on G2 →Deployment statistics and product behaviour described in this guide link to the sources below.
Ready to proctor exams in your own platform? Sign up at app.proctorlink.com to get your API keys, wire the three calls above into a real attempt, and check the reviewer workload before you commit.
Create an account at app.proctorlink.com to get your SDK credentials and start minting sessions.
Install @proctorlink/sdk, the browser loader that mounts the enclave and captures keyframes.
See the browser SDK and integrity report in a 30-minute developer walkthrough.
Per-session and subscription models for custom applications and LMS exams.
The framework-agnostic walkthrough of the same three calls.
The decision guide for picking the SDK route over the plugin or an LTI launch.
Back to the knowledge hub for the full SDK and proctoring series.
Online proctoring supervises remote exams via webcam and microphone inside your LMS, logging rule violations with timestamps so reviewers judge flagged cases, not every session.
AI proctoring uses machine learning to automatically detect suspicious exam behaviour such as multiple faces, tab switching, and absence from the camera.
A comparison of leading Moodle proctoring plugins for universities that need native LMS integration, AI monitoring, and institution-owned data.
How university exam offices choose online proctoring software for entrance grids, finals, and multi-faculty calendars, including stakeholder RFPs and centre-vs-online cost tradeoffs.
How certification bodies and training providers use online proctoring for identity proofing, item-bank protection, and defensible, audit-ready credentialing exams.
A side-by-side comparison of AI proctoring and live human proctoring for cost, scale, accuracy, and exam security.
Practical steps to reduce cheating in Moodle quizzes using proctoring settings, question banks, timing controls, and AI monitoring.
Feature-by-feature comparison of Moodle proctoring plugins covering integration, AI detection, pricing, and data storage.
A guide to online exam proctoring for Indian universities and certification providers, including compliance, pricing in INR, and local case studies.
How to choose between a proctoring LMS plugin, a browser SDK, and a REST API when adding proctoring to software you already own, with a decision guide and code examples.
A step-by-step guide to adding exam proctoring to a web app you own: mint a session on your backend, run the browser SDK, and read a server-side integrity report, with code from the API reference.
What browser-based proctoring can detect (tab switches, clipboard, faces, identity) and what it cannot (phones, a second monitor, a virtual machine), and why the tiering exists.
A step-by-step guide to adding exam proctoring to a React app: mint a session on your backend, wrap the browser SDK in a useEffect hook, handle StrictMode and token expiry, and read a server-side integrity report.
A step-by-step guide to adding exam proctoring to an Angular app: mint a session on your backend, hold the browser SDK in an injectable service, start it in ngOnInit and tear it down in ngOnDestroy, handle NgZone and token expiry, and read a server-side integrity report.
How LTI 1.3 connects a proctoring tool to your LMS: the OpenID Connect launch handshake, why it replaces LTI 1.1 shared secrets, how it compares to a Moodle plugin and the browser SDK, and how to read the same server-side integrity report.
Your backend mints a session, your exam runner starts the SDK, and you read a server-side integrity score into your own review queue. Pilot the SDK on a real attempt and check the reviewer workload before you commit.