How to Design an Exam Integrity Score

An exam integrity score condenses a session’s proctoring signals into one number from 0 to 100, computed server-side so the client cannot influence it. Start at 100 and subtract weighted penalties for each finding. Let face analysis drive the verdict and treat browser events as context for a reviewer. Band the result (low, medium, high) and use it to triage sessions for human review, never to fail a candidate automatically. Everything below is grounded in the ProctorLink API reference and scoring model.

Schedule a Demo

What an integrity score is for

A proctored exam produces far more than a reviewer can read by hand: a frame a minute, a stream of browser events, a heartbeat, and an identity comparison on every frame. An integrity score collapses all of that into one comparable number so you can decide which handful of sessions out of thousands actually need a human to look. The score does not decide guilt. It ranks sessions by how much they need attention, and it has to be explainable: every point lost should trace back to a specific, named finding.

ProctorLink returns the score as part of the report you fetch on your backend with GET /v1/sessions/:id. The integrity block is the headline verdict.

// From GET /v1/sessions/:id — the headline verdict,
// computed entirely server-side. The browser cannot influence it.
"integrity": {
  "score": 72,
  "level": "medium",
  "flagged": true,
  "analysis_complete": true,
  "identity_match": "pass",
  "reasons": [
    { "code": "no_face", "detail": "No face detected in 3 frames", "weight": -18 },
    { "code": "browser_activity", "detail": "Tab hidden 2x, 1 clipboard copy", "weight": -10 }
  ]
}

If you are new to the integration, read handling the exam session lifecycle for how a session is minted, run, and resumed, and proctoring SDK vs API vs LMS plugin for where the SDK fits. This page is about the number that falls out at the end.

Why the score has to be computed server-side

The first design decision is where the score is calculated, and there is only one defensible answer. The browser is the one part of the system the candidate controls. Anything computed in page JavaScript can be edited before it reaches you. So the score, and everything feeding it, is computed entirely server-side from the frames and events uploaded during the attempt. The browser SDK reports what it saw; it never decides what any of it is worth, and it never produces a verdict.

This is the core anti-tamper property. Frames upload straight from the browser to object storage, the session token is scoped to one attempt and expires with it, and the analysis that turns frames into a score runs on infrastructure the candidate never touches. It is also why you read the score from the API on your backend rather than from the SDK in the page. For the full reasoning behind that trust boundary, see why proctoring runs in a cross-origin iframe.

How the score is calculated

The model is deliberately simple to keep it explainable. Every session starts at 100, a clean sheet. Each finding subtracts a weighted penalty, and the weight reflects how much the finding bears on whether the exam was taken honestly by the right person. The result is clamped at 0. The penalties below are the ceilings; the actual deduction scales with how often and how strongly a finding occurred.

Signalreasons[].codePenaltyWhat it measures
Identity mismatchimpersonationup to -50Who sat the exam. The heaviest single finding.
Multiple faces in framemultiple_facesup to -30A second person visible to the camera.
No face detectedno_faceup to -25Frames where no face was found. Not read as "left the room".
Browser activity (pooled)browser_activityup to -20 combinedTab hidden, left fullscreen, clipboard, denied camera — one shared cap.
Supervision gapliveness_gapcan flag aloneHeartbeat stopped; the candidate could not be observed.

Every deduction is itemised in the reasons array, each entry carrying a code you branch on, a human-readable detail you show a reviewer, and a negative weight. Because the entries sum to the gap between 100 and the final score, the number is never a black box: you can always say exactly why a session scored what it did.

Why face analysis drives the verdict and browser events are context

Not all signals are equal, and treating them as if they were is how a scoring model manufactures false accusations. The findings that drive the verdict come from face analysis: an identity mismatch, more than one face in frame, or no face at all. These speak to who was present and whether they sat the exam themselves, which is what the whole exercise is about. That is why an identity mismatch can take off up to 50 points on its own.

Browser events are different. A tab switch, leaving fullscreen, a clipboard action, or a denied camera are worth recording, but each fires often for innocent reasons. So they are pooled into a single browser_activity reason, capped at a combined 20 points. No amount of ordinary browser activity can, by itself, push a session into the flagged band. This restraint is a feature, not a weakness: it means a candidate who rotated their phone or whose headset reconnected is not marked down as a cheat. Describe browser events to a reviewer as context, and let face analysis carry the verdict. The same split, and what a browser tab genuinely can and cannot see, is covered in what browser-based proctoring can and cannot detect.

A note on wording that matters for defensibility: the model reports frames where no face was detected, not “the candidate left the room”. Face analysis is probabilistic, and the score is built from what was measured, not from what you might infer about it.

Reading identity_match, not just the score

