How to Add Proctoring to a Web Application

You add proctoring to a web app you own in three steps: your backend mints a session with one API call, a small browser loader runs the exam and captures the candidate, and your backend reads a server-side integrity report when the exam ends. Your API key stays on your server. The browser only ever holds a short-lived session token, and candidates install nothing. The rest of this page is the working integration, using only calls from the ProctorLink API reference.

Schedule a Demo

What are the moving parts?

Proctoring a custom application is a split between two places. A backend call cannot see the candidate, and a browser tab cannot hold a secret safely, so the work divides cleanly along that line. Your server does the two things a server can do, mint the session before the exam and read the report after it, and the browser does the one thing only a browser can do, capture the candidate.

PartWhere it runsCallCredentials it holds
1. Mint a sessionYour backendPOST /v1/sessionsaccess-token and secret-token (server-side only)
2. Run the examCandidate browserProctorLink.createSession(...).start()Only the short-lived session_jwt
3. Read the reportYour backendGET /v1/sessions/:idaccess-token and secret-token (server-side only)

If you are still deciding whether the SDK route is right for you at all, rather than the Moodle plugin or an LTI 1.3 launch, start with Proctoring SDK vs API vs LMS plugin. If you are new to the field, read What is online proctoring? and What is AI proctoring? first, then come back here to build.

Step 1: Mint a session on your backend

One session covers one quiz attempt. Your backend calls POST /v1/sessions with your server credentials and an identifier for the candidate. The attempt_id is optional in the schema but strongly recommended, because it is what lets a session resume after a refresh or a network drop.

// Your backend. Your API key never enters the browser.
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"]
}

// Response
{
  "session_id": "6a7b18df70e4f8ecf2597b6f",
  "session_jwt": "eyJhbGciOi...",
  "expires_at": 1786443201
}

The session_jwt is a short-lived token scoped to this one attempt, and it is the only value you pass to the browser. Your access-token and secret-token stay on your server. The token lasts two hours by default. Set ttl_seconds to your exam length plus a buffer, and read the returned expires_at, because values outside the allowed range are clamped rather than rejected.

Step 2: Start proctoring in the candidate browser

Install the SDK and hand it the token your backend just minted. In the default flow, recording begins as soon as the camera is granted and the first captured frame becomes the identity reference. That is the entire browser integration.

npm install @proctorlink/sdk@^0.1.3
import { ProctorLink } from '@proctorlink/sdk';

// jwt and sessionId come from the POST /v1/sessions response above,
// fetched from your own backend endpoint.
const session = ProctorLink.createSession({
  jwt: sessionJwt,   // session_jwt
  sessionId,         // session_id
});

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

await session.start();

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

Candidates install nothing. This is Tier 1, the browser-only tier: it observes camera, microphone, focus and blur, fullscreen state, clipboard, and device changes. If you want matching against a deliberate, well-framed photo rather than whatever the first frame caught, create the session with autoStartCapture: false, call captureIdentity() after start() resolves, then call beginCapture() to start the exam.

If your page sets a Content-Security-Policy, allow the enclave origin so the iframe can mount:

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

Step 3: Handle permission and token expiry

Two events are worth wiring up deliberately. The permission event tells you whether the candidate granted the camera, so you can enforce your own policy. The token-expired event fires if a token dies mid-attempt, so capture never fails silently. Re-mint for the same attempt_id, which resumes the session, and call updateToken() with the fresh token.

// The token is short-lived. If it expires mid-exam, re-mint for the
// SAME attempt_id, which resumes the session, then hand the SDK the new token.
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 ended. 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();
  }
});

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. Check resumed before calling updateToken(): a re-mint only resumes while the session is still active, and if the abandonment sweep has already closed it, the mint creates a new session whose token belongs to a different attempt. Do not call stop() or destroy() just to refresh a token, because that ends the attempt server-side and the next mint bills a second session.

