# Creatium Assess API guide

The Creatium Assess API lets a learning platform or your own systems:

- read your organisation's **assessment catalog**,
- send people to an assessment with a **launch link** (single sign-on),
- **enroll** people ahead of time,
- give **assignments**: a set of assessments for people and teams, with progress across the set,
- read **results**: status, score, level per skill, certificate link,
- get a **webhook** when a result changes.

The full reference is at [app.assess.creatium.com/docs](https://app.assess.creatium.com/docs), and as
OpenAPI 3.1 at [`/docs/openapi.yaml`](https://app.assess.creatium.com/docs/openapi.yaml) to import into
Postman, Insomnia or a code generator.

Every client belongs to one organisation and only ever sees that organisation's assessments,
people and results.

> Field names say `learner` (for example `learnerEmail`) to match learning-platform conventions.
> They mean the person taking the assessment.

## Environments

| Environment | Base URL |
|---|---|
| Production | `https://app.assess.creatium.com/api/v1` |
| Sandbox / UAT | `https://staging.app.assess.creatium.com/api/v1` |

Credentials are per environment: a sandbox client does not work in production.

Conventions: JSON over HTTPS, camelCase fields, timestamps in ISO 8601 UTC with `Z`. TLS 1.2 or
later is required (1.0 and 1.1 are refused); `http://` answers `308` to `https://`, but call https
directly so your token never travels in the clear.
New fields can appear in responses at any time, so ignore fields you don't recognise.

## 1. Get credentials

An organisation admin creates them in **Admin → API**.

- **OAuth client** (for partners and platforms): choose a name and scopes, then copy the client ID
  and secret. The secret is shown once. Send it to the partner through a secure channel, never by email.
- **API key** (for your own scripts): a single `ak_…` key with every scope. Send it as a bearer token
  and skip step 2.

<!-- scope-table:start (generated from openapi.yaml by src/lib/spec-contract.test.ts; UPDATE_DOCS=1 to refresh) -->
| Scope | Allows |
|---|---|
| `catalog:read` | `GET /events`, `GET /assessments`, `GET /assessments/{assessmentId}`, `GET /assessments/{assessmentId}/questions` |
| `results:read` | `GET /events`, `GET /results`, `GET /results/{resultId}`, `GET /assignments/{assignmentId}/progress`, `GET /people/{learnerEmail}/profile` |
| `enrollment:read` | `GET /events`, `GET /enrollments`, `GET /assignments`, `GET /assignments/{assignmentId}`, `GET /people` |
| `enrollment:write` | `GET /events`, `POST /sandbox/attempts`, `GET /enrollments`, `POST /enrollments`, `POST /enrollments:batch`, `DELETE /enrollments/{assessmentId}/{learnerEmail}`, `GET /assignments`, `POST /assignments`, `GET /assignments/{assignmentId}`, `PATCH /assignments/{assignmentId}`, `DELETE /assignments/{assignmentId}`, `POST /assignments/{assignmentId}/members`, `DELETE /assignments/{assignmentId}/members/{learnerEmail}`, `DELETE /assignments/{assignmentId}/teams/{team}`, `GET /people`, `PUT /people/{learnerEmail}`, `DELETE /people/{learnerEmail}`, `POST /people/{personId}:changeEmail`, `POST /people:batch` |
| `analytics:read` | `GET /analytics/assessments`, `GET /analytics/assessments/{assessmentId}`, `GET /analytics/answers` |
| `analytics:answer-keys` |  |
| `proctoring:read` |  |
| `webhooks:manage` | `GET /webhook-endpoints`, `POST /webhook-endpoints`, `GET /webhook-endpoints/{endpointId}`, `PATCH /webhook-endpoints/{endpointId}`, `DELETE /webhook-endpoints/{endpointId}`, `POST /webhook-endpoints/{endpointId}:rotateSecret`, `POST /webhook-endpoints/{endpointId}:test` |
| `people:erase` | `POST /people/{personId}:erase` |
<!-- scope-table:end -->

**Rotating a secret:** a client can hold two live secrets. Add a new secret (**New secret**, or
`POST /client-secrets` with the client's own token), switch your system to it, then revoke the old one
(`DELETE /client-secrets/{secretId}`). Nothing stops working in between. To end a token early,
`POST /oauth/revoke` with `token=<access token>` (RFC 7009).

## 2. Get an access token

OAuth 2.0 client credentials. Send the client ID and secret with HTTP Basic authentication:

```bash
curl -s https://app.assess.creatium.com/api/v1/oauth/token \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d grant_type=client_credentials
```

```json
{ "access_token": "at_4f9c…", "token_type": "Bearer", "expires_in": 3600, "scope": "catalog:read results:read enrollment:write" }
```

- A token lasts **1 hour**. There are no refresh tokens: request a new token before it expires.
- Add `-d scope="catalog:read results:read"` to ask for fewer scopes than the client has.
- `client_id` and `client_secret` can go in the form body instead of Basic auth.

Send the token on every call:

```bash
curl -s https://app.assess.creatium.com/api/v1/assessments \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

## 3. Read the catalog

`GET /assessments` returns every assessment that is, or was, available. Drafts are never listed.

```bash
curl -s "https://app.assess.creatium.com/api/v1/assessments?updatedSince=2026-09-01T00:00:00Z" \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

Fields you will use most:

| Field | Meaning |
|---|---|
| `assessmentId` | Stable UUID. The same in every endpoint, never reused. |
| `title`, `description`, `objective`, `thumbnailUrl` | For your catalog card (16:9 image). |
| `launchUrl` | Where to send a person to take it (see step 4). |
| `status` | `Active` (can be taken) or `Retired` (closed; results stay available). |
| `type` | `Skill` (finds a level per skill) or `Certification` (passing issues a credential). |
| `duration`, `timed` | Minutes; `timed: true` means it is a hard time limit. |
| `skills`, `skillDetails` | Skills measured, each with its target level (1–10) and band. |
| `passingRule`, `passingScore` | `SCORE` (pass at `passingScore` %) or `SKILL_TARGETS` (every skill reaches its target). |
| `maxAttempts` | Attempts allowed. No question, role play or project repeats across them. |
| `lastUpdatedDate` | Moves forward on any change. Use it for delta sync. |

**Delta sync:** store the newest `lastUpdatedDate` you received and send it as `updatedSince` next
time. Retired assessments are still returned (with `status: Retired`), so you can hide them.

Filters: `status` (`Active`, `Retired`, `all`), `type`, `language` (BCP 47, e.g. `en`, `ar-EG`),
`updatedBefore`.

## 4. Launch an assessment

Send the person to the assessment's `launchUrl`:

```
https://app.assess.creatium.com/launch/{assessmentId}
```

- It starts single sign-on with the organisation's identity provider, then opens the assessment's
  start page. Someone already signed in goes straight there.
- Launching **assigns** the assessment, so you don't need to enroll first.
- The link is stable and carries no token, so it is safe to store and show in your catalog.
- People are matched by work email, the same address the identity provider sends.
- An ID that is unknown, not yet published or malformed shows a 404 page before any sign-in.
- **Your context and a way back:** `launchUrl?context=enr-8812&returnUrl=https%3A%2F%2Flms.example.com%2Fcourse%2F7`.
  `context` (up to 256 characters) comes back on the result as `launchContext`; `returnUrl` puts a
  **Back** button on the results page once they finish. Return URLs must be https and on one of your
  organisation's allowed LMS domains (**Admin → Embed**); others are ignored.

Other ways in, for platforms that prefer a standard:

- **SCORM 1.2:** download a package per assessment in the Assessment Designer (**Share → SCORM**) and
  upload it to your LMS. The LMS gets completion and score; `/results` gets the full record.
- **Embed:** show the assessment in an iframe at `https://app.assess.creatium.com/embed/{assessmentId}`
  from domains your organisation allows (**Admin → Embed**).

## 5. Enroll people (optional)

Enrolling assigns assessments before launch: they appear on the person's Creatium home, and in
results as `NOT_STARTED`. The person doesn't need an account yet; it is created at their first sign-in.

```bash
curl -s https://app.assess.creatium.com/api/v1/enrollments \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: enroll-john.doe-2026-09-29" \
  -d '{ "email": "john.doe@example.com", "firstName": "John", "lastName": "Doe",
        "assessmentIds": ["5f1d6c1e-2a4b-4c8e-9f3a-7b6d2e1c0a91"], "notify": false }'
```

The response is `207` with one outcome per assessment: `enrolled`, `already_enrolled`, or `failed`
with an `error.code` (`assessment_not_found`, `assessment_not_active`).

- `notify: true` sends Creatium's invitation email. Leave it `false` if your platform tells people itself.
- **Idempotency-Key** (any unique string, up to 255 characters) makes retries safe: the same key and body
  within 24 hours returns the first response; the same key with a different body returns `409`.
- An email outside the organisation's allowed domains returns `422`.
- `GET /enrollments?learnerEmail=…&assessmentId=…` lists enrollments.
- `DELETE /enrollments/{assessmentId}/{learnerEmail}` unenrolls. An attempt in progress can still be
  finished; completed results are kept. Deleting an absent enrollment also returns `204`. The
  `NOT_STARTED` record comes back once as `REMOVED`, so your incremental sync sees it.
- **Many at once:** `POST /enrollments:batch` with `{"enrollments": [{"email": …, "assessmentIds": […]}, …]}`,
  up to 1,000 people. `207` with one outcome per person; someone outside the allowed domains fails
  alone (`email_outside_domains`).
- Wherever a path names a person (`{learnerEmail}`), you can send their `personId` instead. Prefer it:
  paths appear in access logs.

### Assignments: many assessments, many people

An assignment is a named set of assessments, an optional due date and rules, given to people (by
email) and teams. Everyone it reaches is enrolled in each of its assessments (they appear in
`/enrollments` with `source: "ASSIGNMENT"`), including people who join a team later. Scope
`enrollment:write` (`enrollment:read` is enough to list and read them); progress needs `results:read`.

```bash
curl -s https://app.assess.creatium.com/api/v1/assignments \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: onboarding-2026-10" \
  -d '{ "name": "New starter onboarding",
        "assessmentIds": ["5f1d6c1e-2a4b-4c8e-9f3a-7b6d2e1c0a91", "0b7e3d9a-6c21-4f58-8a1e-2d4c9b7f6e30"],
        "dueAt": "2026-10-31T23:59:59Z", "ordered": true,
        "teams": ["Sales"], "emails": ["john.doe@example.com"], "notify": true }'
```

The response is `201` with the assignment and one outcome per member (`added`, `already_member`,
or `failed` with `error.code: email_outside_domains`).

| Rule | Field | What it does |
|---|---|---|
| Due date | `dueAt` | Shown on each person's home; a reminder email goes 48 hours before to anyone unfinished |
| Close at the due date | `closeAtDue: true` | Nobody can start after `dueAt` (an attempt in progress can finish) |
| In order | `ordered: true` | Each assessment opens once the ones before it are finished |
| Attempt limit | `maxAttempts` | Attempts allowed at each assessment (1 to 20) |

The rules never limit someone who was also enrolled in that assessment directly.

- **Teams:** one per person. `PUT /people/{email}` with `{"team": "Sales"}` (or `null` to clear);
  `GET /people?team=Sales` lists them. A team's assignments follow its members at once.
- **More people:** `POST /assignments/{id}/members` with `emails` (up to 1,000) and `teams` (up to 50);
  `207` with one outcome each.
- **Fewer:** `DELETE /assignments/{id}/members/{email}` or `DELETE /assignments/{id}/teams/{team}`.
  They keep anything a team or another assignment still gives them.
- **Change:** `PATCH /assignments/{id}` with only the fields to change; `assessmentIds` replaces the list.
  Send the `ETag` from `GET /assignments/{id}` as `If-Match` and a change someone made in between
  gives `412 precondition_failed` instead of being overwritten. `PATCH` and `PUT /people/…` take an
  `Idempotency-Key` too.
- **Archive:** `DELETE /assignments/{id}`. Attempts in progress can finish; results are kept.
- **Progress:** `GET /assignments/{id}/progress?status=NOT_STARTED,IN_PROGRESS&overdue=true`, one
  record per person: `status` (`NOT_STARTED`, `IN_PROGRESS`, `COMPLETED`), `passed` (across the
  certifications in it), `overdue`, and each assessment's result status.
- Unenrolling (`DELETE /enrollments/…`) someone who has the assessment through an assignment returns
  `409 enrollment_from_assignment`: remove them from the assignment instead.
- **Archived assignments:** `GET /assignments?includeArchived=true` lists them too, with `archivedAt`.

### People

Every person has a `personId` that survives an email change, on results, enrollments, progress and
`GET /people`.

- **Attributes:** `PUT /people/{personId or email}` with any of `team`, `externalId` (employee number,
  unique in the organisation), `managerEmail`, `department` and `location`; `null` clears one.
- **HRIS sync:** `POST /people:batch` with `{"people": [{"email": …, "managerEmail": …, "department": …}, …]}`,
  up to 1,000 at once. Reporting lines are set after everyone in the call exists.
- **Profile:** `GET /people/{personId}/profile` (scope `results:read`): latest level per skill across every
  assessment with the change since the level before, assignments with overdue flags, and certificates.
- **Email change:** `POST /people/{personId}:changeEmail` with `{"email": "new@example.com"}` moves
  everything to the new address; `personId` and every `resultId` stay.
- **Leaver:** `DELETE /people/{personId}` removes their enrollments and assignments (each `NOT_STARTED`
  record comes back as `REMOVED`); finished results stay. Staff are managed in the admin portal.
- **Erasure (GDPR, CCPA):** `POST /people/{personId}:erase`, with the `people:erase` scope (never given
  to API keys). Their attempts stay only as anonymous rows (`personId` null, `learnerEmail`
  `erased-<personId>@erased.invalid`), so totals don't change; then a `person.erased` webhook. Cannot be undone.
- **Question metadata:** `GET /assessments/{assessmentId}/questions` (scope `catalog:read`) lists each
  question's skill, objective, type, difficulty and status, for joining the answers export. Never the
  text or answer.

## 6. Read results

`GET /results` returns one record per person per attempt, plus a `NOT_STARTED` record for each
enrolled person who hasn't started. `updatedSince` is **required**.

```bash
curl -s "https://app.assess.creatium.com/api/v1/results?updatedSince=2026-09-20T00:00:00Z" \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

| Status | Meaning |
|---|---|
| `NOT_STARTED` | Enrolled, no attempt yet. When they start, the same `resultId` becomes their first attempt. |
| `IN_PROGRESS` | Started; `progressPercent` shows how far. |
| `AWAITING_RESULTS` | Submitted; the organisation releases results later. |
| `PASSED` / `FAILED` | Submitted and scored. |
| `REMOVED` | The enrollment was taken away before any attempt. Sent for 30 days under the `NOT_STARTED` record's `resultId`: delete your copy. |

Fields you will use most:

| Field | Meaning |
|---|---|
| `resultId` | Stable per attempt. Re-scoring updates the same record. |
| `personId` | The person; stays the same when their email changes. |
| `learnerEmail`, `learnerName` | Who took it (email in lower case). |
| `attemptNumber`, `isLatestAttempt`, `isBestAttempt` | Which attempt this is. |
| `score` | Percentage (0–100, one decimal), set when `PASSED` or `FAILED`. |
| `result` | `PASS` or `FAIL`. |
| `grade` | Overall band: the band of the lowest measured skill. |
| `proficiency[]` | Per skill: `level` (1–10), `band`, `confidence`, `targetLevel`, `met`. |
| `reportUrl` | The result's report page. After sign-in it opens for the person, your organisation's staff, and (when your policy allows) their managers. Print it for a PDF. |
| `team`, `managerEmail` | The person's team and manager. |
| `certificateUrl`, `certificateIssuedAt`, `certificateExpiresAt` | Public, verifiable credential page and its dates, once issued (`certificateExpiresAt` is null when it doesn't expire). |
| `lastUpdated` | Moves forward on every change, including when a newer attempt flips `isLatestAttempt` or `isBestAttempt`. Use it for incremental sync. |

**Levels and confidence.** Assessments are adaptive: questions follow the person's answers, and an
attempt ends once each skill's level is established. A skill's `level` is only given when Creatium is
at least 80% sure (`confidence` ≥ 0.8). Otherwise `level` and `band` are `null`: there wasn't enough
evidence. Show that as "not enough evidence", not as a low level.

Bands on the 1–10 scale: Foundational 1–3, Intermediate 4–5, Advanced 6–8, Expert 9–10.

**When a record changes:** the attempt starts, progresses, is submitted, is re-scored (for example
after a person reviews an open answer, or a role play is scored after submission), or withheld
results are released. Records are available within 60 seconds.

**Incremental sync:** store the newest `lastUpdated` you received and send it as `updatedSince` next
time. For a backfill, send any past window with `updatedSince` and `updatedBefore`.

Filters: `learnerEmail`, `assessmentId`, `status` (comma-separated, e.g. `PASSED,FAILED`),
`latestOnly=true` (only each person's latest attempt per assessment), `updatedBefore`.

`GET /results/{resultId}` returns one record.

### Proctoring

When a designer turns proctoring on, each result carries `proctoring` (otherwise it is `null`):

```json
"proctoring": {
  "mode": "CAMERA_FULLSCREEN",
  "likelihood": 74,
  "level": "HIGH",
  "flags": [{ "kind": "PHONE", "count": 2, "seconds": 40 }, { "kind": "ANOTHER_PERSON", "count": 1, "seconds": 20 }],
  "verdict": "CONFIRMED",
  "reviewUrl": "https://designer.assess.creatium.com/designer/assessments/…/candidates/…"
}
```

- `likelihood` (0–100) and `level` (`LOW` under 25, `MEDIUM` 25–60, `HIGH` over 60) come from an AI model's readings of stills from the learner's camera and from what their browser noted (leaving the window or full screen, pasting). No image is kept. They are a reason for a person to look, not proof.
- `flags[].kind` also includes `NOTES_OR_DEVICE` and `OTHER` from the camera readings.
- `verdict` is a reviewer's decision (`CLEARED` or `CONFIRMED`, or `null` before review). **Act on the verdict, not the likelihood.** A new verdict fires `result.updated`.
- The answers export (`/analytics/answers`) has `proctoringLevel` on every row of a proctored attempt.

## 7. Pagination

List endpoints return a page and a cursor:

```json
{ "data": [ … ], "pagination": { "limit": 100, "hasMore": true, "nextCursor": "eyJ1Ijoi…", "totalCount": 348 } }
```

Repeat the same request with `cursor=<nextCursor>` until `hasMore` is `false`. `limit` is 1–200
(default 100). Records are ordered by their update time, so an interrupted run can resume from its
last cursor. Keep the same filters: a cursor used with other filters gives `400`.
`/analytics/assessments` returns everything unless you send `limit`.

## 8. Webhooks (optional)

| Event | When | `data` |
|---|---|---|
| `result.updated` | A result record changes | The Result |
| `assignment.completed` | A person finishes every assessment in an assignment | Their progress record |
| `enrollment.created` / `enrollment.removed` | Someone is enrolled, or an enrollment is taken away (any route) | `personId`, `learnerEmail`, `assessmentId`, … |
| `assignment.updated` / `assignment.archived` | An assignment's settings, assessments or members change, or it is archived | `assignmentId`, `name`, `updatedAt`, `archivedAt` |
| `assessment.updated` | A live assessment is published, changed or retired | `assessmentId`, `status`, `lastUpdatedDate` |
| `person.erased` | A person was erased | `personId`, `erasedAt`: delete what you hold about them |

Add an endpoint in **Admin → API → Webhooks**, or by API with the `webhooks:manage` scope:
`POST /webhook-endpoints` with `{"url", "events"}` returns its signing secret once;
`POST /webhook-endpoints/{endpointId}:test` sends a signed `webhook.test`;
`POST /webhook-endpoints/{endpointId}:rotateSecret` gives a new secret, and for 24 hours deliveries are
signed with both (two `v1` values). **Missed some?** `GET /events?createdSince=…` replays the last 30 days,
exactly as delivered.

### `result.updated`

Sent whenever a result record changes, with the same record as `GET /results` in `data`:

```json
{ "id": "3c1e…", "type": "result.updated", "createdAt": "2026-09-20T09:53:10Z", "data": { "resultId": "…", "status": "PASSED", … } }
```

Headers: `Creatium-Event-Id`, `Creatium-Event: result.updated`, and
`Creatium-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>`.

- **Verify** the signature on the raw body, and reject deliveries more than 5 minutes old.
- **Deduplicate** on `Creatium-Event-Id` (also `id` in the body).
- **Ordering is not guaranteed:** apply a record only if its `lastUpdated` is newer than the one you hold.
- **Retries:** any non-2xx response, or no response within 10 seconds, is retried after
  1 min, 5 min, 30 min, 2 h, 6 h and 12 h, for up to 24 hours. Reply 2xx quickly and process afterwards.
  Every attempt, retries included, is re-signed with a fresh timestamp, so the 5-minute check holds;
  the body and `Creatium-Event-Id` stay the same.

Verifying in Node.js:

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

// After a secret rotation the header briefly carries two v1 values: accept if any matches.
function verify(rawBody, header, secret) {
  const pairs = header.split(',').map((p) => p.split('='));
  const t = pairs.find(([k]) => k === 't')?.[1];
  if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
  const expected = Buffer.from(createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex'));
  return pairs.some(([k, v]) => k === 'v1' && v.length === expected.length && timingSafeEqual(expected, Buffer.from(v)));
}
```

Verifying in Python:

```python
import hmac, hashlib, time

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    pairs = [p.split("=", 1) for p in header.split(",")]
    t = next((v for k, v in pairs if k == "t"), None)
    if t is None or abs(time.time() - int(t)) > 300:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    # After a secret rotation the header briefly carries two v1 values: accept if any matches.
    return any(k == "v1" and hmac.compare_digest(expected, v) for k, v in pairs)
```

### `assignment.completed`

Sent once when a person finishes every assessment in an assignment. Signed, deduplicated and retried
exactly like `result.updated`; `data` is their progress record with `assignmentId` and `name`.

### `assessment.completed` (deprecated)

Deprecated, and stops on 31 January 2027: use `result.updated`. It is sent once when an attempt is
submitted, signed with `x-assess-signature: sha256=<hex HMAC-SHA256(secret, raw body)>` (no
timestamp), and delivered once without retries.

## 9. Analytics

For dashboards, BI tools (Power BI, Tableau, Looker) and data warehouses. Scope `analytics:read`.

### Which endpoint for what

| You want | Call | Size |
|---|---|---|
| A tile per assessment: enrolled, completed, share meeting the targets | `GET /analytics/assessments` | One record per assessment |
| One assessment in depth: skills, bands, teams, hardest questions, completions per day | `GET /analytics/assessments/{assessmentId}` | One record |
| The same, only for the people one assignment reaches | `GET /analytics/assessments/{assessmentId}?assignmentId=…` | One record |
| Your own analysis: every answer, joined to people, skills and questions | `GET /analytics/answers` | One record per answer, paged |
| Who passed, levels per skill, certificates | `GET /results` (scope `results:read`) | One record per attempt, paged |

The first two are computed for you, exactly as on the Designer's Analytics page. Each person counts
once, on their latest finished attempt. Rates are 0–1.

- **A period:** `from` and `to` (ISO dates) count only attempts finished in that window, e.g. this quarter against last.
- **Slices:** `groupBy=team|department|location|manager` on `/analytics/assessments/{assessmentId}` adds `groups`.
- **A part of the organisation:** `team`, `managerEmail` and `includeIndirect=true` narrow every analytics
  endpoint, `/results` and assignment progress.
- **Small groups:** a team or group with fewer finished attempts than your policy's `minimumGroupSize`
  (default 5) comes back `suppressed: true`, without rates.

### Managers and the organisation's policy

A client can get a token for one manager (token exchange, RFC 8693), e.g. when a manager opens your
dashboard:

```bash
curl -s https://app.assess.creatium.com/api/v1/oauth/token -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
  -d subject_token=maria.lopez@example.com \
  -d subject_token_type=urn:creatium:params:oauth:token-type:person
```

That token reads only (`catalog:read`, `results:read`, `analytics:read`), and only the manager's reports,
direct and indirect. Your organisation's admins choose, in **Admin → API → Policy**, what it may see:

| `managerAccess` | A manager's token gets |
|---|---|
| `none` | Nothing: the exchange is refused. |
| `aggregates` (default) | Analytics for their reports, suppressed when fewer than `minimumGroupSize` finished. |
| `individual` | Also their reports' results, progress, answers, profiles and report pages. |

The policy also says whether proctoring needs the `proctoring:read` scope and correct answers the
`analytics:answer-keys` scope. Read it at `GET /organization/policy`.

### How the data joins

| Key | Found in | Joins to |
|---|---|---|
| `assessmentId` | every endpoint | `/assessments` (title, skills, targets) |
| `resultId` | `/results`, `/analytics/answers` | one attempt: an answer's `resultId` is the result it belongs to |
| `questionId` | `/analytics/answers`, `questions[]` in `/analytics/assessments/{id}` | one question |
| `learnerEmail` (or `personId`) | `/results`, `/analytics/answers` | one person; `personId` is a stable anonymous ID (`C-…`) per organisation |
| `skill` / `skillId` | `/analytics/answers`, `proficiency[]` in `/results` | a skill of the assessment |

A star schema that works well: `answers` as the fact table; `results` (attempts), `assessments`, and
people as dimensions.

### Every answer: `GET /analytics/answers`

```bash
curl -s "https://app.assess.creatium.com/api/v1/analytics/answers?updatedSince=2026-09-01T00:00:00Z&limit=1000" \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

```json
{ "data": [ {
    "answerId": "a5c6…:8751…", "resultId": "a5c6…", "assessmentId": "6108…", "assessmentTitle": "Q3 sales data literacy cohort",
    "learnerEmail": "john.doe@example.com", "team": "Enterprise", "attemptStatus": "SUBMITTED", "language": "en",
    "questionId": "8751…", "questionNumber": 1, "kind": "knowledge", "type": "single-choice",
    "skill": "Reading dashboards", "skillId": "sk-dashboards", "objective": "Read revenue, deals and conversion from a dashboard",
    "bloom": "Apply", "difficulty": 2, "question": "The weekly revenue tile shows $412k…", "answer": "$448k", "correctAnswer": "$448k",
    "points": 1, "score": 1, "result": "Correct", "answeredAt": "2026-09-02T09:00:00Z",
    "startedDate": "2026-09-02T08:42:00Z", "completedDate": "2026-09-02T09:00:00Z", "lastUpdated": "2026-09-02T09:00:00Z" } ],
  "pagination": { "limit": 1000, "hasMore": true, "nextCursor": "eyJ1Ijoi…" } }
```

| Field | Meaning |
|---|---|
| `answerId` | Unique per answer (`<resultId>:<questionId>`). Upsert on it. |
| `attemptStatus` | `IN_PROGRESS`, `SUBMITTED` or `TIME_EXPIRED`. |
| `kind`, `type` | `knowledge`, `roleplay` or `project`; the question type (`single-choice`, `short-answer`…). |
| `bloom`, `difficulty` | The kind of thinking the question needs, and its level 1–10. |
| `answer`, `correctAnswer` | In words, as the person saw them. `correctAnswer` is empty for open answers, role plays and projects. |
| `points`, `score`, `result` | Points available and earned; `Correct`, `Partly correct`, `Wrong` or `Not scored` (not yet scored, or unanswered). |
| `lastUpdated` | When the attempt last changed. Use it for incremental sync. |

Filters: `updatedSince`, `updatedBefore`, `assessmentId`, `people=anonymous` (a stable `personId`
instead of the email, for sharing with analysts), `limit` (1–1000, default 500).

**CSV:** add `format=csv` for the same records as a CSV with those column names. The next page's
cursor is in the `X-Next-Cursor` response header (absent on the last page).

### Keeping a warehouse up to date

1. **First load:** request `/analytics/answers` and `/results` with an early `updatedSince`, following
   `nextCursor` until `hasMore` is `false`.
2. **Every hour or night:** request again with `updatedSince` = the newest `lastUpdated` you stored.
3. **Upsert** answers on `answerId` and results on `resultId`. A record comes back when it changes:
   an attempt progresses or is submitted, an open answer or role play is scored later, or withheld
   results are released.
4. Refresh `/analytics/assessments` for the computed tiles; it is always current.

### Power BI, Excel and other tools

- Tools that can send a header (Power BI's Web connector, Power Query, Tableau's Web Data Connector)
  use `Authorization: Bearer <token>`. Tokens last an hour, so for **scheduled refreshes** create an
  **API key** (Admin → API → API keys, never expires until revoked) and use it as the bearer token.
- For one-off analysis, `format=csv` opens directly in Excel or Google Sheets.
- Rate limit: 600 requests a minute; with `limit=1000`, that is up to 600,000 answers a minute.

## 10. Errors

Errors are [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem details
(`application/problem+json`):

```json
{ "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." }] }
```

Branch on `code`, not on `detail`. `type` links to the code's explanation in the
[API reference](https://app.assess.creatium.com/docs#errors).

| Status | `code` | What to do |
|---|---|---|
| 400 | `invalid_request` | Fix the request; `errors[]` names the fields. Also an ID in the path that isn't a UUID. |
| 401 | `invalid_token` | Get a new token. |
| 403 | `insufficient_scope` | The client lacks the scope; ask the admin to add it. |
| 404 | `not_found`, `assessment_not_found` | Wrong ID, a draft, or an unknown path. |
| 405 | `method_not_allowed` | The path exists but not with that method; `Allow` lists the ones it takes. |
| 409 | `idempotency_conflict` | Same `Idempotency-Key`, different body. |
| 409 | `enrollment_from_assignment` | The person has the assessment through an assignment: remove them from the assignment. |
| 412 | `precondition_failed` | `If-Match` no longer matches: read the record again, then retry. |
| 422 | `unprocessable` | Not allowed (for example an email outside the allowed domains). |
| 429 | `rate_limited` | Wait `Retry-After` seconds. |
| 500 / 503 | `internal_error`, `service_unavailable` | Retry with backoff (GETs are always safe to retry). |

The token endpoint uses OAuth's own error shape: `{ "error": "invalid_client", "error_description": "…" }`.

Every response has an `X-Request-Id` header. Quote it when you contact support.

## 11. Rate limits

600 requests a minute per client. Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and
`RateLimit-Reset` (seconds until the window resets). Over the limit you get `429` with `Retry-After`.

## Sandbox

On staging (`https://staging.app.assess.creatium.com/api/v1`) you get a sandbox organisation with
assessments to integrate against; ask support for one. Nobody has to take a test: simulate it.

```bash
curl -s https://staging.app.assess.creatium.com/api/v1/sandbox/attempts \
  -H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" \
  -d '{ "learnerEmail": "test.learner@example.com", "assessmentId": "5f1d6c1e-2a4b-4c8e-9f3a-7b6d2e1c0a91", "outcome": "passed" }'
```

`outcome` is `passed`, `failed` or `in_progress`; `levels` (`{"Negotiation": 7}`) sets skill levels
yourself. The result shows up in `/results` and your `result.updated` (and `assignment.completed`)
webhooks fire, exactly as for a real learner. Outside a sandbox organisation the endpoint returns `404`.

## A typical integration

1. **Hourly:** `GET /assessments?updatedSince=<last>` and update your catalog (hide `Retired`).
2. **In your catalog:** link each card to its `launchUrl`.
3. **Optionally:** `POST /enrollments` when someone is assigned in your platform, or
   `POST /assignments` to give a set of assessments to people and teams at once.
4. **Every few minutes, or on each `result.updated` webhook:** `GET /results?updatedSince=<last>` and
   update progress, completion, score and levels.

## Operations

| | |
|---|---|
| Timeouts | Calls normally answer in a second or two; the server allows up to 900 s. Use a 30 s client timeout and retry with backoff. |
| Network | No IP allow-listing needed in either direction. If your firewall allow-lists destinations, every `*.assess.creatium.com` host answers on `34.102.249.142`. Webhooks come from changing Google Cloud addresses: verify `Creatium-Signature`. Fixed outbound IPs on request. |
| Transport | TLS 1.2 or later; `http://` gets a `308` to `https://`; responses send `Strict-Transport-Security`. |
| Versioning | v1. Additions at any time; breaking changes only in `/api/v2`, with at least 90 days' notice, v1 running alongside. |
| Data residency | Stored and processed in the United States (Google Cloud, US). Other countries on request. |
| Retention | Results while the organisation is a customer, deleted 90 days after it leaves. Proctoring readings 90 days. `REMOVED` records and delivered events 30 days. Erasure on request at any time. |

## Changes

All of these went live on 1 October 2026. They add to v1; the ones marked **behaviour** change what
an existing client sees.

**1.6** Sandbox organisations on staging with `POST /sandbox/attempts`; `GET /health`; a 99.5% monthly
availability target and https://status.assess.creatium.com.

**1.5** Reporting lines (`managerEmail`, `department`, `location`; `POST /people:batch`); `team`,
`managerEmail`, `includeIndirect` filters; tokens for a manager (token exchange); the organisation's
policy (`GET /organization/policy`); analytics `from`/`to` and `groupBy`; `GET /people/{personId}/profile`;
scopes `analytics:answer-keys` and `proctoring:read`. **Behaviour:** `reportUrl` is now
`/report/{resultId}`, which opens for the person, staff and (by policy) their managers; analytics teams
with fewer finished attempts than `minimumGroupSize` (default 5) come back `suppressed: true` without rates.

**1.4** `GET /events` (30 days); `enrollment.created`, `enrollment.removed`, `assignment.updated`,
`assignment.archived`, `assessment.updated` webhooks; `/webhook-endpoints` with secret rotation and
tests; `launchUrl?context=&returnUrl=` and `launchContext` on results; `POST /oauth/revoke`;
`/client-secrets`. **Behaviour:** while an endpoint's secret is being rotated, `Creatium-Signature`
carries two `v1` values: accept the delivery if either matches (the samples in section 8 do).

**1.3** `personId` everywhere, `externalId`, `:changeEmail`, `DELETE /people/{personId}`, `:erase`
(scope `people:erase`); `POST /enrollments:batch`; `GET /assessments/{assessmentId}/questions`;
certificate dates; `/assignments?includeArchived=true`; stated retention. **Behaviour:** a
`NOT_STARTED` record's `resultId` is now a UUID, and the person's first attempt keeps it (`enr_…` IDs
still resolve); a new status `REMOVED` tells your sync an enrollment was taken away; an older attempt's
`lastUpdated` moves when a retake changes its `isLatestAttempt` or `isBestAttempt`.

**1.2** `enrollment:read`; `ETag`/`If-Match` on assignments; `Idempotency-Key` on `PATCH` and `PUT`;
paging on `/analytics/assessments`. **Behaviour:** a path ID that isn't a UUID gives `400` (was `404`);
a cursor used with other filters gives `400`; unknown paths and wrong methods return problem+json
`404`/`405`; `/oauth/token` without `grant_type` gives `invalid_request`; rate-limit headers and
`X-Request-Id` on every response, 401s included.

## Support

Through a shared Slack group or **support@creatium.com**. Include the `X-Request-Id` of the call in question.

- **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 page:** https://status.assess.creatium.com shows live and past availability (checked every five
  minutes) and any incident. Incidents are also posted in the Slack group and emailed to your technical contact.
- **Availability:** the production API is available 99.5% of each calendar month, excluding maintenance
  announced 48 hours ahead. `GET /health` needs no token and answers `200` when the API is up.