The score alone hides a distinction that matters. identity_match carries the identity verdict separately: pass means frames were compared and none fell below the match threshold, fail means at least one did, and pending means analysis has not finished. The one to handle carefully is inconclusive: it means analysis finished but nothing could be compared, because no frame had exactly one clear face or the reference photo was unusable. Identity was never checked either way.

switch (report.integrity.identity_match) {
  case 'pass':         // frames were compared; none fell below threshold
    break;
  case 'fail':         // at least one frame did not match the reference
    queueForReview(report);
    break;
  case 'inconclusive': // nothing could be compared — UNKNOWN, not clean
    queueForReview(report);
    break;
  case 'pending':      // analysis not finished — fetch again when validated
    break;
}

Treat inconclusive as unknown, not as a clean pass. It is easy to misread a session that scored well on everything else as fine when identity was never actually verified. The usual cause is an unusable reference photo, which you can catch at mint time with verify_reference_face so the candidate retakes it while they are still present, rather than surfacing it after the exam is over.

Banding the score and deciding what to flag

A raw number out of 100 is hard to act on consistently, so the score is bucketed into three risk bands. The level field gives you the band directly, and flagged is true for any session that needs review.

levelScoreWhat it means for review
lowscore >= 80Clean. Safe to auto-pass under most policies.
mediumscore 50 to 79Flagged. Review the reasons and evidence frames.
highscore < 50Flagged. One or more heavy findings; always review.

Any session scoring below 80 is flagged. The bands and the weights behind them are produced server-side and are identical for every session, which is what makes a result comparable across candidates and defensible if challenged later. What you decide is the policy on top: branch on integrity.level and the reasons[].code values to route sessions. A high-stakes certification exam and a low-stakes practice quiz can treat the same score differently without changing how it was measured.

// On your backend. Fetch only once analysis has finished.
const report = await getSession(sessionId);   // GET /v1/sessions/:id

if (report.status !== 'validated') return;    // not final yet — poll later

const { level, flagged, identity_match, reasons } = report.integrity;

// A score is a triage signal, not a verdict. Route, do not auto-fail.
if (level === 'low' && identity_match === 'pass') {
  autoApprove(report);
} else {
  // Hand the reviewer the codes and the evidence frames behind them.
  queueForReview(report, reasons);   // reasons[].code + report.evidence
}

Common mistakes when designing a score

  • Auto-failing on the number. A score is a triage signal, not a judgment. Failing a candidate because a session scored 68 skips the reasons and the evidence that explain it. Fix: auto-pass clean sessions, route flagged ones to a human who reads reasons and the evidence frames.
  • Weighting browser events like face findings. If a few tab switches can sink a score, ordinary behaviour produces false accusations. Fix: pool browser events under one browser_activity reason with a shared cap, and let face analysis carry the verdict.
  • Reading identity_match: inconclusive as clean. It means identity was never checked, not that it passed. Fix: treat it as unknown, route it to review, and catch unusable reference photos at mint with verify_reference_face.
  • Computing or trusting a client-side score. Anything the browser calculates, a candidate can edit. Fix: read the score only from GET /v1/sessions/:id on your backend, where it was computed server-side from the uploaded evidence.
  • Reading the score before analysis finishes. Face analysis is deferred, so an early fetch shows a 100 with zeroed counts and identity_match: pending, which is an unfinished result, not a clean one. Fix: fetch when status is validated or analysis_complete is true.

Why this model holds up

The design choices reinforce one another. Server-side computation makes the score tamper-resistant. A clean-sheet start with itemised, weighted deductions makes it explainable down to the point. Letting face analysis drive the verdict while browser events stay pooled and capped keeps ordinary behaviour from manufacturing a flag. And banding plus a human review step turns the number into a decision process rather than an automatic sentence. A score built this way is one you can defend to an exam board, a candidate who appeals, or an auditor.

For how an automated score compares with a human watching a live feed, and where each is the better fit, read AI proctoring vs live proctoring. 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

None on its own. The score is a triage signal, not a verdict. It starts at 100 and loses points for each finding, and the band tells you how closely to look: low is 80 or above, medium is 50 to 79, and high is below 50. Any session below 80 is flagged for review. What you do with a flag is your policy decision. The defensible pattern is to auto-pass clean sessions and route anything flagged to a human who reads the reasons array and the evidence frames, rather than failing a candidate on a number.

Sources & references

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

Next steps

Want to see a real integrity score with its reasons and evidence frames? Sign up at app.proctorlink.com to get your API keys, run a session, and read the report end to end.

More Proctoring Guides

Score Sessions You Can Defend

A server-side score, itemised reasons, face analysis driving the verdict, and a human review step. Run a real session and read the integrity report before you commit.