openapi: 3.1.0
info:
  title: Creatium Assess Partner API
  version: 1.6.1
  summary: Catalog, learner results, enrollment and assignments for learning platforms that list Creatium Assess assessments.
  description: |
    The Creatium Assess Partner API lets a learning platform, or an organization's own systems,
    import its assessment catalog, synchronize learner attempts and results, and optionally enroll
    learners or give them assignments ahead of launch.

    **Identity.** Learners are identified by enterprise email address, compared case-insensitively
    and returned in lower case. It is the same address the organization's SAML 2.0 identity provider
    sends as NameID. Every person also has a `personId` (UUID) that stays the same when their email
    changes (`POST /people/{personId}:changeEmail`), and can carry the employer's own `externalId`. An `assessmentId` is a UUID. It is stable for the life of the assessment, identical in
    every endpoint, and never reused.

    **Tenancy.** Every client belongs to one Creatium organization. Every request sees only that
    organization's assessments, learners and results.

    **Transport.** HTTPS only, TLS 1.2 or later: TLS 1.0 and 1.1 are refused. Plain `http://` answers
    `308` to the same `https://` address, but call https directly: a token sent over http has already
    crossed the network in the clear. Responses carry `Strict-Transport-Security`.

    **Conventions.** JSON over HTTPS. Timestamps are returned in ISO 8601 UTC with a `Z`
    suffix; timestamps sent with another offset are accepted and converted. Field names are camelCase.
    List endpoints use cursor pagination (see `Pagination`); a cursor only works with the filters it
    was issued for (other filters give `400`). An ID in the path that is not a UUID gives `400`; a
    well-formed one that matches nothing gives `404`. An unknown path gives `404` and a method a path
    doesn't take gives `405` with `Allow`, both as problem details. Errors use RFC 9457 problem details (see
    `Problem`); each error `code` is explained at `https://app.assess.creatium.com/docs#error-<code>`.
    Unknown fields may be added to responses at any time within a version, so clients should ignore
    fields they do not recognize.

    **Response shape.** Reads wrap their payload in `data` (lists add `pagination`). Six writes return
    the object itself: `POST /enrollments`, `POST /assignments`, `GET` and `PATCH /assignments/{assignmentId}`,
    `POST /assignments/{assignmentId}/members` and `PUT /people/{learnerEmail}`. v2 will wrap every response.

    **Personal data in paths.** Paths that name a person (`/enrollments/{assessmentId}/{learnerEmail}`,
    `/assignments/{assignmentId}/members/{learnerEmail}`, `/people/{learnerEmail}`) take either the
    email address or the `personId`. Prefer the `personId`: proxies and access logs may record paths.
    v2 will take only the `personId`.

    **Data retention.** Results are kept while the organization is a customer and deleted 90 days after
    it leaves. Proctoring readings are kept 90 days. A `REMOVED` record stays in `/results` for 30 days.
    Delivered webhook events are kept 30 days. `POST /people/{personId}:erase` removes a person on request
    (GDPR, CCPA) at any time. Data is stored in the United States; other regions on request.

    **Launch options.** Besides `launchUrl` (SP-initiated SSO), every assessment can be delivered from
    an LMS as a SCORM 1.2 package (Assessment Designer → Share → SCORM) or embedded in an iframe at
    `/embed/{assessmentId}` from domains the organization allows (Admin → Embed). Results from every
    route appear in `/results`.

    **Concurrency and retries.** `GET /assignments/{assignmentId}` returns an `ETag`; send it back as
    `If-Match` on `PATCH` and a change made by someone else in between gives `412`. Every `POST`,
    `PATCH` and `PUT` takes an `Idempotency-Key`.

    **Managers and privacy.** People can carry `managerEmail`, `department` and `location`
    (`PUT /people`, `POST /people:batch`). Person-level reads and analytics take `team`, `managerEmail`
    and `includeIndirect` to narrow themselves. A client can get a token *for a manager* (token exchange,
    see `/oauth/token`): it reads only, and only that manager's reports, direct and indirect. What such a
    token may see, how small a group analytics will show, and whether proctoring and answer keys need
    their own scopes is the organization's policy (`GET /organization/policy`, set by its admins).

    **Events.** Every change a webhook announces is also kept 30 days at `GET /events`, so a client
    that missed deliveries can catch up. Endpoints can be managed by API (`/webhook-endpoints`, scope
    `webhooks:manage`) as well as in the admin portal. A client can rotate its own secrets
    (`/client-secrets`) and revoke its tokens (`/oauth/revoke`).

    **Rate limits.** 600 requests a minute per client. Every response carries `RateLimit-Limit`,
    `RateLimit-Remaining` and `RateLimit-Reset`, and an `X-Request-Id`; over the limit, `429` with
    `Retry-After`. List pages hold up to 200 records (1,000 for `/analytics/answers`).

    **Credentials.** OAuth 2.0 client credentials (`oauth2`) are the way in. Each client can hold two
    live secrets, so a secret is rotated without downtime: add a new secret on the organization's API
    page, switch to it, then revoke the old one. Older integrations may use an API key (`apiKey`, see
    security schemes); prefer OAuth clients for anything new.

    **Sync schedule.** Catalogs change rarely: import hourly with `updatedSince`. Results: pull every
    few minutes, or on each `result.updated` webhook.

    **Timeouts.** Calls normally answer in a second or two; the server allows up to 900 seconds.
    Use a 30-second client timeout and retry with exponential backoff.

    **Network.** No IP allow-listing is needed in either direction: calls to Assess are public HTTPS
    secured by OAuth. If your firewall allow-lists the addresses you call, every `*.assess.creatium.com`
    host (production and staging) answers on `34.102.249.142`. Webhook deliveries come from Google Cloud
    addresses that change, so verify the `Creatium-Signature` rather than the sender's IP. Fixed outbound
    IPs are available on request.

    **Sandbox.** On staging (`https://staging.app.assess.creatium.com/api/v1`) a partner gets its own
    sandbox organization, with assessments to integrate against. There, `POST /sandbox/attempts`
    simulates a learner finishing (or starting) an assessment: the result appears in `/results` and
    `result.updated` and `assignment.completed` fire, as for a real learner. Ask support for one.

    **Service levels.** The production API is available 99.5% of each calendar month, excluding
    maintenance announced 48 hours ahead. Live and past availability: https://status.assess.creatium.com,
    which checks `GET /health` every five minutes. Support answers within 24 hours; production down
    within 4 hours at the latest.

    **Changes in 1.2 to 1.6** (live 1 October 2026; all additive except these, which an existing client
    may notice): a path ID that isn't a UUID gives `400` (was `404`); a cursor reused with other filters
    gives `400`; unknown paths and wrong methods answer problem+json `404`/`405` (was an HTML page);
    `/oauth/token` without `grant_type` gives `invalid_request`; a `NOT_STARTED` record's `resultId` is now
    a UUID that its first attempt keeps (`enr_…` IDs still resolve); `ResultStatus` adds `REMOVED`;
    `reportUrl` is `/report/{resultId}`; an analytics team with fewer finished attempts than the
    organization's `minimumGroupSize` (default 5) is `suppressed`; an older attempt's `lastUpdated` moves
    when a retake flips its flags; during a webhook secret rotation `Creatium-Signature` carries two
    `v1` values. The guide lists everything new, version by version.

    **Versioning.** This is v1 (`/api/v1`). Fields and endpoints can be added at any time within a
    version. Breaking changes ship only in a new version (`/api/v2`), with at least 90 days' notice,
    and v1 keeps running alongside.

    **Data residency.** Learner data is stored and processed in the United States (Google Cloud, US).
    Residency in other countries is available on request.

    **Support.** Through a shared Slack group or support@creatium.com. Production down: we respond as
    soon as possible, and within 4 hours at the latest. Everything else: we respond within 24 hours.
    Escalation: Jisha Nambiar, jisha@creatium.com. Status: https://status.assess.creatium.com; incidents
    are also posted in the Slack group and emailed to your technical contact. Include the `X-Request-Id`
    of the call in question.
  contact:
    name: Creatium Support
    email: support@creatium.com
  license:
    name: Proprietary
    identifier: LicenseRef-Creatium-Proprietary
servers:
  - url: https://app.assess.creatium.com/api/v1
    description: Production
  - url: https://staging.app.assess.creatium.com/api/v1
    description: Sandbox / UAT
security:
  - oauth2: []
  - apiKey: []
tags:
  - name: Authentication
    description: OAuth 2.0 client credentials.
  - name: Catalog
    description: Assessments available to the organization's learners.
  - name: Results
    description: Learner attempts, progress and outcomes (pull).
  - name: Enrollment
    description: Optional. Assign learners before launch. Launching from the catalog link assigns automatically.
  - name: Assignments
    description: |
      Optional. A named set of assessments, with an optional due date and rules, given to people (by
      email) and teams. Everyone it reaches is enrolled in each of its assessments (`source: ASSIGNMENT`
      in `/enrollments`), including people who join a team later. Removing someone, or archiving the
      assignment, unenrolls them from what nothing else gives them; attempts in progress can finish and
      results are kept. Progress is per person across the whole set.
  - name: People
    description: Optional. Each person's team (one per person), which team assignments follow.
  - name: Webhooks
    description: Optional push delivery of result, enrollment, assignment, catalog and erasure events.
  - name: Operations
    description: Health checks and the staging sandbox.
  - name: Events
    description: The last 30 days of events to replay after an outage, and webhook endpoints managed by API.
  - name: Analytics
    description: For dashboards, BI tools and data warehouses. Computed tiles per assessment, and every answer as paged, incremental records (JSON or CSV) with keys that join to results, assessments and questions.

