A closed tab, a refresh, or a dropped network does not fragment one exam into several reports. The SDK flushes its event queue on pagehide, keeps the attempt alive, and rejoining with the same stable attempt_id returns resumed: true with the same session_id. A tab close stops the heartbeat, which the report records as a liveness gap rather than losing the attempt, and a network drop is absorbed by an offline-tolerant queue that flushes when the connection returns. Everything below is grounded in the ProctorLink API reference.
A real exam does not run in a single uninterrupted page view. Candidates refresh, close the tab by accident, lose Wi-Fi, hit the back button, or sit an exam that outlasts its session token. Each of those could, if handled badly, split one attempt into several partial reports, each billed separately and each missing part of the story. ProctorLink is built so that the common interruptions rejoin the same attempt instead. The one design rule you own is a stable attempt_id; the SDK and the server handle the rest.
import { ProctorLink } from '@proctorlink/sdk';
// attempt_id must be STABLE for the attempt. Derive it from your own
// attempt record, never from a random value generated on page load.
const attemptId = exam.attemptId;
// Mint on your backend. If this attempt already has an active session,
// the response comes back with resumed: true and the SAME session_id,
// so a refresh or a reopened tab rejoins instead of starting over.
const { session_id, session_jwt } = await mintSession({
attempt_id: attemptId,
});
let session = ProctorLink.createSession({
jwt: session_jwt, // short-lived, origin-bound session token
sessionId: session_id,
});
await session.start(); // rejoins the same attempt if one was activeIf you are integrating from scratch, start with how to add proctoring to a web application for the full mint-start-report loop, then use this page for the edge cases that loop has to survive. For the wider choice between an SDK, a REST API, and an LMS plugin, see proctoring SDK vs API vs LMS plugin.
On a full page navigation or a refresh, the SDK flushes pending data on pagehide and deliberately does not end the session. You do not need any special teardown. When the page loads again, you mint with the same attempt_id and call start() exactly as you did the first time, and the SDK rejoins the attempt already running.
A tab or browser close is the same path, except the candidate may not come back. The heartbeat the SDK sends on a fixed cadence stops, and that silence becomes a gap in the liveness record. A backgrounded tab (a tab switch rather than a close) fires a tab.hidden event, with tab.visible on return; tab.hidden is one of the events that deducts points, while tab.visible is recorded but not scored. For the full picture of what a browser tab can and cannot observe here, read what browser-based proctoring can and cannot detect.
Resume is the mechanism behind all of this. When you mint a session with an attempt_id that already has an active session, ProctorLink does not create a second one. It returns the existing session_id with a fresh token and resumed: true.
// POST /v1/sessions with the same attempt_id while a session is still active:
{
"session_id": "6a7b18df70e4f8ecf2597b6f",
"session_jwt": "eyJhbGciOi...",
"expires_at": 1786443201,
"resumed": true
}So a candidate who refreshes, reconnects, or reopens the exam continues the same proctoring record instead of splitting into two partial ones, and you are billed once for the attempt rather than twice. For this to work, attempt_id must be stable for the attempt: derive it from your own attempt record, not from a random value generated on page load, or every refresh starts a new session. This is the same identity split explained in why proctoring runs in a cross-origin iframe: your backend holds the API key and mints, the browser only ever receives a short-lived token for one attempt.
Events recorded while the connection is down are not lost. They are held in an offline-tolerant queue in the browser and flushed as soon as connectivity returns. You do not have to detect the drop or retry anything yourself for the queue to work.
A long enough outage can outlast the session token. If that happens the SDK raises token-expired rather than failing silently, because without a valid token it cannot deliver data. Re-mint for the same attempt_id, which resumes, and hand the new token back with updateToken; the queued events flush immediately with it. The dead time still appears in the report as a liveness gap under the liveness_gap reason, because the server genuinely could not observe the candidate during it. That is supervision coverage, not an accusation.
The session token is short-lived, 2 hours by default. The clean approach is to avoid expiry altogether: set ttl_seconds at mint to your exam length plus a buffer, and read the returned expires_at rather than assuming your requested value was used, because out-of-range values are clamped. Keep token-expired as the safety net for exams that run longer than planned.
session.on('token-expired', async () => {
// Re-mint for the SAME attempt_id. This resumes while the session
// is still active, returning the same session_id with a fresh token.
const res = await mintSession({ attempt_id: attemptId });
if (res.resumed) {
session.updateToken(res.session_jwt); // same session — carry on,
// queued events flush now
} else {
// The old session was already closed (e.g. the 3-hour sweep after a
// long outage). This is a new attempt with its own report.
session.destroy();
session = ProctorLink.createSession({
jwt: res.session_jwt,
sessionId: res.session_id,
});
await session.start();
}
});The important branch is resumed. 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, and its token belongs to that one. Passing it to updateToken would make every upload fail, so check resumed and start a fresh session deliberately when it is absent. Do not call stop() or destroy() and start a new session just to refresh a token; that ends the attempt server-side and the next mint creates a separate report, billed twice.
A single-page app has an interruption a full page never does: the candidate navigates off the exam route, through a back button, a link, or 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 whatever the candidate does next. Those frames are scored, and every one with no face counts toward no_face. Left unhandled, a candidate is marked down for using your own back button.
// Single-page app: the candidate navigates off the exam route
// (back button, a link, a modal) without the page unloading.
// Leaving the route is NOT finishing the exam.
session.destroy({ endSession: false });
// On return, mint with the SAME attempt_id (resumes) and start again.Tearing down with endSession: false releases the camera, removes the preview, and flushes queued data, but keeps the session active so you can resume it. When the candidate returns, mint with the same attempt_id (the response has resumed: true) and call start() to rejoin. The identity reference and every frame recorded earlier survive, and one attempt still produces one report. This is exactly how the framework guides wire it up, for example teardown in a React effect cleanup or an Angular ngOnDestroy; see adding proctoring to a React app and adding proctoring to an Angular exam portal.
When you only want to stop capturing for a scheduled break or a modal, but keep the SDK mounted, pause() is the lighter alternative. It releases the camera and stops scoring until you call resume(), and it records session.paused and session.resumed so the gap in the frame timeline reads as a deliberate pause rather than interference.
// A scheduled break or a modal over the exam. You choose the trigger —
// the SDK never pauses itself.
session.pause(); // camera released, nothing captured, nothing scored
session.resume(); // camera back, capture continuesEvery interruption maps to a specific response. The table below is the short version of the whole page: what the SDK does on its own, what you have to do, and how the event shows up in the report a reviewer reads.
| Scenario | What the SDK does | What you do | In the report |
|---|---|---|---|
| Full page refresh / reload | Flushes queued data on pagehide. Does not end the session. | Mint again with the same attempt_id on reload, then start(). | Same session_id; at most a short liveness gap if the reload was slow. |
| Tab or browser closed mid-exam | Heartbeat stops; last data flushed on pagehide. | Nothing immediate. The candidate rejoins, or the 3-hour sweep closes it. | tab.hidden / page.unload events and a liveness gap under liveness_gap. |
| Network drop mid-exam | Events queue locally and flush when connectivity returns. | Nothing for the queue. If the token expired too, handle token-expired. | A liveness gap for the dead time (longest_gap_seconds). |
| SPA route change (no page unload) | Preview survives the route change and keeps capturing unless torn down. | Call destroy({ endSession: false }) on route-leave; rejoin on return. | Avoids frames of an empty chair being scored as no_face. |
| Session token expires | Raises token-expired; capture pauses but events keep queueing. | Re-mint same attempt_id, check resumed, call updateToken. | Queued events flush with the new token; nothing from the gap is lost. |
| Deliberate break or modal | Nothing until you call it — the SDK never pauses itself. | pause(), then resume() when the break ends. | session.paused / session.resumed mark the gap; neither is scored. |
A session does not stay open forever waiting for a candidate who closed their laptop. One with no explicit stop() is closed automatically after 3 hours of silence and then analysed like any finished attempt. Until then it stays active and can be resumed. Because face analysis is deferred rather than real-time, a report fetched the instant an exam ends will not yet contain face results. Fetch it when status is validated (equivalently, when integrity.analysis_complete is true), polling on a sensible interval rather than in a tight loop.
| status | Meaning |
|---|---|
active | Attempt in progress. Re-minting with the same attempt_id resumes it. |
ended | Attempt finished (stop() or the 3-hour sweep); queued for analysis. |
validating | Face analysis running. |
validated | Analysis complete. This is the final report — fetch it now. |
failed | Analysis could not complete. Browser activity is still valid. |
A gap in supervision, whether from a tab close, a sleeping machine, a network drop, or a route-change teardown, deducts points under liveness_gap and can flag a session on its own. That is deliberate: the measure is how much of the attempt could be observed, not what the candidate intended. A score alone should never fail a candidate; use the reasons and the evidence frames for human review of anything flagged.
attempt_id on every page load. A random id per load means every refresh mints a brand-new session, so one exam fragments into several partial reports and is billed several times. Fix: derive attempt_id from your own attempt record so it is stable across reloads.stop() or a bare destroy() for an SPA route change. Both end the attempt server-side, so returning to the exam mints a separate session with its own report and its own billed credit. Fix: tear down with destroy({ endSession: false }) and rejoin with the same attempt_id.updateToken without checking resumed. If the old session already ended, the re-mint returns a token for a new session; applying it to the old one makes every upload fail. Fix: branch on resumed and start a fresh session when it is absent.stop()/destroy() and starting again does not refresh a token, it ends the attempt. Fix: handle token-expired and call updateToken, which keeps the same attempt running.identity_match: pending, which is an unfinished result, not a clean one. Fix: fetch when status is validated.The pieces reinforce one another. A stable attempt_id makes refresh, reconnect, and reopen all rejoin the same record. The offline-tolerant queue absorbs network drops without losing evidence. token-expired plus a resumed check keeps a long exam on one report. And destroy({ endSession: false }) stops an SPA route change from scoring an empty chair. None of it requires you to watch for interruptions yourself; you set one stable identifier and respond to the two events the SDK raises.
If some of your exams run inside Moodle rather than your own engine, you do not wire any of this by hand; the drop-in plugin handles the attempt lifecycle for you, 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.
Want to watch a refresh and a reconnect rejoin the same attempt? Sign up at app.proctorlink.com to get your API keys, mint a session, and run one end to end.
Create an account at app.proctorlink.com to get your SDK credentials and start minting sessions.
Install @proctorlink/sdk, the thin loader that mounts the enclave and captures keyframes.
See resume, token refresh, and the integrity report in a 30-minute walkthrough.
Per-session and subscription models for custom applications and LMS exams.
The full walkthrough of minting a session, running the SDK, and reading the report.
Where the session token lives and why the browser only ever holds a short-lived one.
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.
A step-by-step guide to adding exam proctoring to an assessment platform you built: map your users, exams, and attempts to a session, mint it on your backend, run the browser SDK in your exam runner, and route the server-side integrity report into your own review workflow.
Why the ProctorLink SDK mounts a cross-origin iframe enclave instead of running in your page: the camera permission binds to the enclave origin and persists across sites, the session token stays out of host-page JavaScript, and the integrity score is computed server-side.
Set a stable attempt_id, respond to token-expired, and tear down route changes with endSession false. A refresh, a reconnect, or a reopened tab rejoins the same attempt. Run a real session before you commit.