{
  "openapi": "3.1.0",
  "info": {
    "title": "ProctorLink API",
    "version": "1.0.0",
    "summary": "Mint proctoring sessions and read integrity reports.",
    "description": "The server-to-server API behind the ProctorLink browser SDK.\n\nAn integration is three calls: your backend mints a session when an attempt starts, the browser SDK runs the session with the returned token, and your backend reads the report afterwards. Your API key never reaches the browser.\n\nThis specification covers the API-key surface only, the part an integrator calls. The session-token endpoints the SDK uses for itself (`/v1/sessions/{id}/resume`, `/v1/sessions/{id}/end`, `/v1/ingest/*`) are an internal protocol between the loader and the enclave; they are deliberately undocumented and may change in a patch release.\n\nFull prose reference: https://proctorlink.com/docs",
    "contact": {
      "name": "ProctorLink support",
      "email": "support@proctorlink.com",
      "url": "https://proctorlink.com/contact"
    },
    "license": {
      "name": "Proprietary",
      "url": "https://proctorlink.com/eula"
    }
  },
  "servers": [
    {
      "url": "https://api.proctorlink.com",
      "description": "Production. A separate evaluation host is available on request; only the host changes."
    }
  ],
  "security": [
    {
      "accessToken": [],
      "secretToken": []
    }
  ],
  "tags": [
    {
      "name": "Sessions",
      "description": "Create a session per attempt and read its report."
    },
    {
      "name": "Reports",
      "description": "Find sessions across an exam."
    }
  ],
  "paths": {
    "/v1/sessions": {
      "post": {
        "operationId": "createSession",
        "tags": ["Sessions"],
        "summary": "Mint a session for one attempt",
        "description": "Call this from your backend when a candidate begins an attempt. Returns a short-lived token to pass to the browser SDK.\n\nMinting again with the same `attempt_id` resumes the existing session and returns `resumed: true` with the same `session_id` and a fresh token. That is how a page refresh, a token expiry mid-exam, and a single-page app route change all rejoin one attempt instead of creating a second session, a second report and a second billed credit.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/MintRequest" },
              "examples": {
                "typical": {
                  "summary": "A normal mint",
                  "value": {
                    "external_user_id": "candidate-123",
                    "exam_id": "math-101-final",
                    "attempt_id": "attempt-789",
                    "allowed_origins": ["https://exams.yourcompany.com"]
                  }
                },
                "withIdPhoto": {
                  "summary": "With an ID photo as the identity reference",
                  "value": {
                    "external_user_id": "candidate-123",
                    "attempt_id": "attempt-789",
                    "reference_image_base64": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ...",
                    "verify_reference_face": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Session created, or an existing one resumed.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/MintResponse" }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/v1/sessions/{id}": {
      "get": {
        "operationId": "getSessionReport",
        "tags": ["Sessions"],
        "summary": "Read the proctoring report for one session",
        "description": "Returns the integrity verdict, face analysis, liveness coverage, browser activity, evidence images and timeline.\n\nFace analysis is deferred, so a report fetched the instant an exam ends will have `integrity.analysis_complete: false`. Wait for `status: \"validated\"` before treating the verdict as final.",
        "parameters": [{ "$ref": "#/components/parameters/SessionId" }],
        "responses": {
          "200": {
            "description": "The report.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/SessionReport" }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/v1/reports/sessions": {
      "get": {
        "operationId": "listSessions",
        "tags": ["Reports"],
        "summary": "List sessions for your account",
        "description": "Newest first, scoped to the organisation the API key belongs to. Use `flagged=true` to get only the attempts worth a human look.\n\nNote: this endpoint returns camelCase field names, while `GET /v1/sessions/{id}` returns snake_case. The two are not interchangeable.",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "description": "Filter by lifecycle status.",
            "schema": { "$ref": "#/components/schemas/SessionStatus" }
          },
          {
            "name": "exam_id",
            "in": "query",
            "description": "Filter to one of your exams.",
            "schema": { "type": "string" }
          },
          {
            "name": "flagged",
            "in": "query",
            "description": "`true` returns only sessions flagged by analysis or scoring.",
            "schema": { "type": "string", "enum": ["true", "false"] }
          },
          {
            "name": "page",
            "in": "query",
            "description": "1-based page number.",
            "schema": { "type": "integer", "minimum": 1, "default": 1 }
          },
          {
            "name": "page_size",
            "in": "query",
            "description": "Rows per page. Clamped to 200.",
            "schema": { "type": "integer", "minimum": 1, "maximum": 200, "default": 50 }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of session summaries.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/SessionListResponse" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "accessToken": {
        "type": "apiKey",
        "in": "header",
        "name": "access-token",
        "description": "Issued in the dashboard under Developers, SDK applications. Server-side only."
      },
      "secretToken": {
        "type": "apiKey",
        "in": "header",
        "name": "secret-token",
        "description": "The matching secret, shown once at creation. Server-side only. Anyone holding both can mint sessions billed to your account and read your reports."
      }
    },
    "parameters": {
      "SessionId": {
        "name": "id",
        "in": "path",
        "required": true,
        "description": "The `session_id` returned when the session was minted.",
        "schema": { "type": "string", "example": "6a7b18df70e4f8ecf2597b6f" }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "A required field is missing, or an identifier is malformed.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": { "message": "external_user_id is required" }
          }
        }
      },
      "Unauthorized": {
        "description": "Credentials missing, wrong or revoked.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": { "message": "Invalid API key" }
          }
        }
      },
      "Forbidden": {
        "description": "The key is not linked to an organisation.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": { "message": "API key is not linked to an organization" }
          }
        }
      },
      "NotFound": {
        "description": "Unknown session, or it belongs to another account. Session ids are scoped to the account that minted them.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": { "message": "Session not found" }
          }
        }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "properties": {
          "message": { "type": "string" }
        },
        "required": ["message"]
      },
      "SessionStatus": {
        "type": "string",
        "enum": ["active", "ended", "validating", "validated", "failed"],
        "description": "`active` in progress. `ended` finished, queued for analysis. `validating` analysis running. `validated` complete and final. `failed` analysis could not complete, browser activity is still valid."
      },
      "MintRequest": {
        "type": "object",
        "required": ["external_user_id"],
        "properties": {
          "external_user_id": {
            "type": "string",
            "description": "Your identifier for the candidate. Opaque to us. Use your own user id rather than an email if you want to minimise personal data."
          },
          "exam_id": {
            "type": "string",
            "description": "Your identifier for the exam. Used for grouping in reports."
          },
          "attempt_id": {
            "type": "string",
            "description": "Your identifier for this attempt. Strongly recommended: it is the stable primary key that makes resume work."
          },
          "allowed_origins": {
            "type": "array",
            "items": { "type": "string", "format": "uri" },
            "description": "Origins permitted to run this session."
          },
          "reference_image_base64": {
            "type": "string",
            "description": "A known-good photo of the candidate, such as an ID card, sent inline. Bare base64 or a data URI. Preferred over the URL form."
          },
          "verify_reference_face": {
            "type": "boolean",
            "default": false,
            "description": "Run face detection on the reference photo and refuse the mint unless it contains exactly one face. Without this, an unusable photo silently disables impersonation detection for the whole attempt and only shows up afterwards as `identity_match: \"inconclusive\"`."
          },
          "ttl_seconds": {
            "type": "integer",
            "description": "Desired session-token lifetime. Clamped to the allowed range; the effective value comes back as `expires_at`."
          }
        }
      },
      "MintResponse": {
        "type": "object",
        "required": ["session_id", "session_jwt", "expires_at"],
        "properties": {
          "session_id": {
            "type": "string",
            "description": "Store this against your attempt record. You need it to fetch the report."
          },
          "session_jwt": {
            "type": "string",
            "description": "Pass to the browser SDK. Safe to expose: scoped to this one attempt and short-lived."
          },
          "expires_at": {
            "type": "integer",
            "description": "Unix seconds. Two hours by default."
          },
          "resumed": {
            "type": "boolean",
            "description": "Present and true only when an existing session was resumed rather than a new one created."
          }
        }
      },
      "Integrity": {
        "type": "object",
        "description": "The headline verdict, computed server-side. The browser cannot influence it.",
        "properties": {
          "score": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100,
            "description": "100 is clean. Penalties are subtracted per finding."
          },
          "level": {
            "type": "string",
            "enum": ["low", "medium", "high"],
            "description": "Risk band. low is 80 or above, medium 50 or above, otherwise high."
          },
          "flagged": { "type": "boolean" },
          "analysis_complete": {
            "type": "boolean",
            "description": "False until face analysis has finished."
          },
          "identity_match": {
            "type": "string",
            "enum": ["pending", "pass", "fail", "inconclusive"],
            "description": "`inconclusive` means nothing could be compared, so identity was never checked. Treat it as unknown, not as clean."
          },
          "reasons": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/IntegrityReason" }
          }
        }
      },
      "IntegrityReason": {
        "type": "object",
        "properties": {
          "code": {
            "type": "string",
            "enum": [
              "impersonation",
              "multiple_faces",
              "no_face",
              "liveness_gap",
              "browser_activity"
            ],
            "description": "Branch on this."
          },
          "detail": {
            "type": "string",
            "description": "Human-readable. Show this to reviewers."
          },
          "weight": {
            "type": "number",
            "description": "Negative. The points deducted."
          }
        }
      },
      "FaceAnalysis": {
        "type": "object",
        "properties": {
          "frames_checked": { "type": "integer" },
          "no_face_frames": { "type": "integer" },
          "multi_face_frames": { "type": "integer" },
          "impersonation_frames": { "type": "integer" },
          "min_match_score": {
            "type": ["number", "null"],
            "description": "Lowest similarity against the reference, 0 to 100. Null if never compared."
          }
        }
      },
      "Liveness": {
        "type": ["object", "null"],
        "description": "How much of the attempt could actually be observed. Null for sessions recorded before this measure existed, which is not the same as a measured zero.",
        "properties": {
          "coverage": {
            "type": "number",
            "description": "Fraction of expected heartbeats received, 0 to 1."
          },
          "unobserved_seconds": { "type": "integer" },
          "longest_gap_seconds": { "type": "integer" },
          "heartbeats_received": { "type": "integer" },
          "heartbeats_expected": { "type": "integer" }
        }
      },
      "Activity": {
        "type": "object",
        "description": "Browser-side integrity signals. `by_type` counts meaningful events only; routine noise is excluded.",
        "properties": {
          "total_frames": { "type": "integer" },
          "total_events": { "type": "integer" },
          "flagged_events": { "type": "integer" },
          "by_type": {
            "type": "object",
            "additionalProperties": { "type": "integer" }
          }
        }
      },
      "EvidenceItem": {
        "type": "object",
        "properties": {
          "seq": {
            "type": "integer",
            "description": "Monotonic capture order. A supplied reference uses -1 so it sorts first."
          },
          "captured_at": {
            "type": ["string", "null"],
            "format": "date-time",
            "description": "Null for a supplied reference, which was not captured during the attempt."
          },
          "is_reference": {
            "type": "boolean",
            "description": "The image every other frame was matched against. Exactly one entry per session has this."
          },
          "source": {
            "type": "string",
            "enum": ["captured", "identity", "supplied"],
            "description": "`captured` a keyframe from during the attempt. `identity` the pre-exam photo taken via captureIdentity(). `supplied` a photo provided at mint."
          },
          "image_url": {
            "type": ["string", "null"],
            "format": "uri",
            "description": "Short-lived signed URL. Fetch or copy the image promptly rather than storing the URL. Null while a frame is still pending upload."
          },
          "result": {
            "type": ["object", "null"],
            "description": "Null for a supplied reference, which is the baseline rather than a frame under test.",
            "properties": {
              "faces": { "type": "integer" },
              "no_face": { "type": "boolean" },
              "multi_face": { "type": "boolean" },
              "match_score": { "type": ["number", "null"] },
              "impersonation": { "type": "boolean" }
            }
          }
        }
      },
      "TimelineEvent": {
        "type": "object",
        "properties": {
          "type": { "type": "string", "example": "tab.hidden" },
          "seq": { "type": "integer" },
          "at": { "type": "string", "format": "date-time" },
          "source": { "type": "string", "enum": ["enclave", "host"] }
        }
      },
      "SessionReport": {
        "type": "object",
        "description": "Everything known about one attempt.",
        "properties": {
          "session_id": { "type": "string" },
          "status": { "$ref": "#/components/schemas/SessionStatus" },
          "external_user_id": { "type": "string" },
          "exam_id": { "type": "string" },
          "attempt_id": { "type": "string" },
          "source": {
            "type": "string",
            "description": "Integration surface the session came from, such as `sdk`."
          },
          "started_at": { "type": "string", "format": "date-time" },
          "ended_at": { "type": ["string", "null"], "format": "date-time" },
          "validated_at": { "type": ["string", "null"], "format": "date-time" },
          "duration_seconds": { "type": ["integer", "null"] },
          "integrity": { "$ref": "#/components/schemas/Integrity" },
          "face_analysis": { "$ref": "#/components/schemas/FaceAnalysis" },
          "liveness": { "$ref": "#/components/schemas/Liveness" },
          "activity": { "$ref": "#/components/schemas/Activity" },
          "evidence": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/EvidenceItem" },
            "description": "One entry per image, oldest first."
          },
          "timeline": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/TimelineEvent" },
            "description": "Chronological, capped at 1000 entries."
          }
        }
      },
      "SessionSummary": {
        "type": "object",
        "description": "A row in the list endpoint. Note the camelCase, which differs from the report.",
        "properties": {
          "id": { "type": "string" },
          "examId": { "type": "string" },
          "externalUserId": { "type": "string" },
          "attemptId": { "type": "string" },
          "source": { "type": "string" },
          "status": { "$ref": "#/components/schemas/SessionStatus" },
          "startedAt": { "type": "string", "format": "date-time" },
          "endedAt": { "type": ["string", "null"], "format": "date-time" },
          "validatedAt": { "type": ["string", "null"], "format": "date-time" },
          "counts": {
            "type": "object",
            "properties": {
              "frames": { "type": "integer" },
              "events": { "type": "integer" },
              "flags": { "type": "integer" }
            }
          },
          "verdict": {
            "type": ["object", "null"],
            "description": "Aggregated face-analysis verdict. Null until analysis has run."
          }
        }
      },
      "SessionListResponse": {
        "type": "object",
        "properties": {
          "sessions": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/SessionSummary" }
          },
          "total": { "type": "integer" },
          "page": { "type": "integer" },
          "pageSize": { "type": "integer" }
        }
      }
    }
  }
}