paths:
  /oauth/token:
    post:
      tags: [Authentication]
      operationId: createToken
      summary: Get an access token
      description: |
        OAuth 2.0 client credentials grant (RFC 6749 §4.4). Send the client ID and secret with HTTP
        Basic authentication (preferred) or in the form body.

        **For a manager:** token exchange (RFC 8693) with `subject_token` = the manager's `personId` or
        email. The token holds only `catalog:read`, `results:read` and `analytics:read` (of the client's
        scopes), and sees only the manager's reports. Under the policy's `managerAccess`: `none` refuses
        the exchange (`unauthorized_client`); `aggregates` allows analytics only (a manager whose reports
        are fewer than `minimumGroupSize` sees them suppressed); `individual` also allows their reports'
        results, progress, answers and profiles. It never reads `/events` or manages the client. An
        unknown person is `invalid_grant`. Tokens are opaque bearer tokens, valid
        for `expires_in` seconds (3600). Request a new token before expiry; refresh tokens are not issued.
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [grant_type]
              properties:
                grant_type:
                  type: string
                  enum: [client_credentials, 'urn:ietf:params:oauth:grant-type:token-exchange']
                  description: '`client_credentials` for an organization-wide token; token exchange (RFC 8693) for a token acting for one manager.'
                subject_token:
                  type: string
                  description: Token exchange only. The manager's `personId` or email.
                subject_token_type:
                  type: string
                  const: 'urn:creatium:params:oauth:token-type:person'
                  description: Token exchange only.
                scope:
                  type: string
                  description: Space-separated scopes. Omit for every scope granted to the client.
                  examples: ['catalog:read results:read']
                client_id:
                  type: string
                  description: Only when not using HTTP Basic.
                client_secret:
                  type: string
                  description: Only when not using HTTP Basic.
      responses:
        '200':
          description: Token issued.
          headers:
            Cache-Control:
              schema: { type: string, const: no-store }
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Token' }
              example:
                access_token: at_4f9c2a0e1b7d4c6a9e3f8b2d1c0a7e6f
                token_type: Bearer
                expires_in: 3600
                scope: catalog:read results:read
        '400':
          description: Malformed request or unsupported grant type (`invalid_request`, `unsupported_grant_type`, `invalid_scope`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OAuthError' }
        '401':
          description: Unknown client or wrong secret (`invalid_client`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OAuthError' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }

  /oauth/revoke:
    post:
      tags: [Authentication]
      operationId: revokeToken
      summary: Revoke an access token
      description: |
        Token revocation (RFC 7009). Authenticate as at `/oauth/token` (HTTP Basic) and send one of the
        client's own access tokens as `token`. Answers `200` whether or not the token was live.
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string }
                token_type_hint: { type: string, const: access_token }
      responses:
        '200':
          description: Revoked (or was not live).
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
        '400':
          description: '`token` missing, or not a form body (`invalid_request`).'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OAuthError' }
        '401':
          description: Unknown client or wrong secret (`invalid_client`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OAuthError' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }

  /client-secrets:
    get:
      tags: [Authentication]
      operationId: listClientSecrets
      summary: List this client's secrets
      description: The calling OAuth client's live secrets (last four characters only). Any scope. API keys have none.
      security:
        - oauth2: []
      responses:
        '200':
          description: The live secrets.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/ClientSecret' } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }
    post:
      tags: [Authentication]
      operationId: addClientSecret
      summary: Add a secret (rotation)
      description: |
        A second live secret, shown once in this response. Switch your system to it, then delete the
        old one. A client holds at most two (`409 secret_limit`).
      security:
        - oauth2: []
      responses:
        '201':
          description: The new secret.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ClientSecret' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }

  /client-secrets/{secretId}:
    delete:
      tags: [Authentication]
      operationId: revokeClientSecret
      summary: Revoke a secret
      description: Revokes one of the calling client's secrets. Never its last (`409 secret_limit`). Tokens already issued stay valid until they expire; revoke them with `/oauth/revoke`.
      security:
        - oauth2: []
      parameters:
        - name: secretId
          in: path
          required: true
          description: From `GET /client-secrets`.
          schema: { type: string, format: uuid }
      responses:
        '204':
          description: Revoked.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }

  /events:
    get:
      tags: [Events]
      operationId: listEvents
      summary: List recent events
      description: |
        The last 30 days of events, oldest first, exactly as webhook endpoints receive them, so a client
        that missed deliveries can catch up. Only the types the client's scopes may read:
        `result.updated` and `assignment.completed` need `results:read`; enrollment, assignment and
        `person.erased` events need `enrollment:read`; `assessment.updated` needs `catalog:read`.
        Only organizations with an API client, key or endpoint keep events.
      security:
        - oauth2: ['results:read']
        - oauth2: ['enrollment:read']
        - oauth2: ['enrollment:write']
        - oauth2: ['catalog:read']
        - apiKey: []
      parameters:
        - name: type
          in: query
          description: Comma-separated event types.
          schema: { type: string, examples: ['enrollment.created,enrollment.removed'] }
        - name: createdSince
          in: query
          description: Only events at or after this time (ISO 8601 UTC).
          schema: { type: string, format: date-time }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: One page of events.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Event' } }
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }

  /webhook-endpoints:
    get:
      tags: [Events]
      operationId: listWebhookEndpoints
      summary: List webhook endpoints
      description: The organization's endpoints, oldest first (never their secrets).
      security:
        - oauth2: ['webhooks:manage']
        - apiKey: []
      responses:
        '200':
          description: The endpoints.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/WebhookEndpoint' } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }
    post:
      tags: [Events]
      operationId: createWebhookEndpoint
      summary: Add a webhook endpoint
      description: A public https URL and the events it takes. The signing `secret` is in this response only.
      security:
        - oauth2: ['webhooks:manage']
        - apiKey: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookEndpointRequest' }
      responses:
        '201':
          description: Added, with its secret.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookEndpoint' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }

  /webhook-endpoints/{endpointId}:
    parameters:
      - $ref: '#/components/parameters/EndpointId'
    get:
      tags: [Events]
      operationId: getWebhookEndpoint
      summary: Get a webhook endpoint
      description: One endpoint (never its secret).
      security:
        - oauth2: ['webhooks:manage']
        - apiKey: []
      responses:
        '200':
          description: The endpoint.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookEndpoint' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }
    patch:
      tags: [Events]
      operationId: updateWebhookEndpoint
      summary: Change a webhook endpoint
      description: Send `url`, `events` or both; `events` replaces the list.
      security:
        - oauth2: ['webhooks:manage']
        - apiKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookEndpointRequest' }
      responses:
        '200':
          description: The changed endpoint.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookEndpoint' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }
    delete:
      tags: [Events]
      operationId: deleteWebhookEndpoint
      summary: Remove a webhook endpoint
      description: Idempotent. Undelivered events for it are dropped; they stay at `GET /events`.
      security:
        - oauth2: ['webhooks:manage']
        - apiKey: []
      responses:
        '204':
          description: Removed (or already gone).
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }

  /webhook-endpoints/{endpointId}:rotateSecret:
    post:
      tags: [Events]
      operationId: rotateWebhookSecret
      summary: Rotate an endpoint's secret
      description: |
        A new signing secret, in this response only. For 24 hours deliveries carry signatures with both
        the new and the old secret (two `v1` values), so you can switch without missing any.
      security:
        - oauth2: ['webhooks:manage']
        - apiKey: []
      parameters:
        - $ref: '#/components/parameters/EndpointId'
      responses:
        '200':
          description: The endpoint, with its new `secret`.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookEndpoint' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }

  /webhook-endpoints/{endpointId}:test:
    post:
      tags: [Events]
      operationId: testWebhookEndpoint
      summary: Send a test event
      description: Sends a signed `webhook.test` event (`data.endpointId`) to this endpoint now and says how it answered. Not kept at `/events`.
      security:
        - oauth2: ['webhooks:manage']
        - apiKey: []
      parameters:
        - $ref: '#/components/parameters/EndpointId'
      responses:
        '200':
          description: How the endpoint answered.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
          content:
            application/json:
              schema:
                type: object
                required: [eventId, delivered, status, error]
                properties:
                  eventId: { type: string, format: uuid }
                  delivered: { type: boolean, description: True when the endpoint answered 2xx. }
                  status: { type: [integer, 'null'], description: Its HTTP status. }
                  error: { type: [string, 'null'] }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }

  /health:
    get:
      tags: [Operations]
      operationId: getHealth
      summary: Is the API up?
      description: No token needed. `200` when the API can reach its database; `503` otherwise. The status page probes it every five minutes.
      security: []
      responses:
        '200':
          description: Up.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status: { type: string, const: ok }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }

  /sandbox/attempts:
    post:
      tags: [Operations]
      operationId: createSandboxAttempt
      summary: Simulate an attempt (sandbox only)
      description: |
        Only in a sandbox organization on staging; everywhere else `404`. Enrolls the learner if needed,
        then records an attempt with the outcome asked for, scored against the assessment's own skill
        targets and pass mark (or the `levels` you send). As for a real learner, the record appears in
        `/results` (taking the `NOT_STARTED` record's `resultId`), `result.updated` fires, and
        `assignment.completed` fires when it finishes an assignment. Returns the Result.
      security:
        - oauth2: ['enrollment:write']
        - apiKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [learnerEmail, assessmentId]
              properties:
                learnerEmail: { type: string, format: email }
                assessmentId: { type: string, format: uuid }
                outcome: { type: string, enum: [passed, failed, in_progress], default: passed }
                levels:
                  type: object
                  description: A level (1–10) per skill name, instead of what `outcome` implies.
                  additionalProperties: { type: integer, minimum: 1, maximum: 10 }
      responses:
        '201':
          description: The simulated attempt's Result.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Result' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }

  /assessments:
    get:
      tags: [Catalog]
      operationId: listAssessments
      summary: List assessments
      description: |
        The organization's catalog: assessments that are, or have been, available to learners.
        Drafts are never listed. For delta sync, send `updatedSince` with the `lastUpdatedDate` of the
        newest record from the previous run. Retired assessments are returned with `status: Retired`
        (not deleted), so the importer can hide them. Results are ordered by `lastUpdatedDate`, then
        `assessmentId`, ascending.
      security:
        - oauth2: ['catalog:read']
        - apiKey: []
      parameters:
        - name: updatedSince
          in: query
          description: Only assessments whose `lastUpdatedDate` is at or after this instant.
          schema: { type: string, format: date-time }
          example: '2026-09-01T00:00:00Z'
        - name: updatedBefore
          in: query
          description: Only assessments whose `lastUpdatedDate` is before this instant.
          schema: { type: string, format: date-time }
        - name: status
          in: query
          description: Default `all` (Active and Retired), which delta sync needs to see retirements.
          schema: { type: string, enum: [Active, Retired, all], default: all }
        - name: type
          in: query
          schema: { $ref: '#/components/schemas/AssessmentType' }
        - name: language
          in: query
          description: BCP 47 tag. Only assessments available in this language.
          schema: { type: string }
          example: en
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: One page of assessments.
          headers:
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Assessment' }
                  pagination: { $ref: '#/components/schemas/Pagination' }
              examples:
                catalog: { $ref: '#/components/examples/CatalogPage' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }

  /assessments/{assessmentId}:
    get:
      tags: [Catalog]
      operationId: getAssessment
      summary: Get an assessment
      description: One catalog record, live or retired; drafts are `404 assessment_not_found`. Same shape as in the list.
      security:
        - oauth2: ['catalog:read']
        - apiKey: []
      parameters:
        - $ref: '#/components/parameters/AssessmentId'
      responses:
        '200':
          description: The assessment.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/Assessment' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/AssessmentNotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }

  /assessments/{assessmentId}/questions:
    get:
      tags: [Catalog]
      operationId: listQuestions
      summary: List an assessment's questions (metadata)
      description: The live and retired questions of an assessment, ordered by `questionId`, for joining `/analytics/answers`. Never their text or answers.
      security:
        - oauth2: ['catalog:read']
        - apiKey: []
      parameters:
        - $ref: '#/components/parameters/AssessmentId'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: One page of questions.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Question' } }
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/AssessmentNotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }

  /results:
    get:
      tags: [Results]
      operationId: listResults
      summary: List learner results
      description: |
        One record per learner per attempt, plus one `NOT_STARTED` record for each enrolled learner
        who has not yet started (`attemptNumber` 1, no dates). The date window is mandatory: send
        `updatedSince` (and optionally `updatedBefore`). For incremental sync, send the `lastUpdated`
        of the newest record from the previous run. Records are ordered by `lastUpdated`, then
        `resultId`, ascending, so a run can resume from its cursor.

        A record changes (and its `lastUpdated` moves forward) when the attempt starts, progresses,
        is submitted, is re-scored (for example after a human review of an open answer, or a
        role play scored after submission), or when withheld results are released.

        Latency: records are available within 60 seconds of the activity. Backfill: any past window,
        up to the organization's retention period, by setting `updatedSince` and `updatedBefore`.
      security:
        - oauth2: ['results:read']
        - apiKey: []
      parameters:
        - name: updatedSince
          in: query
          required: true
          description: Start of the window (inclusive).
          schema: { type: string, format: date-time }
          example: '2026-09-20T00:00:00Z'
        - name: updatedBefore
          in: query
          description: End of the window (exclusive). Default now.
          schema: { type: string, format: date-time }
        - name: learnerEmail
          in: query
          description: Only this learner. An email with no records returns an empty page, not an error.
          schema: { type: string, format: email }
        - name: assessmentId
          in: query
          description: Only this assessment. An unknown ID returns `404 assessment_not_found`.
          schema: { type: string, format: uuid }
        - name: status
          in: query
          description: Comma-separated statuses. Default all.
          schema: { type: string }
          example: PASSED,FAILED
        - name: latestOnly
          in: query
          description: Only each learner's latest attempt per assessment.
          schema: { type: boolean, default: false }
        - $ref: '#/components/parameters/TeamFilter'
        - $ref: '#/components/parameters/ManagerFilter'
        - $ref: '#/components/parameters/IncludeIndirect'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: One page of results.
          headers:
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Result' }
                  pagination: { $ref: '#/components/schemas/Pagination' }
              examples:
                results: { $ref: '#/components/examples/ResultsPage' }
        '400': { $ref: '#/components/responses/BadRequestDateWindow' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/AssessmentNotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }

  /results/{resultId}:
    get:
      tags: [Results]
      operationId: getResult
      summary: Get one result
      description: One record from `/results`, by `resultId`. Placeholder IDs from before 1.3.0 (`enr_…`) still resolve.
      security:
        - oauth2: ['results:read']
        - apiKey: []
      parameters:
        - name: resultId
          in: path
          required: true
          description: A `resultId` from `/results`.
          schema: { type: string, maxLength: 64 }
      responses:
        '200':
          description: The result.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/Result' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }

  /enrollments:
    post:
      tags: [Enrollment]
      operationId: enroll
      summary: Enroll a learner
      description: |
        Optional. Assigns assessments to a learner ahead of launch, so they appear on the learner's
        Creatium home and in results as `NOT_STARTED`. No account needs to exist: the learner's
        account is created on first SSO sign-in. Idempotent: enrolling an already-enrolled learner
        succeeds without change. Send an `Idempotency-Key` to make retries safe.
        Returns the object itself, not wrapped in `data`.
      security:
        - oauth2: ['enrollment:write']
        - apiKey: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/EnrollmentRequest' }
            example:
              email: john.doe@example.com
              firstName: John
              lastName: Doe
              assessmentIds: [5f1d6c1e-2a4b-4c8e-9f3a-7b6d2e1c0a91, 0b7e3d9a-6c21-4f58-8a1e-2d4c9b7f6e30]
              notify: false
      responses:
        '207':
          description: One outcome per assessment.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EnrollmentResponse' }
              example:
                email: john.doe@example.com
                results:
                  - assessmentId: 5f1d6c1e-2a4b-4c8e-9f3a-7b6d2e1c0a91
                    status: enrolled
                  - assessmentId: 0b7e3d9a-6c21-4f58-8a1e-2d4c9b7f6e30
                    status: failed
                    error:
                      code: assessment_not_active
                      detail: This assessment is retired and cannot take new enrollments.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }
    get:
      tags: [Enrollment]
      operationId: listEnrollments
      summary: List enrollments
      description: Everyone enrolled in a live or retired assessment, by email or assessment, oldest first. `source` says how (API, invite, launch link or assignment).
      security:
        - oauth2: ['enrollment:read']
        - oauth2: ['enrollment:write']
        - apiKey: []
      parameters:
        - name: learnerEmail
          in: query
          schema: { type: string, format: email }
        - name: assessmentId
          in: query
          schema: { type: string, format: uuid }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: One page of enrollments.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Enrollment' }
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }

  /enrollments:batch:
    post:
      tags: [Enrollment]
      operationId: enrollBatch
      summary: Enroll many people
      description: |
        Up to 1,000 people (10,000 person-assessment pairs) per call; each as `POST /enrollments`. A person
        outside the organization's email domains fails alone (`email_outside_domains`). Send an
        `Idempotency-Key` to make retries safe.
      security:
        - oauth2: ['enrollment:write']
        - apiKey: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/BatchEnrollmentRequest' }
      responses:
        '207':
          description: One outcome per person and assessment.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BatchEnrollmentResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }

  /enrollments/{assessmentId}/{learnerEmail}:
    delete:
      tags: [Enrollment]
      operationId: unenroll
      summary: Unenroll a learner
      description: |
        Removes the assignment. The learner can no longer start the assessment from their Creatium
        home. An attempt already in progress can be finished. Completed attempts and their results
        are kept and still returned by `/results`. Idempotent: removing an absent enrollment returns 204.
        A learner who has the assessment through an assignment gets `409 enrollment_from_assignment`:
        remove them from the assignment instead.
      security:
        - oauth2: ['enrollment:write']
        - apiKey: []
      parameters:
        - $ref: '#/components/parameters/AssessmentId'
        - $ref: '#/components/parameters/LearnerEmailPath'
      responses:
        '204':
          description: Unenrolled (or was not enrolled).
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/AssessmentNotFound' }
        '409': { $ref: '#/components/responses/EnrollmentFromAssignment' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }

  /assignments:
    post:
      tags: [Assignments]
      operationId: createAssignment
      summary: Create an assignment
      description: |
        Gives a set of assessments to people and teams in one call. Assessments must be live, and the
        organization's own or shared down to it by its parent. With `notify`, each person newly reached
        gets one email listing the assessments. Send an `Idempotency-Key` to make retries safe.
        Returns the object itself, not wrapped in `data`.
      security:
        - oauth2: ['enrollment:write']
        - apiKey: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AssignmentRequest' }
            example:
              name: New starter onboarding
              assessmentIds: [5f1d6c1e-2a4b-4c8e-9f3a-7b6d2e1c0a91, 0b7e3d9a-6c21-4f58-8a1e-2d4c9b7f6e30]
              dueAt: '2026-10-31T17:00:00Z'
              ordered: true
              teams: [Sales]
              emails: [john.doe@example.com]
              notify: true
      responses:
        '201':
          description: Created, with one outcome per member.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Assignment'
                  - type: object
                    properties:
                      results: { type: array, items: { $ref: '#/components/schemas/MemberOutcome' } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/AssessmentNotFound' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }
    get:
      tags: [Assignments]
      operationId: listAssignments
      summary: List assignments
      description: Live assignments, oldest change first (cursor paging on `updatedAt`).
      security:
        - oauth2: ['enrollment:read']
        - oauth2: ['enrollment:write']
        - apiKey: []
      parameters:
        - name: includeArchived
          in: query
          description: Also list archived assignments (with `archivedAt`), so a client syncing by `updatedAt` learns of an archive.
          schema: { type: boolean, default: false }
        - name: learnerEmail
          in: query
          description: Only assignments that reach this person, by email or through their team.
          schema: { type: string, format: email }
        - name: team
          in: query
          description: Only assignments given to this team.
          schema: { type: string }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: One page of assignments.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Assignment' } }
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }

  /assignments/{assignmentId}:
    parameters:
      - $ref: '#/components/parameters/AssignmentId'
    get:
      tags: [Assignments]
      operationId: getAssignment
      summary: Get an assignment
      description: One live assignment, with its assessments in order and its members. Returns the object itself, not wrapped in `data`, with an `ETag` to send back as `If-Match` when changing it.
      security:
        - oauth2: ['enrollment:read']
        - oauth2: ['enrollment:write']
        - apiKey: []
      responses:
        '200':
          description: The assignment.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
            ETag: { $ref: '#/components/headers/ETag' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Assignment' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }
    patch:
      tags: [Assignments]
      operationId: updateAssignment
      summary: Change an assignment
      description: Send only the fields to change. `assessmentIds` replaces the whole list (and its order); `null` clears `dueAt` or `maxAttempts`. Send the `ETag` from your last read as `If-Match` to refuse the change (`412`) if someone else changed it first. Returns the object itself, not wrapped in `data`, with its new `ETag`.
      security:
        - oauth2: ['enrollment:write']
        - apiKey: []
      parameters:
        - $ref: '#/components/parameters/IfMatch'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AssignmentUpdate' }
      responses:
        '200':
          description: The changed assignment.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
            ETag: { $ref: '#/components/headers/ETag' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Assignment' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '412': { $ref: '#/components/responses/PreconditionFailed' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }
    delete:
      tags: [Assignments]
      operationId: archiveAssignment
      summary: Archive an assignment
      description: Nobody new can start from it. Attempts in progress can finish; results are kept. Idempotent.
      security:
        - oauth2: ['enrollment:write']
        - apiKey: []
      responses:
        '204':
          description: Archived (or already gone).
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }

  /assignments/{assignmentId}/members:
    parameters:
      - $ref: '#/components/parameters/AssignmentId'
    post:
      tags: [Assignments]
      operationId: addAssignmentMembers
      summary: Give an assignment to more people and teams
      description: Up to 1,000 emails and 50 teams per call. With `notify`, each person newly reached gets one email. Returns the object itself, not wrapped in `data`.
      security:
        - oauth2: ['enrollment:write']
        - apiKey: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MembersRequest' }
      responses:
        '207':
          description: One outcome per member.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
          content:
            application/json:
              schema:
                type: object
                required: [assignmentId, results]
                properties:
                  assignmentId: { type: string, format: uuid }
                  results: { type: array, items: { $ref: '#/components/schemas/MemberOutcome' } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }

  /assignments/{assignmentId}/members/{learnerEmail}:
    delete:
      tags: [Assignments]
      operationId: removeAssignmentMember
      summary: Take a person out of an assignment
      description: They keep anything a team or another assignment still gives them. Idempotent.
      security:
        - oauth2: ['enrollment:write']
        - apiKey: []
      parameters:
        - $ref: '#/components/parameters/AssignmentId'
        - $ref: '#/components/parameters/LearnerEmailPath'
      responses:
        '204':
          description: Removed (or was not a member).
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }

  /assignments/{assignmentId}/teams/{team}:
    delete:
      tags: [Assignments]
      operationId: removeAssignmentTeam
      summary: Take a team out of an assignment
      description: Team names match case-insensitively. Idempotent.
      security:
        - oauth2: ['enrollment:write']
        - apiKey: []
      parameters:
        - $ref: '#/components/parameters/AssignmentId'
        - name: team
          in: path
          required: true
          description: The team's name, URL-encoded; matched case-insensitively.
          schema: { type: string, maxLength: 100 }
      responses:
        '204':
          description: Removed (or was not given to that team).
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }

  /assignments/{assignmentId}/progress:
    get:
      tags: [Assignments]
      operationId: getAssignmentProgress
      summary: Progress per person
      description: |
        One record per person the assignment reaches, ordered by email, from their latest attempt at
        each assessment. Worked out on each request, never cached.
      security:
        - oauth2: ['results:read']
        - apiKey: []
      parameters:
        - $ref: '#/components/parameters/AssignmentId'
        - name: learnerEmail
          in: query
          schema: { type: string, format: email }
        - name: status
          in: query
          description: Comma-separated `ProgressStatus` values.
          schema: { type: string, examples: ['NOT_STARTED,IN_PROGRESS'] }
        - name: overdue
          in: query
          schema: { type: boolean }
        - $ref: '#/components/parameters/TeamFilter'
        - $ref: '#/components/parameters/ManagerFilter'
        - $ref: '#/components/parameters/IncludeIndirect'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: One page of progress records.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/AssignmentProgress' } }
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }

  /people:
    get:
      tags: [People]
      operationId: listPeople
      summary: List people and their teams
      description: Everyone in the organization (candidates and staff) with their team, oldest first.
      security:
        - oauth2: ['enrollment:read']
        - oauth2: ['enrollment:write']
        - apiKey: []
      parameters:
        - name: team
          in: query
          schema: { type: string }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: One page of people.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Person' } }
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }

  /people/{learnerEmail}:
    put:
      tags: [People]
      operationId: setTeam
      summary: Set a person's team or employee number
      description: |
        Sets any of `team` (one per person; `null` clears it), `externalId`, `managerEmail`, `department`
        and `location`; fields left out stay as they are. Someone not in the organization yet is added as a
        candidate (inside its email domains only).
        Assignments given to the team follow at once. Returns the object itself, not wrapped in `data`.
      security:
        - oauth2: ['enrollment:write']
        - apiKey: []
      parameters:
        - $ref: '#/components/parameters/LearnerEmailPath'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PersonUpdate' }
      responses:
        '200':
          description: The person.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Person' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }

    delete:
      tags: [People]
      operationId: deactivatePerson
      summary: Deactivate a leaver
      description: |
        Removes the person's candidate role, assignment memberships and enrollments (each `NOT_STARTED`
        record comes back once as `REMOVED`). Finished results stay. Idempotent. Someone who signs in
        or is enrolled again becomes active again. Staff are refused (`422`): manage them in the admin portal.
      security:
        - oauth2: ['enrollment:write']
        - apiKey: []
      parameters:
        - $ref: '#/components/parameters/LearnerEmailPath'
      responses:
        '204':
          description: Deactivated (or already).
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }

  /people/{personId}:changeEmail:
    post:
      tags: [People]
      operationId: changeEmail
      summary: Change a person's email address
      description: |
        Moves the person's role, team, enrollments, attempts and assignment memberships to the new
        address. The `personId` and every `resultId` stay the same; their records' `lastUpdated` moves.
        Returns the Person itself.
      security:
        - oauth2: ['enrollment:write']
        - apiKey: []
      parameters:
        - $ref: '#/components/parameters/PersonId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email, maxLength: 320 }
      responses:
        '200':
          description: The person, under the new address.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Person' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }

  /people/{personId}:erase:
    post:
      tags: [People]
      operationId: erasePerson
      summary: Erase a person (GDPR, CCPA)
      description: |
        Cannot be undone. Deactivates the person, then removes what identifies them in this organization:
        their answers' content, proctoring readings, certificates, settings, and the person record.
        Their attempts stay only as anonymous rows (`learnerEmail` becomes
        `erased-<personId>@erased.invalid`, `personId` null, scores kept) so organization-wide numbers
        don't change; their `lastUpdated` moves, so your next sync overwrites your copy. Completes within
        the request and then sends `person.erased`. Needs the `people:erase` scope, which API keys never have.
      security:
        - oauth2: ['people:erase']
      parameters:
        - $ref: '#/components/parameters/PersonId'
      responses:
        '200':
          description: Erased.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Erasure' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }

  /people:batch:
    post:
      tags: [People]
      operationId: upsertPeople
      summary: Update many people (HRIS sync)
      description: |
        Up to 1,000 people, each as `PUT /people/{learnerEmail}`: team and attributes, adding anyone new
        (inside the organization's email domains). Reporting lines are set after everyone in the call
        exists, so a manager and their reports can arrive together. A row that fails fails alone.
      security:
        - oauth2: ['enrollment:write']
        - apiKey: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PeopleBatchRequest' }
      responses:
        '207':
          description: One outcome per person.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PeopleBatchResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }

  /people/{learnerEmail}/profile:
    get:
      tags: [People]
      operationId: getPersonProfile
      summary: A person's performance across assessments
      description: |
        Their latest released level per skill (with when, where it was measured, and the change from the
        level before), their assignments with status and overdue flags, and their certificates. A
        manager's token reaches only their reports, and only when the policy's `managerAccess` is `individual`.
      security:
        - oauth2: ['results:read']
        - apiKey: []
      parameters:
        - $ref: '#/components/parameters/LearnerEmailPath'
      responses:
        '200':
          description: The profile.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/PersonProfile' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }

  /organization/policy:
    get:
      tags: [People]
      operationId: getPolicy
      summary: The organization's data policy
      description: What clients and managers' tokens may see. Set by an organization admin in Admin → API. Any scope.
      security:
        - oauth2: []
        - apiKey: []
      responses:
        '200':
          description: The policy.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/Policy' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }

  /analytics/assessments:
    get:
      tags: [Analytics]
      operationId: listAssessmentAnalytics
      summary: Key numbers for every assessment
      description: One summary per live or retired assessment, ordered by `assessmentId`. Rates are 0–1. Without `limit`, every assessment in one response; with it, pages of that size.
      security:
        - oauth2: ['analytics:read']
        - apiKey: []
      parameters:
        - name: from
          in: query
          description: Only attempts finished at or after this time (ISO 8601 UTC); attempts in progress count if started before `to`.
          schema: { type: string, format: date-time }
        - name: to
          in: query
          description: Only attempts finished before this time.
          schema: { type: string, format: date-time }
        - $ref: '#/components/parameters/TeamFilter'
        - $ref: '#/components/parameters/ManagerFilter'
        - $ref: '#/components/parameters/IncludeIndirect'
        - $ref: '#/components/parameters/Cursor'
        - name: limit
          in: query
          description: Page size. Omit to get every assessment at once.
          schema: { type: integer, minimum: 1, maximum: 200 }
      responses:
        '200':
          description: Summaries.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/AnalyticsSummary' }
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }

  /analytics/assessments/{assessmentId}:
    get:
      tags: [Analytics]
      operationId: getAssessmentAnalytics
      summary: Analytics for one assessment
      description: |
        The numbers behind the Designer's Analytics page: completion, the share of people meeting each
        skill's target and the spread of bands, the same by team, how often each question is answered
        correctly, and completions per day. One row per enrolled person, on their latest finished attempt.
        A team or group with fewer finished attempts than the policy's `minimumGroupSize` is listed with
        `suppressed: true` and no rates.
      security:
        - oauth2: ['analytics:read']
        - apiKey: []
      parameters:
        - $ref: '#/components/parameters/AssessmentId'
        - name: language
          in: query
          description: Only people who took it in this language (BCP 47). Ignored when the assessment is not offered in it.
          schema: { type: string }
        - name: assignmentId
          in: query
          description: Only the people this assignment reaches (it must include the assessment).
          schema: { type: string, format: uuid }
        - name: from
          in: query
          description: Only attempts finished at or after this time (ISO 8601 UTC); attempts in progress count if started before `to`.
          schema: { type: string, format: date-time }
        - name: to
          in: query
          description: Only attempts finished before this time.
          schema: { type: string, format: date-time }
        - $ref: '#/components/parameters/TeamFilter'
        - $ref: '#/components/parameters/ManagerFilter'
        - $ref: '#/components/parameters/IncludeIndirect'
        - name: groupBy
          in: query
          description: Adds `groups`, the share meeting each skill's target per team, department, location or manager.
          schema: { type: string, enum: [team, department, location, manager] }
      responses:
        '200':
          description: The assessment's analytics.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/AssessmentAnalytics' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/AssessmentNotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }

  /analytics/answers:
    get:
      tags: [Analytics]
      operationId: listAnswers
      summary: Every answer (for BI tools and warehouses)
      description: |
        One record per answer across the organization's assessments, with the keys to join them to
        results (`resultId`), assessments (`assessmentId`), questions (`questionId`) and people. Records
        are ordered by their attempt's `lastUpdated`, then attempt, then question, so an incremental sync
        can resume from `nextCursor` or restart from the newest `lastUpdated` it stored. A record comes
        back when its attempt changes (progress, submit, later scoring, released results).
        `format=csv` returns the same records as CSV; the next page's cursor is then in `X-Next-Cursor`.
      security:
        - oauth2: ['analytics:read']
        - apiKey: []
      parameters:
        - name: updatedSince
          in: query
          description: Only answers in attempts updated at or after this instant.
          schema: { type: string, format: date-time }
        - name: updatedBefore
          in: query
          schema: { type: string, format: date-time }
        - name: assessmentId
          in: query
          schema: { type: string, format: uuid }
        - name: people
          in: query
          description: '`email` (default) or `anonymous`: a stable `personId` (C-…) instead of `learnerEmail`.'
          schema: { type: string, enum: [email, anonymous], default: email }
        - name: format
          in: query
          schema: { type: string, enum: [json, csv], default: json }
        - $ref: '#/components/parameters/TeamFilter'
        - $ref: '#/components/parameters/ManagerFilter'
        - $ref: '#/components/parameters/IncludeIndirect'
        - $ref: '#/components/parameters/Cursor'
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 1000, default: 500 }
      responses:
        '200':
          description: One page of answers.
          headers:
            X-Next-Cursor:
              description: With format=csv, the cursor of the next page; absent on the last page.
              schema: { type: string }
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/AnswerRecord' }
                  pagination: { $ref: '#/components/schemas/Pagination' }
            text/csv:
              schema: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '5XX': { $ref: '#/components/responses/ServerError' }