Step 4: Read the integrity report on your backend

After the exam, your backend calls GET /v1/sessions/:id. 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.

// Your backend. Fetch 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)
{
  "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 the candidate cannot influence it. A score alone should not fail anyone: use reasons and evidence to drive human review of anything flagged.

How much can a browser-only integration detect?

The SDK route runs entirely in the tab with no install, which covers the signals a browser can legitimately observe. Deeper inspection needs more privileged software, which is why the deployment tiers exist. Pick the tier that matches your risk, not the deepest one available.

TierCandidate installWhat it can observe
Tier 1: Browser SDKNoneCamera, microphone, focus and blur, fullscreen state, clipboard, device changes
Tier 2: Browser extensionExtensionAdds tab enumeration, per-application focus, and download blocking
Tier 3: Desktop agentDesktop appProcess inspection, display enumeration, screen-recording detection, and full lockdown

A browser tab cannot see other applications, a second device, a virtual machine, or a remote-desktop session. That is a property of the web platform, not a gap in any one vendor, and it is exactly why the extension and desktop tiers exist. Face analysis is probabilistic too: the report counts frames where no face was detected, which is a signal for a reviewer, not proof that a candidate left the room.

Common mistakes when wiring the integration

  • Putting your API key in browser JavaScript. The access-token and secret-token are server-side credentials. Anyone holding them can mint sessions billed to your account and read your reports. Fix: mint on your backend and pass only the session_jwt to the SDK.
  • Generating a random attempt_id on page load. Resume keys off attempt_id, so a new value on every refresh splits one exam into several sessions and bills the attempt more than once. Fix: derive attempt_id from your own attempt record so it is stable for the attempt.
  • Fetching the report the instant the exam ends. Face analysis is deferred, so an immediately fetched report has zero face counts and identity_match of pending. Fix: fetch when status is validated, polling on a sensible interval rather than a tight loop.
  • Forgetting the CSP frame-src rule. If your page sets a Content-Security-Policy and does not allow the enclave origin, the iframe cannot mount and capture never starts. Fix: add the enclave origin to frame-src.
  • Refreshing a token by tearing the session down. Calling stop() or destroy() to get a new token ends the attempt server-side, so the next mint creates a separate session: two reports for one attempt, billed twice. Fix: handle token-expired and call updateToken() with a freshly minted token for the same attempt_id.

Where does the design keep your data safe?

The security model is worth understanding before you build. Your API key is server-side only. The browser receives a short-lived, origin-bound session token, and that token lives inside a cross-origin iframe enclave served from ProctorLink’s own domain rather than in host-page JavaScript. The camera permission binds to the enclave origin, so a candidate who has already granted it on one exam is not prompted again on another site that embeds ProctorLink. The integrity score is computed server-side only, which is the core anti-tamper property: a candidate cannot edit their own score no matter what they do in the browser.

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. If your exams instead run inside Moodle, you can skip this integration entirely 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.

What customers say on G2

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 →

Frequently Asked Questions

Two pieces of code, on two sides. On your backend you call POST /v1/sessions with your access-token and secret-token to mint a short-lived session token. In the candidate's browser you install @proctorlink/sdk and call ProctorLink.createSession({ jwt, sessionId }).start(), which mounts the enclave, requests the camera, and captures keyframes. After the exam your backend calls GET /v1/sessions/:id to read the integrity report. Your API key never enters the browser, and the browser only ever holds the session token, which is scoped to one attempt and expires with it.

Sources & references

Deployment statistics and product behaviour described in this guide link to the sources below.

Next steps

Ready to add proctoring to software you already own? Get credentials, then wire the three calls above into a real exam and check the reviewer workload before you commit.

More Proctoring Guides

Add Proctoring to Your Own Application

Your backend mints a session, a small browser loader runs it, and you read a server-side integrity score. Pilot the SDK on a real exam and check the reviewer workload before you commit.