Server API
Server-to-server REST API (sk_ secret key): read sessions/outcomes/reports, look up attempts by correlationId, reset/retry/delete sessions.
On this page
The server API lets your backend read proctoring results and manage attempts programmatically — pull a session's outcome into your LMS, fetch the report, or let a candidate retake. It is a server-to-server REST API, authenticated by a secret key.
This is the pull side. The push side is webhooks — events delivered to you the moment they happen. Most integrations use both: webhooks to know when something changes, the API to fetch the detail.
Two keys: publishable vs secret
Your app key now has two halves, like Stripe:
| Key | Prefix | Where it goes | Used for |
|---|---|---|---|
| Publishable | pk_… | the candidate's browser (SDK config) | candidate-side proctoring |
| Secret | sk_… | your backend only (never the browser) | this server API |
The secret authorizes reading and changing your data, so it must never reach the browser. A candidate who had it could reset their own exam — which is exactly why the API is backend-only.
Getting a secret key
In the dashboard → Settings → Keys, on any key, click Issue API
secret. The full sk_… value is shown once — copy it into your
backend's secrets (env var / secrets manager). Only a hash is stored; if you
lose it, re-issue (the old one stops working).
Authentication
Send the secret as a Bearer token:
Authorization: Bearer sk_live_…Successful responses are wrapped in a data envelope; errors return a standard
problem shape with the matching HTTP status:
// success
{ "data": … }
// failure (HTTP 4xx / 5xx)
{ "statusCode": 404, "message": "no attempts for this correlation id", "error": "Not Found" }Branch on the HTTP status code; message carries the human-readable reason.
All endpoints are scoped to your organization — you can only ever see or
touch your own sessions. A session belonging to another org returns 404
(identical to one that doesn't exist).
Base path: /v1.
The model: correlationId → attempts
You issue a correlationId per exam attempt and pass it to the SDK. The
proctoring system may create several sessions under one correlationId —
one per attempt (a retake, a recovered abandon, a reset). So:
- a
correlationIdis the attempt group — what your LMS knows; - a
sessionIdis one attempt — what webhooks hand you.
The API is correlationId-first (the ID you always have), with sessions underneath. You can look up either way.
On the candidate side, the SDK resolves a correlationId to the current
internal attempt via POST /sessions/resolve-attempt — the Vue components
call it automatically on mount; raw-SDK integrators must call it before
starting preflight.
Endpoints
Attempt group (by your correlationId)
| Method | Path | Returns |
|---|---|---|
GET | /v1/attempts/:correlationId | All attempts (sessions) under it, newest first |
GET | /v1/attempts/:correlationId/latest | Just the latest attempt — the usual "did they pass?" |
Sessions
| Method | Path | Returns |
|---|---|---|
GET | /v1/sessions | List your sessions (paginated via ?cursor=, filter ?status=, page size ?limit= — default 50, max 200) |
GET | /v1/sessions/search?q= | Find by id / correlationId / candidate name+email (?limit= — default 25, max 100) |
GET | /v1/sessions/:id | One session: status, attempt, candidate, preflight summary, analysis status |
GET | /v1/sessions/:id/analysis-status |
Search queries shorter than 2 characters return an empty list (no error).
The report PDF includes an AI-generated insights section (summary, per-flag explanations, recommended verdict) from a pluggable LLM provider. If the provider is disabled or fails, the section is skipped — the report still generates.
Lifecycle (your own sessions)
| Method | Path | Effect |
|---|---|---|
POST | /v1/attempts/:correlationId/retake | Let the candidate go again — start a fresh sitting (new sessionId, linked to the prior via retakeOf). Additive. Body: { force?: boolean } |
POST | /v1/sessions/:id/reset | Reset a session to abandoned. Body: { resetPreflight?: boolean }. Production-gated † |
POST | /v1/sessions/:id/reset-preflight | Clear preflight so it re-runs. Production-gated † |
POST |
† Destructive actions are disabled in production by default and return
403 until explicitly enabled server-side: reset / reset-preflight via
ALLOW_PRODUCTION_PUBLIC_SESSION_RESET=true, delete via
ALLOW_PRODUCTION_PUBLIC_SESSION_DELETE=true. Retake is additive, so it is
never gated.
To let a candidate retake, call
POST /v1/attempts/:correlationId/retake. It creates a fresh sitting (attempt n+1) linked to the prior one viaretakeOfand returns the newsessionId— start a new session with it. It's additive (deletes nothing), idempotent, and returns409if an attempt is still in progress. The prior, completed sitting is kept and locked (immutable).
Examples
Did my candidate pass their latest attempt?
curl https://api.example.com/v1/attempts/exam-attempt-123/latest \
-H "Authorization: Bearer sk_live_…"{
"data": {
"sessionId": "sess_…",
"correlationId": "exam-attempt-123",
"status": "ended",
"attemptNumber": 1,
"mode": "live",
"startedAt": "2026-05-26T10:02:11.201Z",
"endedAt": "2026-05-26T10:32:18.014Z",
"candidate": { "id": "cand_…", "name":
candidate is null when the SDK never identified one; preflight is
null until a preflight attempt has been recorded.
Let them retake (additive — keeps the prior attempt):
curl -X POST https://api.example.com/v1/attempts/exam-attempt-123/retake \
-H "Authorization: Bearer sk_live_…"{ "data": { "sessionId": "sess_…", "attemptNumber": 2, "retakeOf": "sess_…", "reused": false } }Start a new SDK session with the returned sessionId.
Pull the report PDF:
curl https://api.example.com/v1/sessions/sess_…/report \
-H "Authorization: Bearer sk_live_…" -o report.pdfErrors
| Status | Meaning |
|---|---|
400 | Bad request (e.g. a DELETE without a matching confirm) |
401 | Missing / malformed / revoked secret key |
403 | Destructive action disabled in production (see the † gating above) |
404 | Session/attempt not found or not in your org |
409 | Retake while an attempt is still in progress |
The 409 body is not the standard problem shape — no message or
statusCode fields:
{
"error": "attempt-active",
"detail": "current attempt is still in progress; end it before retaking (or pass { force: true })"
}Branch on error === "attempt-active" for that case.
Webhooks (the push side)
To be notified rather than poll, register a webhook endpoint (dashboard →
Webhooks). You'll receive HMAC-signed events (session.ended,
session.abandoned, preflight events, …) carrying the sessionId; verify
the signature and look the detail up via this API. See
webhooks.
What the SDK does for you
The candidate-side endpoints (event ingest, media uploads, preflight
face-detect) are called by the SDK in the browser using your pk_
key — you do not call them directly. This server API is only the backend
read/manage surface.
Current SDK builds attach capture-clock metadata to candidate media uploads.
Webcam photos use x-captured-at; relayed screen and webcam recording chunks
use x-recording-started-at, x-captured-start-at, and
x-captured-end-at. Direct multipart recording starts carry the same
recordingStartedAt anchor in the request body. Values are epoch
milliseconds. The server validates chronological ordering, stores capture
time separately from arrival time, and continues to accept older SDK uploads
that omit these fields.
When screenshot evidence capture is enabled, the SDK also posts event-linked
JPEGs (or a structured unavailable reason) under
/event-evidence/sessions/{sessionId}/events/{eventId}. These candidate-side
routes use the same pk_ key, quota, tenant boundary, capture timestamps, and
retry posture as the other SDK media routes. Reviewers read an available image
through the authenticated
GET /sessions/{sessionId}/event-evidence/{eventId} redirect.
Candidate-SDK rate limits & quotas
Commercial billing counts billable sittings rather than raw SDK requests. The first runtime attempt of a live session counts once; reconnects and internal resume attempts do not count again, explicit retakes do, and demo sessions are excluded. A Developer workspace that has used its included allowance receives:
{
"error": "session-quota-exceeded",
"message": "This workspace has used its included sessions for the current billing period.",
"usage": { "used": 50, "included": 50, "periodEnd": "2026-08-01T00:00:00.000Z" }
}The response uses HTTP 402. It applies only when resolving a new sitting;
an existing or reconnecting assessment is not interrupted.
Browser-side SDK traffic (the pk_ key) is rate-limited at 1,200
requests per minute per app key by default, and can additionally be
subject to monthly event and media quotas (off unless configured
server-side). Violations return 429:
// rate limit
{ "error": "rate-limit-exceeded", "message": "…", "retryAfterSeconds": 42 }
// quota
{ "error": "usage-quota-exceeded", "message": "…" }Demo sessions are exempt from the quotas. This /v1 server API (sk_
key) is not rate-limited.