webhooks:
  result.updated:
    post:
      tags: [Webhooks]
      operationId: resultUpdated
      summary: A result was created or changed
      description: |
        Optional push delivery, alongside pull. Sent when an attempt starts, is submitted or is
        re-scored, and when withheld results are released. Configure the endpoint on the
        organization's API page in the Creatium admin portal.

        - **Signature:** `Creatium-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>`.
          Reject deliveries older than 5 minutes. For 24 hours after a secret is rotated the header
          carries a second `v1` signed with the old secret: accept the delivery if any `v1` matches.
        - **Idempotency:** `Creatium-Event-Id` (also `id` in the body) is unique per event. Deduplicate on it.
        - **Ordering:** not guaranteed. Apply a record only if its `lastUpdated` is newer than the one held.
        - **Retries:** any non-2xx response or a timeout after 10 seconds is retried with exponential
          backoff (1 min, 5 min, 30 min, 2 h, 6 h, 12 h), for up to 24 hours. Every delivery attempt,
          including retries, is re-signed with a fresh timestamp; the body and `Creatium-Event-Id` stay the same.
        - **Size:** one result per event; bodies stay under 64 KB.
        - **Missed events:** the same events are at `GET /events` for 30 days.
      parameters:
        - name: Creatium-Event-Id
          in: header
          required: true
          schema: { type: string, format: uuid }
        - name: Creatium-Signature
          in: header
          required: true
          schema: { type: string }
        - name: Creatium-Event
          in: header
          required: true
          schema: { type: string, const: result.updated }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ResultEvent' }
      responses:
        '2XX': { description: Accepted. Any 2xx stops retries. }

  assignment.completed:
    post:
      tags: [Webhooks]
      operationId: assignmentCompleted
      summary: A person finished every assessment in an assignment
      description: |
        Sent once per person and assignment, when their last assessment in it is submitted or times
        out. Signed, deduplicated and retried exactly like `result.updated`.
      parameters:
        - name: Creatium-Event-Id
          in: header
          required: true
          schema: { type: string, format: uuid }
        - name: Creatium-Signature
          in: header
          required: true
          schema: { type: string }
        - name: Creatium-Event
          in: header
          required: true
          schema: { type: string, const: assignment.completed }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AssignmentEvent' }
      responses:
        '2XX': { description: Accepted. Any 2xx stops retries. }

  enrollment.created:
    post:
      tags: [Webhooks]
      operationId: enrollmentCreated
      summary: Someone was enrolled
      description: Someone was enrolled in a live assessment, however it happened (API, invite, launch link or assignment). Signed, deduplicated and retried exactly like `result.updated`.
      parameters:
        - name: Creatium-Event-Id
          in: header
          required: true
          schema: { type: string, format: uuid }
        - name: Creatium-Signature
          in: header
          required: true
          schema: { type: string }
        - name: Creatium-Event
          in: header
          required: true
          schema: { type: string, const: enrollment.created }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - type: object
                  properties:
                    data: { $ref: '#/components/schemas/EnrollmentEventData' }
      responses:
        '2XX': { description: Accepted. Any 2xx stops retries. }

  enrollment.removed:
    post:
      tags: [Webhooks]
      operationId: enrollmentRemoved
      summary: An enrollment was taken away
      description: Unenrolled, removed from an assignment, or deactivated. With `resultId` when a `REMOVED` record now replaces their `NOT_STARTED` one. Signed, deduplicated and retried exactly like `result.updated`.
      parameters:
        - name: Creatium-Event-Id
          in: header
          required: true
          schema: { type: string, format: uuid }
        - name: Creatium-Signature
          in: header
          required: true
          schema: { type: string }
        - name: Creatium-Event
          in: header
          required: true
          schema: { type: string, const: enrollment.removed }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - type: object
                  properties:
                    data: { $ref: '#/components/schemas/EnrollmentEventData' }
      responses:
        '2XX': { description: Accepted. Any 2xx stops retries. }

  assignment.updated:
    post:
      tags: [Webhooks]
      operationId: assignmentUpdated
      summary: An assignment changed
      description: Its settings, assessments or members changed. Signed, deduplicated and retried exactly like `result.updated`.
      parameters:
        - name: Creatium-Event-Id
          in: header
          required: true
          schema: { type: string, format: uuid }
        - name: Creatium-Signature
          in: header
          required: true
          schema: { type: string }
        - name: Creatium-Event
          in: header
          required: true
          schema: { type: string, const: assignment.updated }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - type: object
                  properties:
                    data: { $ref: '#/components/schemas/AssignmentEventData' }
      responses:
        '2XX': { description: Accepted. Any 2xx stops retries. }

  assignment.archived:
    post:
      tags: [Webhooks]
      operationId: assignmentArchived
      summary: An assignment was archived
      description: Nobody new can start from it; attempts in progress can finish. Signed, deduplicated and retried exactly like `result.updated`.
      parameters:
        - name: Creatium-Event-Id
          in: header
          required: true
          schema: { type: string, format: uuid }
        - name: Creatium-Signature
          in: header
          required: true
          schema: { type: string }
        - name: Creatium-Event
          in: header
          required: true
          schema: { type: string, const: assignment.archived }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - type: object
                  properties:
                    data: { $ref: '#/components/schemas/AssignmentEventData' }
      responses:
        '2XX': { description: Accepted. Any 2xx stops retries. }

  assessment.updated:
    post:
      tags: [Webhooks]
      operationId: assessmentUpdated
      summary: A catalog assessment changed
      description: A live assessment was published or changed, or was retired. Signed, deduplicated and retried exactly like `result.updated`.
      parameters:
        - name: Creatium-Event-Id
          in: header
          required: true
          schema: { type: string, format: uuid }
        - name: Creatium-Signature
          in: header
          required: true
          schema: { type: string }
        - name: Creatium-Event
          in: header
          required: true
          schema: { type: string, const: assessment.updated }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - type: object
                  properties:
                    data: { $ref: '#/components/schemas/AssessmentEventData' }
      responses:
        '2XX': { description: Accepted. Any 2xx stops retries. }

  person.erased:
    post:
      tags: [Webhooks]
      operationId: personErased
      summary: A person was erased
      description: Sent once `POST /people/{personId}:erase` finishes. Delete everything you hold about that `personId`. Signed, deduplicated and retried exactly like `result.updated`.
      parameters:
        - name: Creatium-Event-Id
          in: header
          required: true
          schema: { type: string, format: uuid }
        - name: Creatium-Signature
          in: header
          required: true
          schema: { type: string }
        - name: Creatium-Event
          in: header
          required: true
          schema: { type: string, const: person.erased }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ErasureEvent' }
      responses:
        '2XX': { description: Accepted. Any 2xx stops retries. }

  assessment.completed:
    post:
      tags: [Webhooks]
      operationId: assessmentCompleted
      deprecated: true
      summary: An attempt was submitted (deprecated)
      description: |
        Deprecated: use `result.updated`, which is timestamped, retried and carries the full result.
        `assessment.completed` stops on 31 January 2027.

        Sent once when an attempt is submitted, delivered once without retries. Signed with
        `x-assess-signature: sha256=<hex HMAC-SHA256(secret, raw body)>` (no timestamp).
      parameters:
        - name: x-assess-signature
          in: header
          required: true
          schema: { type: string }
        - name: x-assess-event
          in: header
          required: true
          schema: { type: string, const: assessment.completed }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id, type, createdAt, data]
              properties:
                id: { type: string, format: uuid }
                type: { type: string, const: assessment.completed }
                createdAt: { type: string, format: date-time }
                data:
                  type: object
                  required: [attemptId, assessmentId, candidate, status, submittedAt]
                  properties:
                    attemptId: { type: string, format: uuid }
                    assessmentId: { type: string, format: uuid }
                    assessmentTitle: { type: string }
                    candidate: { type: object, required: [email], properties: { email: { type: string, format: email } } }
                    status: { type: string, enum: [submitted, timeout] }
                    score: { type: [number, 'null'] }
                    levels: { type: object, additionalProperties: { type: [integer, 'null'] } }
                    submittedAt: { type: [string, 'null'], format: date-time }
                    channel: { type: string, enum: [web, embed, scorm, api] }
      responses:
        '2XX': { description: Accepted. }

components:
  securitySchemes:
    oauth2:
      type: oauth2
      description: Client credentials are issued per environment on the organization's API page in the Creatium admin portal.
      flows:
        clientCredentials:
          tokenUrl: /oauth/token
          scopes:
            catalog:read: Read the assessment catalog.
            results:read: Read learner attempts and results.
            enrollment:read: Read enrollments, assignments and people with their teams.
            enrollment:write: Enroll and unenroll learners, and manage assignments and teams. Includes everything `enrollment:read` allows.
            analytics:read: Read analytics and export every answer.
            analytics:answer-keys: See `correctAnswer` in the answer export when the organization's policy withholds it from other clients.
            proctoring:read: See proctoring results when the organization's policy requires this scope.
            webhooks:manage: Add, change, test and remove webhook endpoints, and rotate their secrets.
            people:erase: Erase a person's data (GDPR, CCPA). Never implied by other scopes or by API keys.
    apiKey:
      type: http
      scheme: bearer
      description: |
        Legacy API keys (`ak_…`), created on the organization's API page and sent as
        `Authorization: Bearer ak_…`. A key carries every scope and stays valid until revoked. Prefer an
        OAuth client (`oauth2`) for new integrations.

  parameters:
    AssessmentId:
      name: assessmentId
      in: path
      required: true
      description: Stable assessment ID (UUID).
      schema: { type: string, format: uuid }
    Cursor:
      name: cursor
      in: query
      description: Opaque cursor from the previous page's `pagination.nextCursor`. Omit for the first page. Send the same filters as the request that issued it; other filters give `400`.
      schema: { type: string }
    Limit:
      name: limit
      in: query
      description: Page size.
      schema: { type: integer, minimum: 1, maximum: 200, default: 100 }
    AssignmentId:
      name: assignmentId
      in: path
      required: true
      description: Stable assignment ID (UUID).
      schema: { type: string, format: uuid }
    LearnerEmailPath:
      name: learnerEmail
      in: path
      required: true
      description: The person's `personId`, or their email address URL-encoded (`@` as `%40`, matched case-insensitively). Prefer the `personId`; an email in a path appears in access logs.
      schema: { type: string, format: email }
    TeamFilter:
      name: team
      in: query
      description: Only people in this team (case-insensitive).
      schema: { type: string }
    ManagerFilter:
      name: managerEmail
      in: query
      description: Only this manager's reports (direct; with `includeIndirect=true`, every level below).
      schema: { type: string, format: email }
    IncludeIndirect:
      name: includeIndirect
      in: query
      description: With `managerEmail`, include reports of reports.
      schema: { type: boolean, default: false }
    EndpointId:
      name: endpointId
      in: path
      required: true
      description: The endpoint's `endpointId`.
      schema: { type: string, format: uuid }
    PersonId:
      name: personId
      in: path
      required: true
      description: The person's `personId` (UUID).
      schema: { type: string, format: uuid }
    IfMatch:
      name: If-Match
      in: header
      description: The `ETag` from your last read. When the record has changed since, `412` and nothing is changed.
      schema: { type: string }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      description: Any unique string, at most 255 characters. The same key with the same body returns the first response for 24 hours; with a different body, `409`.
      schema: { type: string, maxLength: 255 }

  headers:
    X-Request-Id:
      description: Unique per request; quote it to support. Also `requestId` in problem bodies.
      schema: { type: string }
    RateLimit-Limit:
      description: Requests allowed in the current window.
      schema: { type: integer }
    RateLimit-Remaining:
      description: Requests left in the current window.
      schema: { type: integer }
    RateLimit-Reset:
      description: Seconds until the window resets.
      schema: { type: integer }
    ETag:
      description: Version of the record; send it as `If-Match` to change only that version.
      schema: { type: string }
    Retry-After:
      description: Seconds to wait before retrying.
      schema: { type: integer }

  schemas:
    Token:
      type: object
      required: [access_token, token_type, expires_in, scope]
      properties:
        access_token: { type: string }
        token_type: { type: string, const: Bearer }
        expires_in: { type: integer, description: Seconds (3600). }
        scope: { type: string }
        issued_token_type: { type: string, const: 'urn:ietf:params:oauth:token-type:access_token', description: Token exchange only. }

    OAuthError:
      type: object
      required: [error]
      properties:
        error:
          type: string
          enum: [invalid_request, invalid_client, invalid_grant, unauthorized_client, unsupported_grant_type, invalid_scope]
        error_description: { type: string }

    Pagination:
      type: object
      required: [limit, hasMore, nextCursor]
      description: Cursor pagination. Keep requesting with `nextCursor` until `hasMore` is false (`nextCursor` is then null).
      properties:
        limit: { type: integer }
        hasMore: { type: boolean, description: False on the final page. }
        nextCursor: { type: [string, 'null'] }
        totalCount: { type: integer, description: 'Records matching the filters across all pages. Omitted where counting would be costly (`/analytics/answers`).' }

    AssessmentType:
      type: string
      description: |
        `Skill`: finds each learner's level in each skill (a diagnostic).
        `Certification`: passing issues a verifiable certificate.
      enum: [Skill, Certification]

    Band:
      type: string
      description: 'Proficiency bands on the 1–10 level scale: Foundational 1–3, Intermediate 4–5, Advanced 6–8, Expert 9–10.'
      enum: [Foundational, Intermediate, Advanced, Expert]

    Assessment:
      type: object
      required: [assessmentId, title, description, descriptionFormat, launchUrl, launchMode, duration, durationUnit, durationIso, status, type, level, maxScore, passingScore, passingRule, languages, skills, lastUpdatedDate]
      properties:
        assessmentId: { type: string, format: uuid, description: Stable; identical in results; never reused. }
        title: { type: string, maxLength: 200 }
        description: { type: string, description: Plain text. }
        descriptionFormat: { type: string, const: text }
        objective: { type: [string, 'null'], description: The learning outcome the assessment measures. }
        launchUrl:
          type: string
          format: uri
          description: Deep link (`/launch/{assessmentId}`) that starts SP-initiated SAML SSO with the organization's identity provider, then opens the assessment's start page and assigns it. A learner already signed in goes straight there. It is stable; no token is embedded. An ID that is unknown, not yet published, or not a UUID shows a `404` page before any sign-in. Add `?context=<up to 256 characters>` (for example your enrollment ID; returned on the result as `launchContext`) and `&returnUrl=<https URL>` (a Back button on the results page once they finish; only to the organization's allowed LMS domains, otherwise ignored).
        launchMode: { type: string, const: SP_INITIATED_SSO }
        duration: { type: integer, description: 'Minutes. The time limit when timed, otherwise the typical time.' }
        durationUnit: { type: string, const: minutes }
        durationIso: { type: string, description: 'ISO 8601 duration, e.g. PT45M.' }
        timed: { type: boolean, description: True when the duration is a hard time limit. }
        status:
          type: string
          enum: [Active, Retired]
          description: '`Active`: learners can launch it. `Retired`: closed to new attempts; results remain available.'
        type: { $ref: '#/components/schemas/AssessmentType' }
        level:
          allOf: [{ $ref: '#/components/schemas/Band' }]
          description: Complexity. The highest target band set across the assessment's skills.
        adaptive:
          type: boolean
          const: true
          description: Questions adapt to the learner, and the assessment ends once every skill's level is established. The number of questions varies.
        questionCount:
          type: object
          required: [max]
          properties:
            max: { type: integer, description: Most knowledge-check questions an attempt can include. }
            rolePlays: { type: integer, description: Role plays in each attempt. }
            projects: { type: integer, description: Projects in each attempt. }
        maxScore: { type: number, const: 100, description: Scores are percentages. }
        passingScore:
          type: [number, 'null']
          minimum: 0
          maximum: 100
          description: The pass mark, when the assessment passes on score. Null when it passes on skill targets (see `passingRule`).
        passingRule:
          type: string
          enum: [SCORE, SKILL_TARGETS]
          description: '`SCORE`: passed when `score` ≥ `passingScore`. `SKILL_TARGETS`: passed when every skill reaches its `targetLevel`.'
        languages:
          type: array
          items: { type: string }
          description: BCP 47 tags (ISO 639-1 language, optional ISO 3166 region), e.g. en, ar-EG.
        skills:
          type: array
          items: { type: string }
          description: Names of the skills assessed.
        skillDetails:
          type: array
          items: { $ref: '#/components/schemas/SkillDetail' }
        sections:
          type: array
          items: { type: string }
          description: The assessment's sections. Each skill is a section.
        maxAttempts: { type: integer, description: 'Attempts a learner may take. No question, role play or project repeats across them.' }
        publishedDate: { type: [string, 'null'], format: date-time }
        activationDate: { type: [string, 'null'], format: date-time, description: When it became available to learners. }
        expiryDate: { type: [string, 'null'], format: date-time, description: When it was retired; null while active. }
        lastUpdatedDate: { type: string, format: date-time, description: Changes on any change to a catalog field. Use for delta sync. }
        industry: { type: [string, 'null'] }
        linkedCourses:
          type: array
          items: { type: string }
          description: Reserved. Always empty in v1.
        thumbnailUrl: { type: [string, 'null'], format: uri, description: '16:9 card image, public HTTPS.' }
        rating: { type: 'null', description: Not collected in v1. }
        benchmarks:
          oneOf:
            - $ref: '#/components/schemas/Benchmarks'
            - type: 'null'

    SkillDetail:
      type: object
      required: [skillId, name, targetLevel, targetBand]
      properties:
        skillId: { type: string }
        name: { type: string }
        targetLevel: { type: integer, minimum: 1, maximum: 10 }
        targetBand: { $ref: '#/components/schemas/Band' }
        objectives:
          type: array
          items: { type: string }

    Benchmarks:
      type: object
      description: Aggregates across the organization's completed attempts. Present only with 20 or more completions.
      required: [completions, medianScore, passRate]
      properties:
        completions: { type: integer }
        medianScore: { type: number }
        passRate: { type: number, minimum: 0, maximum: 1 }

    ResultStatus:
      type: string
      description: |
        `NOT_STARTED`: enrolled, no attempt yet.
        `IN_PROGRESS`: started, not submitted.
        `AWAITING_RESULTS`: submitted; the organization releases results later.
        `PASSED`: submitted; pass threshold met.
        `FAILED`: submitted; below threshold.
        `REMOVED`: the enrollment was taken away before any attempt (unenrolled, removed from the
        assignment, or the person deactivated). Returned for 30 days, under the `NOT_STARTED` record's
        `resultId`, so an incremental sync learns of it. Delete your copy.
      enum: [NOT_STARTED, IN_PROGRESS, AWAITING_RESULTS, PASSED, FAILED, REMOVED]

    Result:
      type: object
      required: [resultId, learnerEmail, assessmentId, attemptNumber, status, maxScore, passingRule, lastUpdated]
      properties:
        resultId: { type: string, description: 'Stable per attempt; re-scoring updates the same record. A `NOT_STARTED` record keeps its `resultId` when the learner starts: it becomes their first attempt.' }
        personId: { type: [string, 'null'], format: uuid, description: 'The person; survives an email change. Null once the person is erased (`learnerEmail` is then `erased-<personId>@erased.invalid`).' }
        learnerEmail: { type: string, format: email, description: Lower case. Matches the SAML NameID. }
        team: { type: [string, 'null'] }
        managerEmail: { type: [string, 'null'], format: email }
        learnerName: { type: [string, 'null'] }
        assessmentId: { type: string, format: uuid }
        attemptNumber: { type: integer, minimum: 1, description: '1 for the first attempt, 2 for the first retake, and so on.' }
        isLatestAttempt: { type: boolean }
        isBestAttempt: { type: boolean, description: The completed attempt with the highest score for this learner and assessment. }
        status: { $ref: '#/components/schemas/ResultStatus' }
        completionReason:
          type: [string, 'null']
          enum: [SUBMITTED, TIME_EXPIRED, null]
          description: Set once the attempt is submitted.
        score: { type: [number, 'null'], minimum: 0, maximum: 100, description: 'Percentage, 1 decimal place. Set when PASSED or FAILED.' }
        scoreType: { type: string, const: PERCENTAGE }
        maxScore: { type: number, const: 100 }
        passingScore: { type: [number, 'null'], description: 'The pass mark applied, when `passingRule` is SCORE.' }
        passingRule: { type: string, enum: [SCORE, SKILL_TARGETS] }
        result: { type: [string, 'null'], enum: [PASS, FAIL, null], description: Set when PASSED or FAILED. }
        startedDate: { type: [string, 'null'], format: date-time, description: Null only when NOT_STARTED. }
        completedDate: { type: [string, 'null'], format: date-time, description: When submitted. }
        progressPercent: { type: [integer, 'null'], minimum: 0, maximum: 100, description: While IN_PROGRESS. }
        grade:
          type: [string, 'null']
          description: Overall proficiency, the band of the learner's lowest measured skill.
        proficiency:
          type: array
          items: { $ref: '#/components/schemas/SkillResult' }
          description: One entry per skill.
        strongSkills:
          type: array
          items: { type: string }
          description: Skills at or above their target level.
        weakSkills:
          type: array
          items: { type: string }
          description: Skills below their target level.
        sectionScores:
          type: array
          items: { $ref: '#/components/schemas/SectionScore' }
        reportUrl:
          type: [string, 'null']
          format: uri
          description: 'The result''s report page (`/report/{resultId}`). Permanent link; it opens after sign-in for the learner, the organization''s staff, and (when the policy''s `managerAccess` is `individual`) the learner''s managers, direct or above. Null until the attempt is scored. Print it for a PDF.'
        certificateUrl: { type: [string, 'null'], format: uri, description: 'Public, verifiable credential page (Certifier), when issued.' }
        certificateIssuedAt: { type: [string, 'null'], format: date-time, description: When the certificate was issued. }
        certificateExpiresAt: { type: [string, 'null'], format: date-time, description: When it expires; null when it doesn't. }
        badgeUrl: { type: [string, 'null'], format: uri, description: 'Digital badge, when issued.' }
        proctoring:
          oneOf:
            - $ref: '#/components/schemas/Proctoring'
            - type: 'null'
          description: Null when the attempt wasn't proctored (proctoring off, or the learner was excused), or when the organization's policy shares proctoring only with clients holding `proctoring:read`.
        launchContext: { type: [string, 'null'], maxLength: 256, description: 'The `context` the attempt was launched with (`launchUrl?context=…`); null otherwise.' }
        lastUpdated: { type: string, format: date-time, description: 'Moves forward on every change to the record, including when a newer attempt or score flips this one''s `isLatestAttempt` or `isBestAttempt`.' }

    Proctoring:
      type: object
      required: [mode, likelihood, level, flags, verdict, reviewUrl]
      description: >-
        Camera proctoring. An AI model reads stills from the learner's camera (none is kept) and the browser notes
        what the learner does; `likelihood` combines them.
        It is a reason for a person to review, not proof: act on `verdict`, which a reviewer sets.
      properties:
        mode: { type: string, enum: [CAMERA, CAMERA_FULLSCREEN] }
        likelihood: { type: [integer, 'null'], minimum: 0, maximum: 100, description: 'Cheating likelihood, 0–100. Null while the attempt is in progress.' }
        level: { type: [string, 'null'], enum: [LOW, MEDIUM, HIGH, null], description: 'LOW under 25, MEDIUM 25–60, HIGH over 60.' }
        flags:
          type: array
          items:
            type: object
            required: [kind, count, seconds]
            properties:
              kind: { type: string, enum: [NOT_PRESENT, ANOTHER_PERSON, LOOKING_AWAY, FACE_PARTIAL, PHONE, NOTES_OR_DEVICE, OTHER, LEFT_WINDOW, LEFT_FULLSCREEN, PASTE, SECOND_SCREEN, CAMERA_OFF, INTERRUPTED] }
              count: { type: integer, minimum: 1 }
              seconds: { type: integer, minimum: 0, description: Total time across occurrences. }
        verdict: { type: [string, 'null'], enum: [CLEARED, CONFIRMED, null], description: "A reviewer's decision; a change fires result.updated." }
        reviewUrl: { type: string, format: uri, description: The attempt's review page in Creatium Assess (staff sign-in). }

    SkillResult:
      type: object
      required: [skill, level, band, targetLevel, met]
      properties:
        skill: { type: string }
        level: { type: [integer, 'null'], minimum: 1, maximum: 10, description: 'Null: not enough evidence to give a level with confidence.' }
        band:
          oneOf:
            - $ref: '#/components/schemas/Band'
            - type: 'null'
        confidence: { type: [number, 'null'], minimum: 0, maximum: 1, description: Probability the true level is within one of `level`. A level is given only at 0.8 or above. }
        targetLevel: { type: integer, minimum: 1, maximum: 10 }
        met: { type: boolean }

    SectionScore:
      type: object
      required: [section, score, maxScore]
      properties:
        section: { type: string, description: Skill name. }
        score: { type: number, description: Percentage of the section's available points earned. }
        maxScore: { type: number, const: 100 }

    ResultEvent:
      type: object
      required: [id, type, createdAt, data]
      properties:
        id: { type: string, format: uuid }
        type: { type: string, const: result.updated }
        createdAt: { type: string, format: date-time }
        data: { $ref: '#/components/schemas/Result' }

    AnalyticsSummary:
      type: object
      required: [assessmentId, title, status, type, enrolled, completed, completionRate, meetingTargetRate]
      properties:
        assessmentId: { type: string, format: uuid }
        title: { type: string }
        status: { type: string, enum: [Active, Retired] }
        type: { $ref: '#/components/schemas/AssessmentType' }
        enrolled: { type: integer, description: People enrolled now plus anyone with an attempt (enrolled earlier or launched directly). }
        completed: { type: integer, description: People with at least one finished attempt. }
        completionRate: { type: number, minimum: 0, maximum: 1 }
        medianMinutes: { type: [number, 'null'] }
        meetingTargetRate: { type: [number, 'null'], minimum: 0, maximum: 1, description: Average over the skills of the share meeting the skill's target; null when suppressed. }
        suppressed: { type: boolean, description: "True for a manager's token whose reports finished fewer than the policy's `minimumGroupSize`." }

    AssessmentAnalytics:
      type: object
      required: [assessmentId, title, status, enrolled, completed, completionRate, skills, teams, questions, completionsByDay]
      properties:
        assessmentId: { type: string, format: uuid }
        title: { type: string }
        status: { type: string, enum: [Active, Retired] }
        language: { type: [string, 'null'] }
        enrolled: { type: integer, description: People enrolled now plus anyone with an attempt (enrolled earlier or launched directly). }
        completed: { type: integer, description: People with at least one finished attempt. }
        completionRate: { type: number }
        medianMinutes: { type: [number, 'null'] }
        skills:
          type: array
          items:
            type: object
            required: [skill, targetLevel, targetBand, meetingTargetRate, bands]
            properties:
              skill: { type: string }
              targetLevel: { type: integer }
              targetBand: { $ref: '#/components/schemas/Band' }
              meetingTargetRate: { type: number }
              bands: { type: object, description: 'Share of people in each band, e.g. { "Foundational": 0.2, "Intermediate": 0.5, ... }.', additionalProperties: { type: number } }
        teams:
          type: array
          items:
            type: object
            required: [team, suppressed, meetingTargetRate]
            properties:
              team: { type: string }
              suppressed: { type: boolean, description: Fewer finished attempts than the policy's `minimumGroupSize`. }
              meetingTargetRate: { type: [object, 'null'], description: 'Share meeting the target, per skill name; null when suppressed.', additionalProperties: { type: number } }
        groupBy: { type: string, enum: [team, department, location, manager], description: Present with `groupBy`. }
        groups:
          type: array
          description: Present with `groupBy`. A null group is people without that attribute.
          items:
            type: object
            required: [group, completed, suppressed, meetingTargetRate]
            properties:
              group: { type: [string, 'null'] }
              completed: { type: integer }
              suppressed: { type: boolean }
              meetingTargetRate: { type: [object, 'null'], additionalProperties: { type: number } }
        suppressed: { type: boolean, description: "True for a manager's token whose reports finished fewer than `minimumGroupSize`: only the counts are given." }
        from: { type: [string, 'null'], format: date-time }
        to: { type: [string, 'null'], format: date-time }
        questions:
          type: array
          items:
            type: object
            required: [questionId, kind, level, text, answered, correctRate]
            properties:
              questionId: { type: string, format: uuid }
              kind: { type: string, enum: [knowledge, roleplay, project] }
              level: { type: integer }
              text: { type: string }
              answered: { type: integer }
              correctRate: { type: [number, 'null'], description: 'Share of answers scoring 80% or more, on each person''s latest finished attempt. Null when nobody has answered it.' }
        completionsByDay:
          type: array
          items:
            type: object
            required: [date, completions]
            properties:
              date: { type: string, format: date }
              completions: { type: integer }

    AnswerRecord:
      type: object
      required: [answerId, resultId, assessmentId, questionId, questionNumber, kind, type, result, lastUpdated]
      properties:
        answerId: { type: string, description: '<resultId>:<questionId>; unique per answer. Upsert on it.' }
        resultId: { type: string, description: The attempt; joins to /results. }
        assessmentId: { type: string, format: uuid }
        assessmentTitle: { type: string }
        learnerEmail: { type: string, format: email, description: Present unless people=anonymous. }
        personId: { type: string, description: 'Stable anonymous ID (C-…) per organization; present when people=anonymous. Deliberately not the `personId` of /people, so it can''t be traced back.' }
        team: { type: [string, 'null'] }
        attemptStatus: { type: string, enum: [IN_PROGRESS, SUBMITTED, TIME_EXPIRED] }
        language: { type: string }
        questionId: { type: string, format: uuid }
        questionNumber: { type: integer }
        kind: { type: string, enum: [knowledge, roleplay, project] }
        type: { type: string }
        skill: { type: [string, 'null'] }
        skillId: { type: string }
        objective: { type: [string, 'null'] }
        bloom: { type: [string, 'null'] }
        difficulty: { type: [integer, 'null'], minimum: 1, maximum: 10 }
        question: { type: string }
        answer: { type: [string, 'null'], description: The answer in words. }
        correctAnswer: { type: [string, 'null'], description: 'The expected answer in words; null for open answers, role plays and projects, and when the organization''s policy shares answer keys only with clients holding `analytics:answer-keys`.' }
        managerEmail: { type: [string, 'null'], format: email, description: Null when people=anonymous. }
        points: { type: [number, 'null'] }
        score: { type: [number, 'null'] }
        result: { type: string, enum: [Correct, Partly correct, Wrong, Not scored] }
        answeredAt: { type: [string, 'null'], format: date-time }
        startedDate: { type: string, format: date-time }
        completedDate: { type: [string, 'null'], format: date-time }
        lastUpdated: { type: string, format: date-time, description: When the attempt last changed; use for incremental sync. }
        proctoringLevel: { type: [string, 'null'], enum: [LOW, MEDIUM, HIGH, null], description: "The attempt's proctoring level once scored; null when not proctored, or when the policy requires `proctoring:read` and the client lacks it." }

    EnrollmentRequest:
      type: object
      required: [email, assessmentIds]
      properties:
        email: { type: string, format: email }
        firstName: { type: string, maxLength: 100 }
        lastName: { type: string, maxLength: 100 }
        assessmentIds:
          type: array
          minItems: 1
          maxItems: 100
          items: { type: string, format: uuid }
        notify:
          type: boolean
          default: false
          description: Send Creatium's invitation email. Leave false when the platform notifies learners itself.

    EnrollmentResponse:
      type: object
      required: [email, results]
      properties:
        email: { type: string, format: email }
        results:
          type: array
          items:
            type: object
            required: [assessmentId, status]
            properties:
              assessmentId: { type: string, format: uuid }
              status: { type: string, enum: [enrolled, already_enrolled, failed] }
              error:
                type: object
                required: [code, detail]
                properties:
                  code: { type: string, enum: [assessment_not_found, assessment_not_active, email_outside_domains], description: '`email_outside_domains` only in `/enrollments:batch`.' }
                  detail: { type: string }

    Enrollment:
      type: object
      required: [learnerEmail, assessmentId, enrolledAt]
      properties:
        personId: { type: [string, 'null'], format: uuid }
        learnerEmail: { type: string, format: email }
        assessmentId: { type: string, format: uuid }
        enrolledAt: { type: string, format: date-time }
        source: { type: string, enum: [API, INVITE, LAUNCH, ASSIGNMENT] }

    AssignmentSettings:
      type: object
      properties:
        name: { type: string, minLength: 1, maxLength: 200 }
        assessmentIds:
          type: array
          description: In order. Live assessments only.
          minItems: 1
          maxItems: 50
          items: { type: string, format: uuid }
        dueAt: { type: [string, 'null'], format: date-time, description: Shown on each person's home; a reminder goes out 48 hours before to anyone unfinished. }
        closeAtDue: { type: boolean, default: false, description: After `dueAt` nobody can start a new attempt. }
        ordered: { type: boolean, default: false, description: Each assessment opens once the ones before it are finished. }
        maxAttempts: { type: [integer, 'null'], minimum: 1, maximum: 20, description: Attempts allowed at each assessment; null for no limit. }
    AssignmentRequest:
      allOf:
        - $ref: '#/components/schemas/AssignmentSettings'
        - $ref: '#/components/schemas/MembersRequest'
        - type: object
          required: [name, assessmentIds]
    AssignmentUpdate:
      $ref: '#/components/schemas/AssignmentSettings'
    MembersRequest:
      type: object
      properties:
        emails: { type: array, maxItems: 1000, items: { type: string, format: email } }
        teams: { type: array, maxItems: 50, items: { type: string, maxLength: 100 } }
        notify: { type: boolean, default: false }
    Assignment:
      type: object
      required: [assignmentId, name, assessmentIds, dueAt, closeAtDue, ordered, maxAttempts, members, createdAt, updatedAt]
      properties:
        assignmentId: { type: string, format: uuid }
        name: { type: string }
        assessmentIds: { type: array, items: { type: string, format: uuid } }
        dueAt: { type: [string, 'null'], format: date-time }
        closeAtDue: { type: boolean }
        ordered: { type: boolean }
        maxAttempts: { type: [integer, 'null'] }
        members:
          type: object
          required: [emails, teams]
          properties:
            emails: { type: array, items: { type: string, format: email } }
            teams: { type: array, items: { type: string } }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        archivedAt: { type: [string, 'null'], format: date-time, description: 'Set once archived; only listed with `includeArchived=true`.' }
    MemberOutcome:
      type: object
      required: [status]
      properties:
        email: { type: string, format: email }
        team: { type: string }
        status: { type: string, enum: [added, already_member, failed] }
        error:
          type: object
          required: [code, detail]
          properties:
            code: { type: string, enum: [email_outside_domains] }
            detail: { type: string }
    ProgressStatus:
      type: string
      description: |
        `NOT_STARTED`: no attempt at any assessment in it.
        `IN_PROGRESS`: at least one started, not all finished.
        `COMPLETED`: every assessment has a finished attempt.
      enum: [NOT_STARTED, IN_PROGRESS, COMPLETED]
    AssignmentProgress:
      type: object
      required: [learnerEmail, status, passed, overdue, completedAt, assessments]
      properties:
        personId: { type: [string, 'null'], format: uuid }
        learnerEmail: { type: string, format: email }
        team: { type: [string, 'null'] }
        managerEmail: { type: [string, 'null'], format: email }
        status: { $ref: '#/components/schemas/ProgressStatus' }
        passed:
          type: [boolean, 'null']
          description: Across the certifications in it only. Null when there are none, or any is unfinished or awaiting release.
        overdue: { type: boolean, description: Past `dueAt` and not completed. }
        completedAt: { type: [string, 'null'], format: date-time }
        assessments:
          type: array
          items:
            type: object
            required: [assessmentId, status]
            properties:
              assessmentId: { type: string, format: uuid }
              status: { $ref: '#/components/schemas/ResultStatus' }
    AssignmentEvent:
      type: object
      required: [id, type, createdAt, data]
      properties:
        id: { type: string, format: uuid }
        type: { type: string, const: assignment.completed }
        createdAt: { type: string, format: date-time }
        data:
          allOf:
            - $ref: '#/components/schemas/AssignmentProgress'
            - type: object
              required: [assignmentId, name]
              properties:
                assignmentId: { type: string, format: uuid }
                name: { type: string }
    Person:
      type: object
      required: [personId, email, team, externalId]
      properties:
        personId: { type: [string, 'null'], format: uuid, description: Stable; survives an email change. }
        email: { type: string, format: email }
        team: { type: [string, 'null'] }
        externalId: { type: [string, 'null'], maxLength: 200, description: 'The employer''s own ID (employee number), unique in the organization.' }
        managerEmail: { type: [string, 'null'], format: email, description: Their manager (a reporting line). }
        department: { type: [string, 'null'], maxLength: 100 }
        location: { type: [string, 'null'], maxLength: 100 }

    PersonUpdate:
      type: object
      description: Send any of these; fields left out stay as they are, and `null` clears one.
      properties:
        team: { type: [string, 'null'], maxLength: 100 }
        externalId: { type: [string, 'null'], minLength: 1, maxLength: 200 }
        managerEmail: { type: [string, 'null'], format: email, description: Not the person themselves. }
        department: { type: [string, 'null'], minLength: 1, maxLength: 100 }
        location: { type: [string, 'null'], minLength: 1, maxLength: 100 }

    PeopleBatchRequest:
      type: object
      required: [people]
      properties:
        people:
          type: array
          minItems: 1
          maxItems: 1000
          items:
            allOf:
              - $ref: '#/components/schemas/PersonUpdate'
              - type: object
                required: [email]
                properties:
                  email: { type: string, format: email }

    PeopleBatchResponse:
      type: object
      required: [results]
      properties:
        results:
          type: array
          items:
            type: object
            required: [email, status]
            properties:
              email: { type: string, format: email }
              status: { type: string, enum: [updated, failed] }
              error:
                type: object
                required: [code, detail]
                properties:
                  code: { type: string, description: 'A Problem code: unprocessable, external_id_in_use, …' }
                  detail: { type: string }

    PersonProfile:
      allOf:
        - $ref: '#/components/schemas/Person'
        - type: object
          required: [skills, assignments, certificates]
          properties:
            skills:
              type: array
              description: Latest released level per skill across every assessment, with the one before.
              items:
                type: object
                required: [skill, level, band, measuredAt, assessmentId, resultId, previousLevel, change]
                properties:
                  skill: { type: string }
                  level: { type: integer, minimum: 1, maximum: 10 }
                  band: { $ref: '#/components/schemas/Band' }
                  measuredAt: { type: string, format: date-time }
                  assessmentId: { type: string, format: uuid }
                  resultId: { type: string }
                  previousLevel: { type: [integer, 'null'] }
                  change: { type: [integer, 'null'], description: level − previousLevel. }
            assignments:
              type: array
              items:
                type: object
                required: [assignmentId, name, dueAt, status, overdue, completedAt]
                properties:
                  assignmentId: { type: string, format: uuid }
                  name: { type: string }
                  dueAt: { type: [string, 'null'], format: date-time }
                  status: { $ref: '#/components/schemas/ProgressStatus' }
                  overdue: { type: boolean }
                  completedAt: { type: [string, 'null'], format: date-time }
            certificates:
              type: array
              items:
                type: object
                required: [assessmentId, title, resultId, certificateUrl, issuedAt, expiresAt]
                properties:
                  assessmentId: { type: string, format: uuid }
                  title: { type: string }
                  resultId: { type: string }
                  certificateUrl: { type: string, format: uri }
                  issuedAt: { type: [string, 'null'], format: date-time }
                  expiresAt: { type: [string, 'null'], format: date-time }

    Policy:
      type: object
      required: [managerAccess, minimumGroupSize, proctoringRequiresScope, answerKeysRequireScope]
      properties:
        managerAccess: { type: string, enum: [none, aggregates, individual], description: 'What a token issued for a manager may read (default `aggregates`).' }
        minimumGroupSize: { type: integer, minimum: 1, maximum: 50, description: Analytics never break out a group with fewer finished attempts (default 5). }
        proctoringRequiresScope: { type: boolean, description: 'True: proctoring only for clients holding `proctoring:read`.' }
        answerKeysRequireScope: { type: boolean, description: 'True: `correctAnswer` only for clients holding `analytics:answer-keys`.' }

    Event:
      type: object
      required: [id, type, createdAt, data]
      description: Exactly the body a webhook endpoint receives.
      properties:
        id: { type: string, format: uuid, description: Same as the `Creatium-Event-Id` header. }
        type: { type: string, enum: [result.updated, assignment.completed, enrollment.created, enrollment.removed, assignment.updated, assignment.archived, assessment.updated, person.erased] }
        createdAt: { type: string, format: date-time }
        data: { type: object, description: 'Per type: `Result`, `AssignmentProgress` (+ assignmentId, name), `EnrollmentEventData`, `AssignmentEventData`, `AssessmentEventData` or `{personId, erasedAt}`.' }

    EnrollmentEventData:
      type: object
      required: [personId, learnerEmail, assessmentId]
      properties:
        personId: { type: [string, 'null'], format: uuid }
        learnerEmail: { type: string, format: email }
        assessmentId: { type: string, format: uuid }
        enrolledAt: { type: string, format: date-time, description: enrollment.created only. }
        source: { type: string, enum: [API, INVITE, LAUNCH, ASSIGNMENT], description: enrollment.created only. }
        removedAt: { type: string, format: date-time, description: enrollment.removed only. }
        resultId: { type: [string, 'null'], description: 'enrollment.removed only: the `REMOVED` record''s resultId, or null when they had attempts (those stay).' }

    AssignmentEventData:
      type: object
      required: [assignmentId, name, updatedAt, archivedAt]
      description: Read `GET /assignments/{assignmentId}` (or `?includeArchived=true`) for the rest.
      properties:
        assignmentId: { type: string, format: uuid }
        name: { type: string }
        updatedAt: { type: string, format: date-time }
        archivedAt: { type: [string, 'null'], format: date-time }

    AssessmentEventData:
      type: object
      required: [assessmentId, status, lastUpdatedDate]
      description: Read `GET /assessments/{assessmentId}` for the rest.
      properties:
        assessmentId: { type: string, format: uuid }
        status: { type: string, enum: [Active, Retired] }
        lastUpdatedDate: { type: string, format: date-time }

    WebhookEndpoint:
      type: object
      required: [endpointId, url, events, createdAt, previousSecretExpiresAt]
      properties:
        endpointId: { type: string, format: uuid }
        url: { type: string, format: uri, description: Public https URL. }
        events: { type: array, items: { type: string }, description: 'Any of the Event types, and the older `assessment.completed`.' }
        createdAt: { type: string, format: date-time }
        previousSecretExpiresAt: { type: [string, 'null'], format: date-time, description: Until when deliveries also carry a signature with the previous secret. }
        secret: { type: string, description: 'The signing secret: only in the responses that create or rotate it.' }

    WebhookEndpointRequest:
      type: object
      required: [url, events]
      properties:
        url: { type: string, format: uri }
        events: { type: array, minItems: 1, items: { type: string } }

    ClientSecret:
      type: object
      required: [secretId, lastFour, createdAt]
      properties:
        secretId: { type: string, format: uuid }
        lastFour: { type: string }
        createdAt: { type: string, format: date-time }
        clientSecret: { type: string, description: 'The secret itself: only in the response that creates it.' }

    Erasure:
      type: object
      required: [personId, status, erasedAt]
      properties:
        personId: { type: string, format: uuid }
        status: { type: string, const: ERASED }
        erasedAt: { type: string, format: date-time }

    ErasureEvent:
      type: object
      required: [id, type, createdAt, data]
      properties:
        id: { type: string, format: uuid }
        type: { type: string, const: person.erased }
        createdAt: { type: string, format: date-time }
        data:
          type: object
          required: [personId, erasedAt]
          properties:
            personId: { type: string, format: uuid }
            erasedAt: { type: string, format: date-time }

    BatchEnrollmentRequest:
      type: object
      required: [enrollments]
      description: Up to 1,000 people and 10,000 (person, assessment) pairs in one call.
      properties:
        enrollments:
          type: array
          minItems: 1
          maxItems: 1000
          items:
            type: object
            required: [email, assessmentIds]
            properties:
              email: { type: string, format: email }
              firstName: { type: string, maxLength: 100 }
              lastName: { type: string, maxLength: 100 }
              assessmentIds: { type: array, minItems: 1, maxItems: 100, items: { type: string, format: uuid } }
        notify: { type: boolean, default: false, description: Send Creatium's invitation email to each person newly enrolled. }

    BatchEnrollmentResponse:
      type: object
      required: [results]
      properties:
        results: { type: array, items: { $ref: '#/components/schemas/EnrollmentResponse' }, description: One per person, in the order sent. }

    Question:
      type: object
      required: [questionId, kind, type, skillId, status]
      description: Metadata only, never the question's text or answer. Field names match AnswerRecord, so they join on `questionId`.
      properties:
        questionId: { type: string, format: uuid }
        kind: { type: string, enum: [knowledge, roleplay, project] }
        type: { type: string, description: 'single-choice, multiple-choice, ordering, role-play, project, …' }
        skill: { type: [string, 'null'] }
        skillId: { type: string }
        objective: { type: [string, 'null'] }
        bloom: { type: [string, 'null'], enum: [Remember, Understand, Apply, Analyze, Evaluate, Create, null] }
        difficulty: { type: [integer, 'null'], minimum: 1, maximum: 10 }
        weight: { type: integer, minimum: 1, maximum: 5 }
        status: { type: string, enum: [ACTIVE, RETIRED], description: '`RETIRED` questions are no longer served but may appear in older answers.' }

    Problem:
      type: object
      description: RFC 9457 problem details. `code` is stable and machine-readable; `detail` is for people.
      required: [type, title, status, code, requestId]
      properties:
        type: { type: string, format: uri, description: 'The error code explained, at `https://app.assess.creatium.com/docs#error-<code>`.', examples: ['https://app.assess.creatium.com/docs#error-invalid_request'] }
        title: { type: string }
        status: { type: integer }
        code:
          type: string
          enum: [invalid_request, invalid_token, insufficient_scope, not_found, method_not_allowed, assessment_not_found, assessment_not_active, idempotency_conflict, enrollment_from_assignment, email_in_use, external_id_in_use, secret_limit, precondition_failed, unprocessable, rate_limited, internal_error, service_unavailable]
          x-enum-descriptions:
            invalid_request: '400. A parameter or the body is not valid; `errors[]` names the fields. Fix the request.'
            invalid_token: '401. The access token is missing, expired, revoked or wrong. Get a new token.'
            insufficient_scope: '403. The client lacks the scope this operation needs. Ask the organization admin to add it.'
            not_found: '404. No such resource in this organization (or no such path).'
            method_not_allowed: '405. The path exists but not with that method; the `Allow` header lists the ones it takes.'
            assessment_not_found: '404. The assessment ID does not exist in this organization, or is a draft.'
            assessment_not_active: '422. The assessment is retired and takes no new enrollments or assignments.'
            idempotency_conflict: '409. The same `Idempotency-Key` was sent with a different body in the last 24 hours.'
            enrollment_from_assignment: '409. The person has the assessment through an assignment; remove them from the assignment instead.'
            email_in_use: '409. Another person in the organization already has that email address.'
            external_id_in_use: '409. Another person in the organization already has that `externalId`.'
            secret_limit: '409. A client has at most two live secrets, and never fewer than one: revoke the old one first, or add a new one first.'
            precondition_failed: '412. `If-Match` no longer matches the record. Read it again, then retry your change.'
            unprocessable: '422. Well-formed but not allowed, for example an email outside the organization''s domains.'
            rate_limited: '429. More than 600 requests in a minute. Wait `Retry-After` seconds.'
            internal_error: '500. Something failed on our side. Retry with backoff; quote the `requestId` if it persists.'
            service_unavailable: '503. Temporarily unavailable. Retry after `Retry-After` seconds.'
        detail: { type: string }
        requestId: { type: string, description: Quote it to support. Also returned as the `X-Request-Id` header. }
        errors:
          type: array
          description: Field-level problems (400 and 422).
          items:
            type: object
            required: [field, message]
            properties:
              field: { type: string }
              message: { type: string }

  responses:
    BadRequest:
      description: The request is malformed (a parameter or the body is not valid).
      headers:
        X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
        RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
        RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
        RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          example:
            type: https://app.assess.creatium.com/docs#error-invalid_request
            title: Invalid request
            status: 400
            code: invalid_request
            detail: Some parameters are not valid.
            requestId: req_01J8Z6V3M4
            errors: [{ field: limit, message: Number must be less than or equal to 200 }]
    BadRequestDateWindow:
      description: The request is malformed, or the mandatory date window (`updatedSince`) is missing.
      headers:
        X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
        RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
        RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
        RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          example:
            type: https://app.assess.creatium.com/docs#error-invalid_request
            title: Invalid request
            status: 400
            code: invalid_request
            detail: updatedSince is required.
            requestId: req_01J8Z6V3M4
            errors: [{ field: updatedSince, message: Required. Send an ISO 8601 UTC date-time. }]
    Unauthorized:
      description: Missing, expired or invalid access token.
      headers:
        X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
        RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
        RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
        RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
        WWW-Authenticate:
          schema: { type: string, examples: ['Bearer error="invalid_token"'] }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    Forbidden:
      description: The token lacks the scope this endpoint needs (`insufficient_scope`).
      headers:
        X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
        RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
        RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
        RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    NotFound:
      description: No such resource in this organization.
      headers:
        X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
        RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
        RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
        RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    AssessmentNotFound:
      description: The assessment ID does not exist in this organization, or is a draft (`assessment_not_found`).
      headers:
        X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
        RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
        RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
        RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          example:
            type: https://app.assess.creatium.com/docs#error-assessment_not_found
            title: Assessment not found
            status: 404
            code: assessment_not_found
            detail: No assessment 0b7e3d9a-6c21-4f58-8a1e-2d4c9b7f6e30 in this organization.
            requestId: req_01J8Z6V3M5
    PreconditionFailed:
      description: '`If-Match` no longer matches: someone changed the record since you read it (`precondition_failed`).'
      headers:
        X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
        RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
        RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
        RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    Conflict:
      description: 'The change clashes with another record: `email_in_use`, `external_id_in_use` or `idempotency_conflict`.'
      headers:
        X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
        RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
        RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
        RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    IdempotencyConflict:
      description: The Idempotency-Key was used with a different body (`idempotency_conflict`).
      headers:
        X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
        RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
        RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
        RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    Unprocessable:
      description: Well-formed but not allowed (for example an email outside the organization's allowed domains).
      headers:
        X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
        RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
        RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
        RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    EnrollmentFromAssignment:
      description: The learner has this assessment through an assignment (`enrollment_from_assignment`). Remove them from the assignment instead.
      headers:
        X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
        RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
        RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
        RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    TooManyRequests:
      description: Rate limit exceeded (`rate_limited`). Retry after `Retry-After` seconds.
      headers:
        Retry-After: { $ref: '#/components/headers/Retry-After' }
        X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
        RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
        RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
        RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    ServerError:
      description: '`internal_error` (500) or `service_unavailable` (503, with `Retry-After`). Safe to retry GET requests with backoff.'
      headers:
        X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
        RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
        RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' }
        RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }

  examples:
    CatalogPage:
      value:
        data:
          - assessmentId: 5f1d6c1e-2a4b-4c8e-9f3a-7b6d2e1c0a91
            title: Sales Data Literacy
            description: Finds your level in reading sales dashboards, data quality and data privacy, and shows where to focus next.
            descriptionFormat: text
            objective: Read and question sales data confidently, and handle customer data correctly.
            launchUrl: https://app.assess.creatium.com/launch/5f1d6c1e-2a4b-4c8e-9f3a-7b6d2e1c0a91
            launchMode: SP_INITIATED_SSO
            duration: 25
            durationUnit: minutes
            durationIso: PT25M
            timed: false
            status: Active
            type: Skill
            level: Advanced
            adaptive: true
            questionCount: { max: 42, rolePlays: 1, projects: 0 }
            maxScore: 100
            passingScore: null
            passingRule: SKILL_TARGETS
            languages: [en, ar-EG]
            skills: [Reading dashboards, Data quality, Data privacy basics]
            skillDetails:
              - { skillId: sk-dashboards, name: Reading dashboards, targetLevel: 6, targetBand: Advanced, objectives: [Read revenue, deals and conversion from a dashboard] }
              - { skillId: sk-quality, name: Data quality, targetLevel: 4, targetBand: Intermediate, objectives: [Find duplicate or missing records in a CRM export] }
              - { skillId: sk-privacy, name: Data privacy basics, targetLevel: 4, targetBand: Intermediate, objectives: [Handle a customer data request correctly] }
            sections: [Reading dashboards, Data quality, Data privacy basics]
            maxAttempts: 5
            publishedDate: '2026-09-02T08:00:00Z'
            activationDate: '2026-09-02T08:00:00Z'
            expiryDate: null
            lastUpdatedDate: '2026-09-10T12:30:00Z'
            industry: null
            linkedCourses: []
            thumbnailUrl: https://app.assess.creatium.com/api/blobs/thumbnails/5f1d6c1e.png
            rating: null
            benchmarks: { completions: 128, medianScore: 74.5, passRate: 0.62 }
        pagination: { limit: 100, hasMore: false, nextCursor: null, totalCount: 1 }
    ResultsPage:
      value:
        data:
          - resultId: 9d2c7a51-3e8f-4b16-a0c4-5f7e2b1d8c63
            learnerEmail: john.doe@example.com
            learnerName: John Doe
            assessmentId: 5f1d6c1e-2a4b-4c8e-9f3a-7b6d2e1c0a91
            attemptNumber: 2
            isLatestAttempt: true
            isBestAttempt: true
            status: PASSED
            completionReason: SUBMITTED
            score: 82
            scoreType: PERCENTAGE
            maxScore: 100
            passingScore: null
            passingRule: SKILL_TARGETS
            result: PASS
            startedDate: '2026-09-20T09:00:00Z'
            completedDate: '2026-09-20T09:52:00Z'
            progressPercent: null
            grade: Intermediate
            proficiency:
              - { skill: Reading dashboards, level: 7, band: Advanced, confidence: 0.86, targetLevel: 6, met: true }
              - { skill: Data quality, level: 5, band: Intermediate, confidence: 0.82, targetLevel: 4, met: true }
              - { skill: Data privacy basics, level: 4, band: Intermediate, confidence: 0.91, targetLevel: 4, met: true }
            strongSkills: [Reading dashboards, Data quality, Data privacy basics]
            weakSkills: []
            sectionScores:
              - { section: Reading dashboards, score: 85, maxScore: 100 }
              - { section: Data quality, score: 78, maxScore: 100 }
              - { section: Data privacy basics, score: 83, maxScore: 100 }
            reportUrl: https://app.assess.creatium.com/report/9d2c7a51-3e8f-4b16-a0c4-5f7e2b1d8c63
            certificateUrl: null
            badgeUrl: null
            proctoring: null
            lastUpdated: '2026-09-20T09:53:10Z'
          - resultId: 3b6e1f08-7c2d-4a95-b8e1-0d4f6a2c9e17
            learnerEmail: jane.roe@example.com
            learnerName: Jane Roe
            assessmentId: 5f1d6c1e-2a4b-4c8e-9f3a-7b6d2e1c0a91
            attemptNumber: 1
            isLatestAttempt: true
            isBestAttempt: false
            status: IN_PROGRESS
            completionReason: null
            score: null
            scoreType: PERCENTAGE
            maxScore: 100
            passingScore: null
            passingRule: SKILL_TARGETS
            result: null
            startedDate: '2026-09-20T10:05:00Z'
            completedDate: null
            progressPercent: 40
            grade: null
            proficiency: []
            strongSkills: []
            weakSkills: []
            sectionScores: []
            reportUrl: null
            certificateUrl: null
            badgeUrl: null
            proctoring: null
            lastUpdated: '2026-09-20T10:11:42Z'
        pagination: { limit: 100, hasMore: true, nextCursor: eyJ1IjoiMjAyNi0wOS0yMFQxMDoxMTo0MloiLCJpIjoiM2I2ZTFmMDgifQ, totalCount: 348 }
