# AI tools

Read these docs as Markdown, give them to a model with llms.txt, or connect an agent to the docs MCP server.

Every page here is also plain Markdown, for coding assistants and agents. None of these tools needs an API key, and none of them can read company data.

## Markdown pages

* Add `.md` to any page's address: [/quickstart.md](https://docs.kapiworks.com/quickstart.md). The home page is [/index.md](https://docs.kapiworks.com/index.md).
* Or ask for Markdown with `Accept: text/markdown`:

```sh
curl -H 'Accept: text/markdown' https://docs.kapiworks.com/guides/sync
```

* **Copy page**, at the top of every page, copies its Markdown.

## llms.txt

* [/llms.txt](https://docs.kapiworks.com/llms.txt) lists every page with a one-line description, following [llmstxt.org](https://llmstxt.org/).
* [/llms-full.txt](https://docs.kapiworks.com/llms-full.txt) holds every page in one file.
* The OpenAPI contract at [`https://api.kapiworks.com/v1/openapi.json`](https://api.kapiworks.com/v1/openapi.json) describes every operation for code generators.

## Docs MCP server

The docs have a read-only [MCP](https://modelcontextprotocol.io/) server at `https://docs.kapiworks.com/api/mcp`, over streamable HTTP. It needs no sign-in and keeps no session. It has four tools:

| Tool          | What it does                                             |
| ------------- | -------------------------------------------------------- |
| `list_pages`  | Lists every page with its address.                       |
| `get_page`    | Returns one page as Markdown, by its address.            |
| `search`      | Searches the docs.                                       |
| `get_openapi` | Returns the OpenAPI contract these docs were built from. |

To add it to Claude Code:

```sh
claude mcp add --transport http kapi-works-docs https://docs.kapiworks.com/api/mcp
```

Other clients take the same address as a streamable HTTP server.


# Authentication

Company API keys, sent from your server over HTTPS in the Authorization header.

Every call sends a company API key as a bearer token, over HTTPS:

```http
GET /v1/training-records?limit=200 HTTP/1.1
Host: api.kapiworks.com
Authorization: Bearer kw_live_…
Accept: application/json
```

This is a company API key, not an OAuth access token. The key decides the company: addresses take no company selector, and a key reads only the company that created it.

## Keep the key on your server

The API is for your server, not for browsers. It sends no CORS headers, and it reads the key only from the `Authorization` header. Browser applications, cookies and keys in web addresses are unsupported.

Send the key only to the API host. Keep it in your secret manager, out of source code, command arguments and logs. The [request samples](https://docs.kapiworks.com/quickstart#3-make-the-first-call) show how for curl, Node and Python.

A key sent in a web address is refused, and the key is revoked at once, because web addresses end up in logs and browser history. The company's owners and admins are told, and the call answers [`key_in_url`](https://docs.kapiworks.com/errors/key_in_url). Ask for a new key and send it only in the header.

## What a key looks like

A key is `kw_live_` (production) or `kw_test_` (Kapi's other environments), then 43 letters, digits, hyphens or underscores, a dot, and an 8-character checksum. The API checks the checksum first, so a mistyped key is refused straight away.

Kapi stores a SHA-256 hash of each key and its last four characters, not the key itself. Works Studio shows the key once, when it is created.

## What the API checks on every call

1. The key is genuine, active and not expired. If not, the call answers [`invalid_token`](https://docs.kapiworks.com/errors/invalid_token). Unknown, expired and revoked keys all get this same answer.
2. If the key is limited to networks, the call comes from one of them. If not, [`ip_not_allowed`](https://docs.kapiworks.com/errors/ip_not_allowed).
3. The key has the [scope](https://docs.kapiworks.com/scopes) the endpoint needs. If not, [`insufficient_scope`](https://docs.kapiworks.com/errors/insufficient_scope), and the `WWW-Authenticate` header names the scope it needs.

Revocation takes effect on the next call. Each response carries an `X-Request-Id`; quote it when you [contact support](https://docs.kapiworks.com/support), and do not send the key.

## HTTP methods

The API is read-only. Every endpoint answers `GET` and `HEAD`; `HEAD` returns the same status and headers without a body. Other methods answer [`method_not_allowed`](https://docs.kapiworks.com/errors/method_not_allowed).


# Changelog

Changes to the Kapi Works API and these docs, newest first.

Each entry names the contract revision it brought. Follow the [RSS feed](https://docs.kapiworks.com/changelog/rss.xml) to hear about new entries.

## API 1.2: live sessions on certificates

*12 October 2026*

Certificates finished at a Kapi live session say when, how long and who taught it.

Contract revision 1.2.0. Clients that follow the 1.1 contract keep working.

**New**

* Certificates finished by attending a Kapi live session in full carry the session: `live_session_id`, `live_session_format`, `live_session_starts_at`, `live_session_ends_at`, `live_session_minutes` and `live_session_marked_by`. With `people:read` they also carry `trainer_name`. See [Training evidence](https://docs.kapiworks.com/guides/training-evidence).
* Records packs add the same columns to the certificate register.

## API 1.1 and the developer docs

*10 October 2026*

A snapshot check, rate-limit headers, safer keys, error pages and these docs.

Contract revision 1.1.0. Clients that follow the 1.0 contract keep working, except that a cursor from 1.0 is refused: start a new pull.

**New**

* [`GET /v1/snapshot`](https://docs.kapiworks.com/reference/get-snapshot) answers "has anything changed?" in one call, with any scope.
* Draft IETF `RateLimit` and `RateLimit-Policy` headers on each accepted call, and `Retry-After` on 429 and 503 that says exactly how long to wait.
* `HEAD` on every endpoint.
* Keys can be limited to networks, and owners and admins are told when a key is used from a new network.
* Error `type` links to the code's page on these docs, and new codes [`key_in_url`](https://docs.kapiworks.com/errors/key_in_url), [`ip_not_allowed`](https://docs.kapiworks.com/errors/ip_not_allowed) and [`upstream_unavailable`](https://docs.kapiworks.com/errors/upstream_unavailable).
* Example responses for every operation, and tags in the contract.
* These docs, with Markdown pages, llms.txt and a docs MCP server. `/v1/docs` on the API now redirects here.
* A [sandbox company](https://docs.kapiworks.com/guides/sandbox) with fictional people, for building an integration before you connect a real company.
* When Kapi erases a person at the company's instruction, the people register shows `access_state` `erased` and open records end with `source_removed`. See [Training evidence](https://docs.kapiworks.com/guides/training-evidence).
* While a company's contract is ending, answers carry a `Sunset` header with the day Kapi deletes the company's data, and a `Link` to [When a company's contract ends](https://docs.kapiworks.com/guides/company-ends).

**Changed**

* Calls the API refuses no longer count against the rate limits.
* A key sent in a web address is revoked, and its company is told.
* Creating a key needs two-step sign-in.
* A `limit` above 200 is treated as 200 instead of being refused.
* Timeouts answer 504 with `read_timeout`, and an unreachable records service 503 with `upstream_unavailable`.
* Responses carry only the fields the contract publishes, and fields that list values are open, so new values can appear.
* Cursors are sealed. Cursors from 1.0 are refused with `invalid_cursor`.

## Kapi Works API 1.0

*8 October 2026*

Read employee training evidence from your own server, with company API keys and an OpenAPI 3.1 contract.

The first release of the read-only Kapi Works API, contract revision 1.0.1:

* Five registers: training records at a moment, training records over a period, certificates, people and courses.
* Company API keys created by owners and admins in Works Studio, with the scopes `training:read`, `certificates:read` and `people:read`, an expiry of at most one year and at most ten active keys a company.
* Pinned snapshots and cursors, so every page of a pull agrees.
* Errors as RFC 9457 problem details with stable codes, and limits of 60 calls a minute for each key, 120 for each company and 2 at once for each company.
* The contract at `/v1/openapi.json`.


# Errors

Every error code the API returns, with what it means and what your integration should do.

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

```json
{
  "type": "https://docs.kapiworks.com/errors/invalid_token",
  "title": "Invalid API key",
  "status": 401,
  "detail": "The API key is invalid or no longer active.",
  "code": "invalid_token"
}
```

Act on `code`, or on `type`, which is the address of the code's page here. They are stable. `title` and `detail` are for people and can change, so do not parse them.

## Every code

| Status | Code | What to do |
| --- | --- | --- |
| 400 | [`invalid_request`](https://docs.kapiworks.com/errors/invalid_request) | Fix the query and send it again. Unknown or repeated parameters, malformed dates and IDs, and periods longer than 400 days are refused. |
| 400 | [`invalid_cursor`](https://docs.kapiworks.com/errors/invalid_cursor) | Start the pull again without a cursor. A cursor works only on the endpoint and filters that produced it, for the same company. |
| 400 | [`key_in_url`](https://docs.kapiworks.com/errors/key_in_url) | A URL can be stored in logs and browser history. Ask the company administrator for a new key, and send it only in the Authorization header. |
| 401 | [`invalid_token`](https://docs.kapiworks.com/errors/invalid_token) | Stop polling and ask the company administrator for a new key. Unknown, expired and revoked keys get the same answer. |
| 403 | [`insufficient_scope`](https://docs.kapiworks.com/errors/insufficient_scope) | Ask the company administrator for a key with the scope named in the WWW-Authenticate header. Retrying the same key does not help. |
| 403 | [`ip_not_allowed`](https://docs.kapiworks.com/errors/ip_not_allowed) | Call from one of the key's networks, or ask the company administrator for a key that allows yours. |
| 404 | [`not_found`](https://docs.kapiworks.com/errors/not_found) | Use a documented path on the API host. |
| 405 | [`method_not_allowed`](https://docs.kapiworks.com/errors/method_not_allowed) | The API is read-only. The Allow header lists the methods it accepts. |
| 422 | [`before_history`](https://docs.kapiworks.com/errors/before_history) | Use history_complete_from as the earliest moment you ask for. Earlier records are unavailable, not empty. |
| 422 | [`record_too_large`](https://docs.kapiworks.com/errors/record_too_large) | Use a records pack for this register, and report the request ID to Kapi support. |
| 429 | [`rate_limited`](https://docs.kapiworks.com/errors/rate_limited) | Wait at least Retry-After seconds, then add a small random delay. Refused calls do not count against the limit. |
| 429 | [`concurrent_limit`](https://docs.kapiworks.com/errors/concurrent_limit) | Wait for the running calls to finish, then retry after Retry-After seconds. Run one pull at a time per company. |
| 502 | [`read_failed`](https://docs.kapiworks.com/errors/read_failed) | Keep your last complete pull. Retry with exponential backoff, and report the request ID if it keeps failing. |
| 503 | [`unavailable`](https://docs.kapiworks.com/errors/unavailable) | Contact Kapi support with the request ID. Retrying does not help until the configuration is fixed. |
| 503 | [`upstream_unavailable`](https://docs.kapiworks.com/errors/upstream_unavailable) | Retry after Retry-After seconds with exponential backoff and jitter. Keep your last complete pull meanwhile. |
| 503 | [`writer_unavailable`](https://docs.kapiworks.com/errors/writer_unavailable) | Retry after Retry-After seconds. A pull already in progress can still finish from its cursor. |
| 504 | [`read_timeout`](https://docs.kapiworks.com/errors/read_timeout) | Retry with a smaller limit, then return to your usual page size. |

## Headers that come with errors

* `X-Request-Id` on every error. Quote it when you [contact support](https://docs.kapiworks.com/support), without the key.
* `WWW-Authenticate` on 401 and 403 scope refusals. A scope refusal names the scope it needs.
* `Retry-After` on 429 and 503: wait at least that many seconds.
* `Allow` on 405, listing `GET, HEAD`.

## Retrying

Retry connection failures and statuses 429, 502, 503 and 504, at most five times, with delays such as 1, 2, 4, 8 and 16 seconds plus a little random jitter. `Retry-After` takes precedence over your own delay. Use a timeout on every request, and when retries stop, report a failed sync instead of an empty result.

Do not retry 400, 401, 403, 404, 405 or 422 without changing something: the same request gets the same answer. Retrying a `GET` is safe, because reading creates no records.


# When a company's contract ends

The Sunset header, what keeps working until the deletion date, and what to copy before it.

When a company's contract with Kapi Works ends, Kapi keeps its data for an export window, usually 30 days, and then deletes it. The API gives your integration the deletion date as soon as the end is set.

## The Sunset header

From the moment Kapi sets a company's contract end date, answers to its keys carry two headers, following [RFC 8594](https://www.rfc-editor.org/rfc/rfc8594):

```http
Sunset: Wed, 30 Dec 2026 23:59:59 GMT
Link: <https://docs.kapiworks.com/guides/company-ends>; rel="sunset"
```

`Sunset` is the day Kapi deletes the company's data. `Link` points to this page. Look for them on successful answers. Some errors carry them too, but a `401` and [`ip_not_allowed`](https://docs.kapiworks.com/errors/ip_not_allowed) do not.

The headers are about this company's data, not about the API. Other companies and the `/v1` contract carry on as before.

Kapi staff can move the end or the deletion date, or cancel the end. The headers follow: the date changes, or the headers go away. Read them on every call, not once.

```js
// After any call to the API.
const sunset = response.headers.get("sunset");
if (sunset) {
  // Tell whoever runs this integration, and copy what the company asked you to keep before this date.
  console.warn(`Kapi Works deletes this company's data on ${new Date(sunset).toISOString()}.`);
}
```

## Until the deletion date

Until the contract ends, nothing changes but the headers.

After the contract ends, until the `Sunset` date:

* Existing keys keep reading, with the same scopes and limits.
* The company can no longer create keys. If your key expires before the `Sunset` date, ask the company for a new one before the contract ends.
* The company's owners and admins can still revoke keys. Revocation takes effect on the next call.

## When the date comes

Kapi deletes the company's keys first, then all of its records:

* Calls with its keys answer `401` [`invalid_token`](https://docs.kapiworks.com/errors/invalid_token), as for any revoked key.
* Its certificate check pages show nothing, as for a code that does not exist.

If Kapi holds the deletion, the API keeps answering after the date, and `Sunset` shows a date in the past. When the hold ends, Kapi sets a new deletion date at least 72 hours ahead, and the headers show it.

## What to do

1. Check every answer for `Sunset`, and tell whoever runs the integration when it appears.
2. Ask the company what to keep and for how long. The company decides that, not Kapi.
3. Before the `Sunset` date, make a [complete pull](https://docs.kapiworks.com/guides/sync) of what the company asked you to keep.
4. After the date, stop calling and remove the company's keys from your systems.


# Example app

A runnable Node 24 receiving server that pulls all five registers safely, with its full source.

This example is a receiving server's scheduled pull. It uses only Node 24's standard library and the public contract: no Kapi code, no npm packages and no database credentials. It is a pattern to copy, not a connector for a particular vendor.

## Set it up

1. In Works Studio, open **Settings → API keys** and create a key for the receiving system with `training:read`, `people:read` and `certificates:read`.
2. Store the key in your server's secret manager as `KAPI_WORKS_API_KEY`. Keep it out of source code, browser code, command arguments and logs.
3. Save the source below as `sync.mjs`, and make a `connection.json` with the API base URL and the company and deployment you expect. Confirm both IDs with the company administrator when you set up the connection; the first training request returns them in its `snapshot`. Keep a separate configuration and output folder for each company.

```json
{
  "baseUrl": "https://api.kapiworks.com/v1",
  "organizationId": "<verified company UUID>",
  "deploymentId": "<verified deployment UUID>"
}
```

Run it with the key already in the environment:

```sh
node sync.mjs connection.json /private/harbourline/latest.json
```

The example refuses plain HTTP to any host except Kapi's local development API, and it does not follow redirects with the key.

Optional settings in `connection.json`:

* `limit`: 1 to 200, default 200. Use 2 to exercise paging with a small company.
* `asAt`: a timestamp or date for every register. Leave it out to read the current moment. A fixed `asAt` always pulls in full.
* `refreshHours`: the longest gap between full pulls when nothing has changed. Default 24.
* `from`: the start of the people and period training registers. It defaults to `history_complete_from` from the first training pull. Once history is longer than 400 days, set a start within that limit; an unsupported period fails without replacing the previous report.

## What it does

**It checks for changes first.** Before pulling again, it asks [`/v1/snapshot`](https://docs.kapiworks.com/reference/get-snapshot) whether anything changed. If `sequence` and `capture_sequence` match the last complete pull, and that pull is less than `refreshHours` old, it keeps `latest.json` and stops after one call. Outcomes that depend on the clock, such as `not_due_yet` becoming `overdue`, can change without new facts, so a full pull still runs at least daily.

**It pulls safely.** Pages are fetched one at a time, paced below the per-key limit, and deduplicated by each row's stable ID. Every page has to match the configured company and the pull's first snapshot, and every register has to finish with the number of rows it advertised. Registers can come from different snapshots; use a records pack when you need one snapshot across all of them.

**It replaces the report only after a complete pull.** It writes a private temporary file and renames it over `latest.json`, so a failed run keeps the previous report. Repeated pulls replace rows instead of adding duplicates. A lock file next to the output refuses overlapping runs; after a killed process, check it has stopped before you remove a stale lock.

**It retries what is worth retrying.** Each request times out after 30 seconds. Connection failures and statuses 429, 502, 503 and 504 retry at most five times, with exponential backoff and jitter. `Retry-After` takes precedence, and a delay of more than five minutes stops the run so your scheduler can try later. 401, 403 and query errors stop at once. The command exits with a non-zero status on failure, so a scheduler can alert someone; it sends no notifications itself.

**It summarises without judging.** The output holds all five registers, each register's snapshot and page count, the request IDs, the time of the last successful sync and a summary for each employee. Accepted invitations stay separate rows and are not counted again as employees. The summary keeps Kapi's outcomes and times: it does not work out a second training status or turn completion into an exam score. Earlier completions, waivers, exceptions, unknown outcomes and certificates that are not valid are marked for review, and so is `no_required_records`. The example has no HR roster of its own, so compare its employee IDs with yours to find people who are missing from Kapi.

Certificates need a check address. The API gives `check_code`; build the address as `https://www.kapiworks.com/verify/<check_code>`.

The example keeps whole registers in memory. For much larger imports, load the pages into staging tables in your database and swap them in one transaction, keeping the same rule: every page first, then publish.

## Source

```js title="sync.mjs"
#!/usr/bin/env node
// Standalone receiving-system example. Uses only Node's standard library and the public /v1 contract.
import http from 'node:http';
import https from 'node:https';
import {mkdir, open, readFile, rename, rm, writeFile} from 'node:fs/promises';
import {dirname, resolve} from 'node:path';
import {pathToFileURL} from 'node:url';
import {setTimeout as delay} from 'node:timers/promises';
import {randomUUID} from 'node:crypto';

const completed = new Set(['completed_on_time', 'completed_late']);
const outcomes = new Set([...completed, 'completed_before_requirement', 'not_due_yet', 'overdue', 'missed',
  'excused', 'waived', 'optional_not_done']);
const tally = (rows, field) => rows.reduce((counts, row) => {
  counts[row[field]] = (counts[row[field]] ?? 0) + 1;
  return counts;
}, Object.create(null));

function readJson(url, key) {
  return new Promise((resolveResponse, reject) => {
    const local = url.hostname === 'api.localhost';
    const request = (url.protocol === 'https:' ? https : http).request(url, {
      method: 'GET', headers: {Authorization: `Bearer ${key}`, Accept: 'application/json'},
      // Equivalent to the guide's curl --resolve, without changing the computer's hosts file.
      ...(local ? {lookup: (_host, _options, done) => done(null, [{address: '127.0.0.1', family: 4}])} : {}),
    }, response => {
      const chunks = [];
      let size = 0;
      response.on('data', chunk => {
        size += chunk.length;
        if (size > 3 * 1024 * 1024) request.destroy(new Error('Response exceeds the documented 3 MiB limit'));
        else chunks.push(chunk);
      });
      response.on('error', () => {clearTimeout(timer); reject(new Error('API response was interrupted'));});
      response.on('end', () => {
        clearTimeout(timer);
        try {
          resolveResponse({status: response.statusCode, headers: response.headers,
            body: JSON.parse(Buffer.concat(chunks).toString('utf8'))});
        } catch { reject(new Error('API returned invalid JSON')); }
      });
    });
    const timer = setTimeout(() => request.destroy(new Error('API request timed out')), 30_000);
    request.on('error', () => {clearTimeout(timer); reject(new Error('API connection failed or timed out'));});
    request.end();
  });
}

export function summarize(registers) {
  const {training, people, certificates, courses} = registers;
  const releases = new Map(courses.data.map(row => [row.course_release_id, row.course_title]));
  const employees = people.data.filter(row => row.entry_type === 'employee').map(person => {
    const records = training.data.filter(row => row.learner_profile_id === person.learner_profile_id);
    const issued = certificates.data.filter(row => row.learner_profile_id === person.learner_profile_id);
    const review = new Set(person.no_required_records ? ['no_required_records'] : []);
    for (const row of records) {
      if (!outcomes.has(row.outcome)) review.add('unknown_outcome');
      if (['waived', 'excused', 'completed_before_requirement'].includes(row.outcome)) review.add(row.outcome);
    }
    for (const row of issued) if (row.status !== 'valid') review.add(`${row.status}_certificate`);
    return {personId: person.learner_profile_id, name: person.person_name, access: person.access_state,
      completed: records.filter(row => completed.has(row.outcome)).length,
      outcomes: tally(records, 'outcome'), review: [...review],
      training: records.map(row => ({recordId: row.record_id, courseId: row.course_id,
        course: releases.get(row.course_release_id) ?? row.course_id, requirement: row.requirement,
        outcome: row.outcome, dueAt: row.due_at ?? null, completedAt: row.completed_at ?? null}))};
  });
  return {rosterCoverage: 'Kapi employees only; reconcile against your authoritative employee roster.',
    training: tally(training.data, 'outcome'), certificates: tally(certificates.data, 'status'),
    invitations: people.data.filter(row => row.entry_type === 'invitation').length, employees};
}

export async function pullCompany(config, {key = process.env.KAPI_WORKS_API_KEY, pause = delay, previous = null} = {}) {
  const base = new URL(config.baseUrl);
  if (base.protocol !== 'https:' && !(base.protocol === 'http:' && base.hostname === 'api.localhost')) {
    throw new Error('Use HTTPS; only api.localhost permits HTTP for local development');
  }
  if (base.username || base.password || base.search || base.hash || !/^\/v1\/?$/.test(base.pathname)) {
    throw new Error('baseUrl must be the API origin followed by /v1, without credentials or query parameters');
  }
  if (!key || /[\r\n]/.test(key)) throw new Error('Set KAPI_WORKS_API_KEY in the receiving server environment');
  const uuid = /^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}$/i;
  if (!uuid.test(config.organizationId) || !uuid.test(config.deploymentId)) {
    throw new Error('Configure the expected organizationId and deploymentId for this connection');
  }
  const limit = config.limit ?? 200;
  if (!Number.isInteger(limit) || limit < 1 || limit > 200) throw new Error('limit must be 1 to 200');
  const calls = [];
  async function get(path, parameters) {
    const url = new URL(base.href.replace(/\/$/, '') + path);
    url.search = new URLSearchParams(parameters).toString();
    for (let attempt = 0; attempt <= 5; attempt++) {
      // One sequential caller stays below the per-key limit. 429 still handles other clients in the company.
      await pause(1100);
      let response;
      try { response = await readJson(url, key); }
      catch (error) {
        if (attempt === 5) throw error;
        await pause(1000 * 2 ** attempt + Math.random() * 250);
        continue;
      }
      const {status, headers, body} = response;
      const requestId = String(headers['x-request-id'] ?? 'unavailable').replace(/[^a-zA-Z0-9-]/g, '').slice(0, 80);
      calls.push({endpoint: path, status, requestId});
      if (status === 200) return body;
      const code = /^[a-z_]+$/.test(body.code) ? body.code : 'unexpected_response';
      const error = new Error(`HTTP ${status} ${code}; request ID ${requestId}`);
      if (attempt === 5 || ![429, 502, 503, 504].includes(status)) throw error;
      const retryAfter = headers['retry-after'];
      const seconds = retryAfter && /^\d+$/.test(retryAfter) ? Number(retryAfter)
        : Math.max(0, (Date.parse(retryAfter ?? '') - Date.now()) / 1000) || 0;
      if (seconds > 300) throw new Error(`${error.message}; reschedule after Retry-After (${seconds}s)`);
      await pause(Math.max(1000 * 2 ** attempt, seconds * 1000) + Math.random() * 250);
    }
  }

  async function pull(path, id, parameters = {}) {
    const rows = new Map(), cursors = new Set();
    let cursor = null, snapshot, total, signature, pages = 0;
    do {
      const body = await get(path, cursor === null ? {limit, ...parameters} : {limit, cursor});
      const s = body.snapshot;
      if (!s || s.organization_id !== config.organizationId || s.deployment_id !== config.deploymentId) {
        throw new Error(`${path}: company or deployment does not match this connection`);
      }
      if (!Number.isSafeInteger(s.sequence) || !Number.isSafeInteger(s.capture_sequence) ||
          typeof s.chain_hash !== 'string' || !Array.isArray(body.data) || !Number.isSafeInteger(body.total) || body.total < 0 ||
          !(body.next_cursor === null || (typeof body.next_cursor === 'string' && body.next_cursor.length > 0))) {
        throw new Error(`${path}: invalid page envelope`);
      }
      const nextSignature = JSON.stringify([s.sequence, s.capture_sequence, s.chain_hash, s.as_at, s.from,
        s.known_at, s.logic?.version, s.logic?.date, s.history_complete_from]);
      if (pages && (signature !== nextSignature || total !== body.total)) throw new Error(`${path}: snapshot changed during paging`);
      snapshot = s; signature = nextSignature; total = body.total; pages++;
      for (const row of body.data) {
        if (typeof row[id] !== 'string' || !row[id] ||
            (row.organization_id && row.organization_id !== config.organizationId) ||
            (row.deployment_id && row.deployment_id !== config.deploymentId)) throw new Error(`${path}: invalid row identity`);
        rows.set(row[id], row);
      }
      cursor = body.next_cursor;
      if (cursor !== null && cursors.has(cursor)) throw new Error(`${path}: repeated cursor`);
      cursors.add(cursor);
    } while (cursor !== null);
    if (rows.size !== total) throw new Error(`${path}: received ${rows.size} unique rows, expected ${total}`);
    return {snapshot, pages, total, data: [...rows.values()]};
  }

  // One call answers "has anything changed?". Facts can still age without a change (not_due_yet becomes overdue),
  // so a full pull still runs at least every refreshHours (default 24).
  const last = previous?.registers?.training?.snapshot;
  if (last && !config.asAt && Date.now() - Date.parse(previous.syncedAt) < (config.refreshHours ?? 24) * 3_600_000) {
    const {snapshot} = await get('/snapshot', {});
    if (snapshot?.organization_id === config.organizationId && snapshot?.deployment_id === config.deploymentId &&
        snapshot.sequence === last.sequence && snapshot.capture_sequence === last.capture_sequence) {
      return {...previous, unchanged: true, checkedAt: new Date().toISOString(), calls};
    }
  }
  const training = await pull('/training-records', 'record_id', config.asAt ? {as_at: config.asAt} : {});
  const span = {from: config.from ?? training.snapshot.history_complete_from, to: training.snapshot.as_at};
  const registers = {training,
    period: await pull('/training-records/period', 'record_id', span),
    people: await pull('/people', 'person_id', span),
    certificates: await pull('/certificates', 'certificate_id', {as_at: training.snapshot.as_at}),
    courses: await pull('/courses', 'row_id')};
  return {schema: 'kapi.works.example-sync/v1', syncedAt: new Date().toISOString(),
    organizationId: config.organizationId, deploymentId: config.deploymentId,
    registers, summary: summarize(registers), calls};
}

export async function syncToFile(config, output, options) {
  await mkdir(dirname(output), {recursive: true, mode: 0o700});
  const lock = await open(output + '.lock', 'wx', 0o600);
  const temporary = output + '.' + randomUUID() + '.tmp';
  try {
    const previous = await readFile(output, 'utf8').then(JSON.parse).catch(() => null);
    const report = await pullCompany(config, {...options, previous});
    // An unchanged snapshot keeps the last complete pull exactly as it was saved.
    if (report.unchanged) return report;
    await writeFile(temporary, JSON.stringify(report, null, 2) + '\n', {mode: 0o600, flag: 'wx'});
    await rename(temporary, output);
    return report;
  } finally {
    await rm(temporary, {force: true});
    await lock.close();
    await rm(output + '.lock', {force: true});
  }
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  try {
    const [configPath, outputPath] = process.argv.slice(2);
    if (!configPath || !outputPath) throw new Error('Usage: node sync.mjs connection.json /private/company/latest.json');
    const report = await syncToFile(JSON.parse(await readFile(configPath, 'utf8')), resolve(outputPath));
    console.log(JSON.stringify({syncedAt: report.syncedAt, unchanged: Boolean(report.unchanged), employees: report.summary.employees.length,
      invitations: report.summary.invitations, training: report.summary.training, certificates: report.summary.certificates}));
  } catch (error) { console.error(error.message); process.exitCode = 1; }
}

```


# The five registers

What each register returns, at a moment or over a period, and how dates and the company's time zone work.

The API has five registers of employee evidence, plus a snapshot check. All of them answer `GET` with JSON.

| Endpoint | Returns | Scope |
| --- | --- | --- |
| [`GET /v1/training-records`](https://docs.kapiworks.com/reference/list-training-records) | Training records at a moment | `training:read` |
| [`GET /v1/training-records/period`](https://docs.kapiworks.com/reference/list-period-training-records) | Every training record owed during a period | `training:read` |
| [`GET /v1/certificates`](https://docs.kapiworks.com/reference/list-certificates) | Certificates at a moment | `certificates:read` |
| [`GET /v1/people`](https://docs.kapiworks.com/reference/list-people) | Employees and invitations during a period | `people:read` |
| [`GET /v1/courses`](https://docs.kapiworks.com/reference/list-courses) | Course versions referenced by employee records | `training:read` |
| [`GET /v1/snapshot`](https://docs.kapiworks.com/reference/get-snapshot) | Check whether anything changed | Any |

## At a moment, or over a period

**At a moment.** Training records and certificates accept `as_at`: the register as it stood then. Leave it out to read the register now.

**Over a period.** Period training records and people need `from` and `to` on the first page of a pull. A period is at most 400 days long. Period training lists every record owed at any time in the period, with its outcome at the end of the period or when the record ended. People lists the employees and invitations that existed in the period.

The cursor keeps the dates of the first call, so send `from`, `to` and `as_at` only on the first page.

Training endpoints also accept `course`, a course UUID, to read one course. Courses list the published course versions that employee records refer to, with their content fingerprints.

## Dates and the company's time zone

Prefer RFC 3339 timestamps with an explicit offset, such as `2026-10-09T09:00:00Z`. A date on its own is read in the company's time zone: `from` starts at the beginning of that day, and `as_at` and `to` end at the end of it.

## Where history starts

Kapi records history from `history_complete_from`, which every snapshot carries. Earlier records are unavailable, not empty: asking for a moment or period before it answers [`before_history`](https://docs.kapiworks.com/errors/before_history), with `history_complete_from` in the body. Use that time as the earliest you ask for.

## The response

Every register answers with the same envelope:

* `data`: the rows of this page;
* `total`: the rows in the whole pull, not just this page;
* `snapshot`: the pinned snapshot the pull reads, with the company and deployment it belongs to;
* `next_cursor`: send it to get the next page; null when the pull is complete.

Rows hold only the fields the contract publishes. v1 can add fields and new values for open fields such as `outcome`; ignore fields you do not know, and see [Versioning](https://docs.kapiworks.com/versioning) for the rest. The [field dictionary](https://docs.kapiworks.com/reference/field-dictionary) explains every field.


# Sandbox company

A pretend company on the live system, full of fictional people, for building and testing an integration.

Build your integration against the sandbox company before you connect a real one. It runs on the live system with the same address, limits and code as every company, so only the key changes when you go live.

## Get a key

Email [support@kapiworks.com](mailto:support@kapiworks.com) with your company, the integration you are building and the engineers who need access. Kapi staff issue one key per engineer, valid for 90 days, with the scopes you need. Keys on the live system start with `kw_live_`, sandbox keys included. Kapi also tells you the sandbox's organisation ID: it is the only thing that tells the sandbox apart from a real company, so check `snapshot.organization_id` against it.

## What is in it

Larkspur Freight Sandbox is a fictional logistics company. Every person in it is invented, and every address ends in `@larkspur-freight-sandbox.example.com`.

* **People**: 36 employees in three teams (Warehouse, Drivers, Office), invitations that are pending, expired and cancelled, two people who left, and one person who was erased.
* **Training**: more than 200 live training records, so `/v1/training-records` needs two pages at `limit=200`. Between them they show every outcome: `completed_on_time`, `completed_late`, `completed_before_requirement`, `not_due_yet`, `overdue`, `waived` and `optional_not_done`. `missed` and `excused` appear in [`/v1/training-records/period`](https://docs.kapiworks.com/reference/list-period-training-records) with `from` set to `history_complete_from`.
* **Certificates**: valid, revoked and replaced certificates, and site passes that expire a day after they are issued.
* **Erasure**: one person reads "Erased person", with `access_state` `erased`, as described in [Training evidence](https://docs.kapiworks.com/guides/training-evidence).

History starts when the sandbox was created. Asking for an earlier moment answers [`before_history`](https://docs.kapiworks.com/errors/before_history).

## It keeps changing

The sandbox changes on its own, so your sync sees new facts:

* each day, a new invitation whenever none is waiting;
* every four weeks, a new course for every team, due 30 days later. These records start `not_due_yet` and become `overdue` if nobody completes them.

Use [`/v1/snapshot`](https://docs.kapiworks.com/reference/get-snapshot) to see when something changed.

## What it does not do

The sandbox sends no email at all: no invitations, reminders or key notices. Treat its data like any company's data anyway, and keep it apart from real companies' data.


# Keeping data in sync

Pull every page of a snapshot, check it belongs to the right company, and replace your copy only after a complete pull.

A pull reads one register from its first page to its last. Every page of a pull comes from the same pinned snapshot of the company's records, so the pages agree with each other even while people keep completing training.

## Pull a register

1. Start a pull without a cursor, with a `limit` of up to 200. Keep the `snapshot` with the result.
2. While `next_cursor` is not null, call the same endpoint with that cursor and your page size. The cursor carries the company, deployment, dates and filters of the first call. Send it unchanged to the same endpoint. Cursors are sealed: an edited cursor, or one from another company or endpoint, is refused with [`invalid_cursor`](https://docs.kapiworks.com/errors/invalid_cursor). Start the pull again when that happens.
3. Collect every page into a temporary result, removing duplicates by `record_id`, `certificate_id`, `person_id` or a course's `row_id`. Check that `snapshot.sequence` and `snapshot.capture_sequence` stay the same on every page.
4. Replace your previous copy only after the whole pull succeeds.

A page can hold fewer rows than you asked for, to stay within 3 MiB, so only a null `next_cursor` means the pull is finished. You may change the page size between pages; changing a filter means starting a new pull.

**curl**

```bash
# Supply the key from your server's secret manager. Do not log it.
# curl reads the header from stdin, keeping the key out of its arguments.
printf 'header = "Authorization: Bearer %s"\n' "$KAPI_WORKS_API_KEY" |
  curl --config - --silent --show-error --fail-with-body \
  'https://api.kapiworks.com/v1/training-records?limit=200'
```

**Node**

```js
// Node 24, as an ES module (first-call.mjs). KAPI_WORKS_API_KEY comes from your secret manager.
const response = await fetch("https://api.kapiworks.com/v1/training-records?limit=200", {
  headers: { Authorization: `Bearer ${process.env.KAPI_WORKS_API_KEY}`, Accept: "application/json" },
  redirect: "error",
  signal: AbortSignal.timeout(30_000),
});
const requestId = response.headers.get("x-request-id");
const body = await response.json();
if (!response.ok) throw new Error(`HTTP ${response.status} ${body.code}; request ID ${requestId}`);
console.log(JSON.stringify(body, null, 2));
```

**Python**

```python
# Python 3, standard library only. KAPI_WORKS_API_KEY comes from your secret manager.
import json, os, urllib.error, urllib.request

class NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, *args):  # do not resend the key to another address
        return None

request = urllib.request.Request(
    "https://api.kapiworks.com/v1/training-records?limit=200",
    headers={"Authorization": "Bearer " + os.environ["KAPI_WORKS_API_KEY"], "Accept": "application/json"},
)
try:
    with urllib.request.build_opener(NoRedirect).open(request, timeout=30) as response:
        print(json.dumps(json.load(response), indent=2))
except urllib.error.HTTPError as error:
    problem = json.load(error)
    raise SystemExit(f"HTTP {error.code} {problem['code']}; request ID {error.headers['X-Request-Id']}")
```

## Check the snapshot

Match `snapshot.organization_id` and `snapshot.deployment_id` to the company connection you saved before you import any rows. Each register is pinned separately, so two registers can come from different snapshots. When you need several registers from one snapshot, use a records pack from Works Studio.

`snapshot.known_at` is when Kapi's history writer captured the facts; compare it with `snapshot.as_at`. A later pull can include changes captured since. If relevant changes have waited more than five minutes for capture, a new pull answers [`writer_unavailable`](https://docs.kapiworks.com/errors/writer_unavailable) until they are captured; a pull that already has its cursor can still finish.

Names, work emails and course titles are current display values, not historical identity records.

## Schedule pulls

Run a full pull of each register on a schedule. One hourly job per company is a reasonable start; the schedule is your choice, not a delivery promise. Avoid overlapping runs, and stagger companies.

Between full pulls, [`/v1/snapshot`](https://docs.kapiworks.com/reference/get-snapshot) answers "has anything changed?" in one call, with any scope. If its `sequence` and `capture_sequence` match your last complete pull, no recorded facts have changed. Outcomes that depend on the clock can still change, for example `not_due_yet` becoming `overdue`, so recompute them from `due_at` or pull again at least daily.

The history sequence is not a change feed. There is no changed-since endpoint and no webhooks in v1.

## Retry what can be retried

Retry connection failures and statuses 429, 502, 503 and 504, at most five times, with delays such as 1, 2, 4, 8 and 16 seconds plus a little random jitter. When a response has `Retry-After`, wait at least that many seconds; it takes precedence over your own delay. Use a request timeout on every call.

Stop at once on 400, 401, 403, 404, 405 and 422: retrying the same request gives the same answer. The [errors](https://docs.kapiworks.com/errors) list what to do for each code. Retrying a `GET` is safe, because reading creates no training or completion records.

## Show what happened

Keep the time of the last complete pull and show it to your users, with any connection error. A failed pull is not the same as "no training missing": keep the previous copy and say the sync failed.


# Training evidence

Outcomes, completions, certificates and check codes, and how to find training that is missing.

The registers record what Kapi saw: what each employee owed, by when, and what they did about it. Your integration decides what that means for its own controls and notifications.

## Find missing training

Join training records to people and courses by their stable IDs, then compare them with your own employee roster and training policy. A person missing from Kapi has no records, so find them from your roster. People and requirements have to be in Kapi before the registers can show them.

Your integration can use these facts to flag missing evidence, tell its users, and clear the warning after a later complete pull. Kapi does not choose another platform's control mappings or notification policy.

## Outcomes

Every training record has an `outcome`:

| Outcome                        | Meaning                                                                                                |
| ------------------------------ | ------------------------------------------------------------------------------------------------------ |
| `completed_on_time`            | Completed by the deadline, or completed when there is no deadline. `completed_at` holds the time.      |
| `completed_late`               | Completed after the deadline. `days_late` says by how many days.                                       |
| `completed_before_requirement` | Completed before the requirement began. Review it against your policy.                                 |
| `not_due_yet`                  | Not completed, and the deadline has not passed or there is none.                                       |
| `overdue`                      | Not completed, and the deadline has passed.                                                            |
| `missed`                       | The record stopped applying after its deadline, without a completion.                                  |
| `excused`                      | The record stopped applying before its deadline, without a completion. An exception, not a completion. |
| `waived`                       | An administrator waived it, or it is no longer required. An exception, not a completion.               |
| `optional_not_done`            | Optional training not completed. Not a failure of required training.                                   |

v1 can add outcome values. Treat an outcome you do not know as needing review, not as completed.

A person with `no_required_records` set to `true` has no required records open at that moment. Decide whether that agrees with your policy; do not mark the person as trained because of it.

## What completion means

Completion is the completion Kapi recorded, not a separate exam score. `completed_at` is when Kapi's server received it; training finished offline arrives when the device next syncs, so a completion can arrive after its deadline. `joined_at` is when the person joined Kapi, not when they started work, and joiner deadlines count from joining Kapi.

## Certificates

A certificate record is not a PDF. Its `status` at the requested moment is `valid`, `expired`, `revoked` or `replaced`. `kind` is `completion` or `assessed`.

A person who finished the course by attending a Kapi live session in full has the session on their certificate record: `live_session_id`, `live_session_format` (`online` or `on_site`), `live_session_starts_at`, `live_session_ends_at`, `live_session_minutes` and `live_session_marked_by` (`trainer`, or `kapi` when Kapi changed the mark after an ask). With `people:read` it also has `trainer_name`. These fields are null for a course finished online.

With `people:read`, each certificate has a `check_code`. Anyone can confirm a certificate at `https://www.kapiworks.com/verify/<check_code>`; that page shows the holder's name, which is why the code needs `people:read`.

## People who were erased

When Kapi erases a person at the company's instruction, their training records and certificates stay under their IDs, so the company's evidence stays complete. `person_name` reads "Erased person" and `work_email` is null. The people register shows `access_state` as `erased`, and records that were still open end with `ended_reason` `source_removed`. Their certificate check page shows nothing. This holds for any `as_at`, including moments before the erasure, because names and work emails always come from current records, not from history.

The company that asked for the erasure should tell you the `person_id`. When you see "Erased person" or `access_state` `erased` for a person you hold, delete the name, work email and anything else you copied that identifies them, unless the company tells you otherwise. You may keep the IDs and dates so your counts stay right. Do not try to find out who the person was.

## What the API covers

The registers cover employee-targeted training only: the employees of the company that created the key, including employees in shares that also reach partners or customers. Partner and customer learners are not in v1.


# Overview

Read a company's employee training records, deadlines, completions and certificates from your own server.

- **Base URL:** `https://api.kapiworks.com/v1`
- **Contract:** OpenAPI 3.1.0, revision 1.2.0. [Download](https://api.kapiworks.com/v1/openapi.json)
- **Access:** Company API keys, from your server. Read-only.

## Make your first call

1. A company owner or admin creates a key in Works Studio, under **Settings → API keys**.
2. You store it in your server's secret manager as `KAPI_WORKS_API_KEY`.
3. Your server reads the first page of training records. Every page of one pull comes from the same snapshot of the company's records.

The [Quickstart](https://docs.kapiworks.com/quickstart) walks through each step.

**curl**

```bash
# Supply the key from your server's secret manager. Do not log it.
# curl reads the header from stdin, keeping the key out of its arguments.
printf 'header = "Authorization: Bearer %s"\n' "$KAPI_WORKS_API_KEY" |
  curl --config - --silent --show-error --fail-with-body \
  'https://api.kapiworks.com/v1/training-records?limit=200'
```

**Node**

```js
// Node 24, as an ES module (first-call.mjs). KAPI_WORKS_API_KEY comes from your secret manager.
const response = await fetch("https://api.kapiworks.com/v1/training-records?limit=200", {
  headers: { Authorization: `Bearer ${process.env.KAPI_WORKS_API_KEY}`, Accept: "application/json" },
  redirect: "error",
  signal: AbortSignal.timeout(30_000),
});
const requestId = response.headers.get("x-request-id");
const body = await response.json();
if (!response.ok) throw new Error(`HTTP ${response.status} ${body.code}; request ID ${requestId}`);
console.log(JSON.stringify(body, null, 2));
```

**Python**

```python
# Python 3, standard library only. KAPI_WORKS_API_KEY comes from your secret manager.
import json, os, urllib.error, urllib.request

class NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, *args):  # do not resend the key to another address
        return None

request = urllib.request.Request(
    "https://api.kapiworks.com/v1/training-records?limit=200",
    headers={"Authorization": "Bearer " + os.environ["KAPI_WORKS_API_KEY"], "Accept": "application/json"},
)
try:
    with urllib.request.build_opener(NoRedirect).open(request, timeout=30) as response:
        print(json.dumps(json.load(response), indent=2))
except urllib.error.HTTPError as error:
    problem = json.load(error)
    raise SystemExit(f"HTTP {error.code} {problem['code']}; request ID {error.headers['X-Request-Id']}")
```

## What you can read

| Endpoint | Returns | Scope |
| --- | --- | --- |
| [`GET /v1/training-records`](https://docs.kapiworks.com/reference/list-training-records) | Training records at a moment | `training:read` |
| [`GET /v1/training-records/period`](https://docs.kapiworks.com/reference/list-period-training-records) | Every training record owed during a period | `training:read` |
| [`GET /v1/certificates`](https://docs.kapiworks.com/reference/list-certificates) | Certificates at a moment | `certificates:read` |
| [`GET /v1/people`](https://docs.kapiworks.com/reference/list-people) | Employees and invitations during a period | `people:read` |
| [`GET /v1/courses`](https://docs.kapiworks.com/reference/list-courses) | Course versions referenced by employee records | `training:read` |
| [`GET /v1/snapshot`](https://docs.kapiworks.com/reference/get-snapshot) | Check whether anything changed | Any |

Every call is a `GET` that answers with JSON. Keys are company keys for your server, not OAuth tokens, and the API sends no CORS headers, so it is for servers rather than browsers.

## Find your way

- [Quickstart](https://docs.kapiworks.com/quickstart): Create a key and make a first call.
- [Keeping data in sync](https://docs.kapiworks.com/guides/sync): Read every page of a snapshot, and replace your copy only after a complete pull.
- [Training evidence](https://docs.kapiworks.com/guides/training-evidence): Outcomes, certificates and check codes, and what each one means.
- [API reference](https://docs.kapiworks.com/reference): Every operation, parameter and field, generated from the OpenAPI contract.
- [Errors](https://docs.kapiworks.com/errors): Every error code, what it means and what to do.
- [AI tools](https://docs.kapiworks.com/ai-tools): Markdown pages, llms.txt and a docs MCP server.


# API keys

Expiry, networks, rotation, notices and the record of calls a company keeps for each key.

Company owners and admins manage keys in Works Studio, under **Settings → API keys**. This page explains the rules those keys follow.

## Limits on keys

* A key expires at most 365 days after it is created. Pick the shortest expiry the integration can live with.
* A company can have 10 active keys at once.
* Creating a key needs two-step sign-in on the creator's account and a sign-in within the last 15 minutes.

## Networks

A key can be limited to networks: IP addresses or ranges, separated by commas, up to 20 of them. A call from anywhere else answers [`ip_not_allowed`](https://docs.kapiworks.com/errors/ip_not_allowed) and does not count against the rate limits. Leave the networks empty to allow any network.

Whether or not a key is limited, Kapi notes the networks each key is used from. The first network is expected; when a key is later used from a new one, the company's owners and admins are told.

## Rotate a key

1. Create a replacement key with the same scopes.
2. Update the receiving server with the new key.
3. Confirm a successful call with it.
4. Revoke the old key.

Revocation takes effect on the next call.

## Notices

Owners and admins of the company receive an email when:

* a key is created;
* a key expires in 30 days, and again in 7 days;
* a revoked or expired key is used, at most once a key each day;
* a key is sent in a web address, which revokes it;
* a key is used from a new network.

These notices are separate from training reminders, and they contain no key.

## Calls you can review

The API keys page in Works Studio lists the company's recent calls with their key, register, status, rows, time taken and request ID. The request ID is the `X-Request-Id` the call returned. Kapi keeps call records for 13 months.

## Before you switch an integration on

Build against the [sandbox company](https://docs.kapiworks.com/guides/sandbox) first. Then test with a company that has authorised you: scoped access, every page of each register, missing training, completions, expired certificates, retries and revocation. Keep data from different companies apart. There is no public demo key and no self-service sign-up: Kapi gives sandbox keys to partner engineers on request.


# Quickstart

Create a key, keep it on your server, and read your first page of training records.

You need a Kapi Works company and an owner or admin of that company. They create the key in Works Studio; your server makes the calls.

## 1. Create a key

In Works Studio, a company owner or admin opens **Settings → API keys** and selects **Create key**. Creating a key needs two-step sign-in on their account and a sign-in within the last 15 minutes.

They give the key:

* a name they will recognise later, such as the name of the receiving system;
* the receiving company, its country and the purpose of the integration;
* the [scopes](https://docs.kapiworks.com/scopes) the integration needs, and no more;
* an expiry, at most 365 days away;
* optionally, the networks it may be used from. Leave this empty to allow any network.

Create a separate key for each receiving integration and each company. A key covers one company, across all of its learning spaces.

## 2. Store the key on your server

Works Studio shows the key once. Copy it into your server's secret manager and give it to your code as `KAPI_WORKS_API_KEY`. Keep it out of source code, browser code, command arguments and logs.

Production keys start with `kw_live_`; keys from Kapi's other environments start with `kw_test_`. The prefix names the environment, and [sandbox keys](https://docs.kapiworks.com/guides/sandbox) on the live system also start with `kw_live_`. Each key works only on the deployment that issued it.

## 3. Make the first call

Read the first page of training records. The key goes in the `Authorization` header and nowhere else: a key sent in a web address is revoked at once.

**curl**

```bash
# Supply the key from your server's secret manager. Do not log it.
# curl reads the header from stdin, keeping the key out of its arguments.
printf 'header = "Authorization: Bearer %s"\n' "$KAPI_WORKS_API_KEY" |
  curl --config - --silent --show-error --fail-with-body \
  'https://api.kapiworks.com/v1/training-records?limit=200'
```

**Node**

```js
// Node 24, as an ES module (first-call.mjs). KAPI_WORKS_API_KEY comes from your secret manager.
const response = await fetch("https://api.kapiworks.com/v1/training-records?limit=200", {
  headers: { Authorization: `Bearer ${process.env.KAPI_WORKS_API_KEY}`, Accept: "application/json" },
  redirect: "error",
  signal: AbortSignal.timeout(30_000),
});
const requestId = response.headers.get("x-request-id");
const body = await response.json();
if (!response.ok) throw new Error(`HTTP ${response.status} ${body.code}; request ID ${requestId}`);
console.log(JSON.stringify(body, null, 2));
```

**Python**

```python
# Python 3, standard library only. KAPI_WORKS_API_KEY comes from your secret manager.
import json, os, urllib.error, urllib.request

class NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, *args):  # do not resend the key to another address
        return None

request = urllib.request.Request(
    "https://api.kapiworks.com/v1/training-records?limit=200",
    headers={"Authorization": "Bearer " + os.environ["KAPI_WORKS_API_KEY"], "Accept": "application/json"},
)
try:
    with urllib.request.build_opener(NoRedirect).open(request, timeout=30) as response:
        print(json.dumps(json.load(response), indent=2))
except urllib.error.HTTPError as error:
    problem = json.load(error)
    raise SystemExit(f"HTTP {error.code} {problem['code']}; request ID {error.headers['X-Request-Id']}")
```

The answer is one page of records, the snapshot they come from, and a cursor for the next page:

```json
{
  "data": [
    {
      "deployment_id": "00000000-0000-4000-8000-000000000001",
      "organization_id": "4f6b2a52-1c1e-4f7a-9d3e-2b7c8a9e0d11",
      "learning_space_id": "8b2d6c3e-5f41-4a8e-9c27-1d0e3f4a5b62",
      "record_id": "a7e3c1b9-2d48-4f6a-8e15-9b0c7d6e5f43",
      "learner_profile_id": "c3a1e7f2-8d64-4b1f-a9e2-6f5d4c3b2a10",
      "person_name": "Amira Okafor",
      "work_email": "amira.okafor@example.com",
      "course_id": "d2f8b4a6-7c1e-4e93-b5a0-3c9e8f7d6a21",
      "course_release_id": "e5c2a9d7-4b3f-4c18-9a6e-2f1d0c9b8a77",
      "renews_certificate_id": null,
      "sequence": 1271,
      "valid_from": "2026-09-12T13:41:09Z",
      "recorded_at": "2026-09-12T13:41:12Z",
      "cause": "change",
      "logic_version": 4,
      "requirement": "required",
      "assigned_at": "2026-09-01T08:00:00Z",
      "due_at": "2026-09-30T22:59:59Z",
      "due_time_zone": "Europe/London",
      "due_basis": "share_deadline",
      "completed_at": "2026-09-12T13:41:09Z",
      "completion_receipt_id": "9c8b7a6f-5e4d-4c3b-8a29-1f0e9d8c7b66",
      "ended_at": null,
      "ended_reason": null,
      "waived_at": null,
      "waiver_actor_id": null,
      "waiver_reason_code": null,
      "outcome": "completed_on_time",
      "days_late": null,
      "reminders_sent": 1,
      "share_ids": [
        "b6d4f2a8-9e1c-4a35-8f72-0e9d8c7b6a54"
      ],
      "doors": [
        "employee"
      ],
      "certificate_id": "f1b9d3e5-6a2c-4d70-8b4e-5c3a2f1e0d98",
      "obligation_change_id": null
    }
  ],
  "total": 1,
  "snapshot": {
    "organization_id": "4f6b2a52-1c1e-4f7a-9d3e-2b7c8a9e0d11",
    "deployment_id": "00000000-0000-4000-8000-000000000001",
    "as_at": "2026-10-09T09:00:00Z",
    "from": null,
    "known_at": "2026-10-09T08:59:41Z",
    "sequence": 1284,
    "capture_sequence": 352,
    "chain_hash": "3f9a1c7e5b2d8f4a6c0e9b1d3f5a7c9e2b4d6f8a0c1e3b5d7f9a2c4e6b8d0f1a",
    "logic": {
      "version": 4,
      "date": "2026-10-06"
    },
    "history_complete_from": "2026-10-06T05:00:00Z",
    "available": true
  },
  "next_cursor": null
}
```

The rows above are invented. A real response holds the company's records, which are personal data, so store and log them with care.

## 4. Read every page

While `next_cursor` is not null, call the same endpoint again with that cursor. Only a null `next_cursor` means the pull is complete, because a page can hold fewer rows than you asked for. [Keeping data in sync](https://docs.kapiworks.com/guides/sync) covers paging, snapshots and retries.

## Next steps

* [Authentication](https://docs.kapiworks.com/authentication): how the API checks a key, and what it refuses.
* [The five registers](https://docs.kapiworks.com/guides/registers): what each endpoint returns, at a moment or over a period.
* [Errors](https://docs.kapiworks.com/errors): every error code, with what to do.


# Rate limits

How many calls a key and a company can make, what counts, and how to slow down when told to.

| Limit | Value |
| --- | --- |
| Calls a minute for each key | 60 |
| Calls a minute for each company, across all its keys | 120 |
| Calls running at once for each company | 2 |
| Rows in one page | 200 |
| Longest period | 400 days |
| Largest response | 3 MiB |
| How long call records are kept | 13 months |
| Longest key lifetime | 365 days |
| Active keys for each company | 10 |
| Notice before a breaking change | 12 months |

These numbers come from the contract, so they change here when they change in the API.

## What counts

Each minute is a rolling window. The per-key and per-company limits both apply: giving an integration more keys does not raise its company's limit.

Calls the API accepts count, and so do calls with a mistake in them, such as an invalid query or a missing scope. Calls the API refuses for a revoked, expired or unknown key, a network the key does not allow, or a limit do not count, so a busy or retrying integration does not lock itself out.

## Reading the headers

Every accepted call returns the provisional [IETF RateLimit](https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/) headers:

* `RateLimit-Policy` lists the quotas: calls a minute for the key and for the company, and calls at once for the company.
* `RateLimit` gives the calls remaining (`r`) and the seconds until a call frees up (`t`), for the key and for the company.

The headers follow a draft standard, so their format can change; treat them as advice and rely on the status codes.

## When you hit a limit

A call over a limit answers 429 with [`rate_limited`](https://docs.kapiworks.com/errors/rate_limited) or [`concurrent_limit`](https://docs.kapiworks.com/errors/concurrent_limit) and a `Retry-After` header. Wait at least `Retry-After` seconds, add a small random delay, and pause every call for that company, not only the one that failed. Run one pull at a time for each company.

## Pages and periods

A page holds at most 200 rows and 3 MiB; a larger `limit` is treated as 200. A page can hold fewer rows to stay within the size, so follow `next_cursor` until it is null. A period is at most 400 days.


# Field dictionary

What every field of every register means, from the same dictionary as the records packs.

These meanings come from the dictionary Kapi uses for the registers and the records packs, published in the contract. The contract gives each field's type, and whether it can be null.

## Training

| Field | Meaning |
| --- | --- |
| `deployment_id` | Kapi deployment UUID. |
| `organization_id` | Company UUID; the tenant boundary. |
| `learning_space_id` | Learning-space UUID. |
| `record_id` | Stable owed-record UUID: person, course and published release, or renewal cycle. |
| `learner_profile_id` | Employee profile UUID; retained after erasure. |
| `person_name` | Name from its current source row. Erased person once Kapi erased the person at the company’s instruction. Certificate names are the issued subject name until then. |
| `work_email` | Address on the current source employee invitation; not the Auth account email. Null for an erased person. |
| `course_id` | Course UUID. |
| `course_release_id` | Immutable published course version UUID. |
| `renews_certificate_id` | Certificate whose renewal created this owed record. |
| `sequence` | Sequence of this stored row version in the company history; snapshot.sequence is the upper boundary for the pull. |
| `valid_from` | When these facts took effect. |
| `recorded_at` | When the history writer captured them. |
| `cause` | first, change or sweep. |
| `logic_version` | Version of the shared owed-learning decider. |
| `requirement` | required, optional, no_longer_required or superseded. |
| `assigned_at` | When the requirement began. |
| `due_at` | Deadline instant, or null for no deadline. |
| `due_time_zone` | IANA company/deadline time zone. |
| `due_basis` | share_deadline, days_after_added, renewal, override or none. |
| `completed_at` | Time Kapi’s server received the completion, including offline completions received later. |
| `completion_receipt_id` | Server completion receipt UUID. |
| `ended_at` | When the record stopped applying. |
| `ended_reason` | Fixed reason code, never typed text; source_removed when the person was erased. |
| `share_ids` | JSON array of all contributing share UUIDs. |
| `doors` | JSON array of audience doors; includes employee. |
| `certificate_id` | Certificate UUID. |
| `obligation_change_id` | Source obligation-change ID. |
| `waived_at` | When an administrator changed the obligation. |
| `waiver_actor_id` | Administrator UUID. |
| `waiver_reason_code` | Fixed waiver reason code. |
| `outcome` | completed_on_time, completed_late, completed_before_requirement, not_due_yet, overdue, missed, excused, waived or optional_not_done. |
| `days_late` | Ceiling of elapsed days past the deadline at completion or the end of the period/obligation. |
| `reminders_sent` | Number of completed reminder sends known at the pinned capture. |

## Certificates

| Field | Meaning |
| --- | --- |
| `certificate_id` | Certificate UUID. |
| `deployment_id` | Kapi deployment UUID. |
| `organization_id` | Company UUID; the tenant boundary. |
| `learning_space_id` | Learning-space UUID. |
| `learner_profile_id` | Employee profile UUID; retained after erasure. |
| `person_name` | Name from its current source row. Erased person once Kapi erased the person at the company’s instruction. Certificate names are the issued subject name until then. |
| `course_id` | Course UUID. |
| `course_release_id` | Immutable published course version UUID. |
| `course_release_version` | Published course version number. |
| `certificate_title` | Title recorded on the certificate edition. |
| `kind` | completion or assessed. |
| `earned_at` | Completion or assessment time. |
| `issued_at` | Certificate issue time. |
| `valid_until` | Fixed expiry instant; null means no expiry. |
| `status` | Certificate state at the requested moment: valid, expired, revoked or replaced. |
| `changed_at` | Revocation/replacement time, when known at this snapshot. |
| `changed_by` | Actor UUID for revocation/replacement. |
| `supersedes_id` | Earlier certificate corrected by this certificate. |
| `renews_id` | Earlier certificate renewed by this certificate. |
| `check_code` | Certificate check code. Returned only with people:read because it opens the public check page, which shows the holder's name. The check page shows nothing once the holder is erased. |
| `live_session_id` | Live session UUID when the person finished the course by attending a live session in full; null otherwise. |
| `live_session_format` | online or on_site. |
| `live_session_starts_at` | When the live session started. |
| `live_session_ends_at` | When the live session ended. |
| `live_session_minutes` | Length of the live session in minutes. |
| `live_session_marked_by` | Who made the final register mark: trainer, or kapi when Kapi changed it after an ask. |
| `trainer_name` | Trainer of the live session, from their current Kapi profile; A Kapi expert until they set a name. |

## People

| Field | Meaning |
| --- | --- |
| `deployment_id` | Kapi deployment UUID. |
| `organization_id` | Company UUID; the tenant boundary. |
| `learning_space_id` | Learning-space UUID. |
| `person_id` | Stable employee or invitation row UUID. |
| `learner_profile_id` | Employee profile UUID; retained after erasure. |
| `invitation_id` | Invitation UUID for invitation rows. |
| `sequence` | Sequence of this stored row version in the company history; snapshot.sequence is the upper boundary for the pull. |
| `valid_from` | When these facts took effect. |
| `recorded_at` | When the history writer captured them. |
| `cause` | first, change or sweep. |
| `logic_version` | Version of the shared owed-learning decider. |
| `entry_type` | employee or invitation; accepted invitations and employees are distinct source rows. |
| `person_name` | Name from its current source row. Erased person once Kapi erased the person at the company’s instruction. Certificate names are the issued subject name until then. |
| `work_email` | Address on the current source employee invitation; not the Auth account email. Null for an erased person. |
| `invited_at` | Invitation creation time. |
| `joined_at` | When the person joined Kapi; not an employment start date. |
| `left_at` | When access ended in Kapi. |
| `invitation_expires_at` | Invitation expiry time. |
| `invitation_state` | Stored invitation state. |
| `invitation_status` | Invitation state at the requested moment, including expiry. |
| `access_state` | active, suspended, ended, erased, invited or staff_practice. erased means Kapi removed the person at the company’s instruction. |
| `doors` | JSON array of audience doors; includes employee. |
| `team_ids` | JSON array of company team UUIDs. |
| `records_owed` | Open required employee records at the requested moment. |
| `records_completed` | Completed employee records at the requested moment. |
| `no_required_records` | True when no required records are open. |

## Courses

| Field | Meaning |
| --- | --- |
| `row_id` | Stable course release row UUID. |
| `course_id` | Course UUID. |
| `course_release_id` | Immutable published course version UUID. |
| `course_title` | Title from the current source course. |
| `version` | Published version number. |
| `published_at` | Time the release was sealed. |
| `publisher_id` | Publisher UUID. |
| `content_sha256` | Immutable course content fingerprint. |
| `contents_sha256` | Fingerprint of the list of contained releases. |
| `source_kind` | course_release, release_unavailable or lesson_share_without_course_release. |
| `lessons` | JSON array of lesson release IDs, current source titles, versions and content SHA-256 values. |


# API reference

Every operation, parameter, response and field, generated from the OpenAPI contract the API serves.

The reference pages are generated from the same contract the API serves at [`https://api.kapiworks.com/v1/openapi.json`](https://api.kapiworks.com/v1/openapi.json), during each release of these docs. Download it to generate a client or to diff one revision against another. This is contract revision 1.2.0.

## Base URL

`https://api.kapiworks.com/v1`

Every endpoint answers `GET` and `HEAD` over HTTPS, with JSON. Send the company API key in the `Authorization` header; see [Authentication](https://docs.kapiworks.com/authentication).

## Operations

| Endpoint | Returns | Scope |
| --- | --- | --- |
| [`GET /v1/training-records`](https://docs.kapiworks.com/reference/list-training-records) | Training records at a moment | `training:read` |
| [`GET /v1/training-records/period`](https://docs.kapiworks.com/reference/list-period-training-records) | Every training record owed during a period | `training:read` |
| [`GET /v1/certificates`](https://docs.kapiworks.com/reference/list-certificates) | Certificates at a moment | `certificates:read` |
| [`GET /v1/people`](https://docs.kapiworks.com/reference/list-people) | Employees and invitations during a period | `people:read` |
| [`GET /v1/courses`](https://docs.kapiworks.com/reference/list-courses) | Course versions referenced by employee records | `training:read` |
| [`GET /v1/snapshot`](https://docs.kapiworks.com/reference/get-snapshot) | Check whether anything changed | Any |

## Every response

* `X-Request-Id`: a UUID for this call. Quote it to [support](https://docs.kapiworks.com/support).
* `Cache-Control: no-store`: responses hold company data, so nothing on the way may store them.
* `RateLimit-Policy` and `RateLimit`: your remaining calls this minute, for the key and for the company. See [Rate limits](https://docs.kapiworks.com/rate-limits).

Errors are `application/problem+json` with a stable `code`; see [Errors](https://docs.kapiworks.com/errors).

## Paged registers

Registers answer one page at a time, with the same envelope: `data`, `total`, `snapshot` and `next_cursor`. Follow `next_cursor` until it is null; [Keeping data in sync](https://docs.kapiworks.com/guides/sync) explains how.

The [field dictionary](https://docs.kapiworks.com/reference/field-dictionary) explains every field of every register.


# Scopes

What each scope lets a key read, and which fields need people:read.

A key's scopes decide which registers it reads. The company owner or admin picks them when they create the key; give each integration only the scopes it needs.

| Scope | In Works Studio | Reads | Endpoints |
| --- | --- | --- | --- |
| `training:read` | Training records | Training records, at a moment and over a period, and the courses they refer to. | [`/v1/training-records`](https://docs.kapiworks.com/reference/list-training-records), [`/v1/training-records/period`](https://docs.kapiworks.com/reference/list-period-training-records), [`/v1/courses`](https://docs.kapiworks.com/reference/list-courses) |
| `certificates:read` | Certificate records | Certificates and their state at a moment. | [`/v1/certificates`](https://docs.kapiworks.com/reference/list-certificates) |
| `people:read` | Names and work emails | The people register, and names, work emails and check codes in the other registers. | [`/v1/people`](https://docs.kapiworks.com/reference/list-people) |

[`/v1/snapshot`](https://docs.kapiworks.com/reference/get-snapshot) works with any scope, because it returns only sequence numbers and times.

## Names, work emails and check codes need people:read

`people:read` also controls identity fields in the other registers. Without it:

* training records leave out `person_name` and `work_email`;
* certificate records leave out `person_name` and `check_code`.

Person IDs, dates and status stay in every record, so an integration without `people:read` still joins records to its own roster by ID.

A certificate's check code opens a public page that shows the holder's name, so it needs the same permission as the name.

## When a scope is missing

A call to a register the key has no scope for answers [`insufficient_scope`](https://docs.kapiworks.com/errors/insufficient_scope) with status 403. The `WWW-Authenticate` header names the scope it needs. Retrying with the same key gives the same answer; ask the company administrator for a key with that scope.


# Security

How the API protects keys and company data, and how to report a security problem.

## Report a problem

Email [support@kapiworks.com](mailto:support@kapiworks.com) with what you found, how to reproduce it, and any request IDs. Send no API keys and no personal data you do not need to. The same address is in this site's [security.txt](https://docs.kapiworks.com/.well-known/security.txt).

## Keys

* The key decides the company. A key reads only the company that created it, and addresses take no company selector.
* Keys hold 256 random bits and an 8-character checksum. The API checks the checksum before anything else, so a mistyped key is refused before it reaches Kapi's database.
* Kapi stores a SHA-256 hash of each key and its last four characters. Works Studio shows the key once, when it is created.
* A key expires within 365 days. A company has at most 10 active keys.
* Creating a key needs two-step sign-in and a sign-in within the last 15 minutes.
* A key can be limited to networks. Owners and admins are told when a key is used from a new network.
* A key sent in a web address is revoked at once, and owners and admins are told.

## Data

* Kapi Works keeps its database in London, United Kingdom. The providers that process it, what each does and where, are on the [sub-processors list](https://www.kapiworks.com/sub-processors).
* The API is read-only. It answers `GET` and `HEAD` only.
* Responses carry only the fields the contract publishes, and names, work emails and check codes only with `people:read`.
* Responses send `Cache-Control: no-store`, `X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer`, and no CORS headers.
* Cursors are sealed with AES-256-GCM, so they reveal nothing and an edited cursor is refused.

## Call records

Kapi records each call: the key, register, status, rows, time taken and request ID. The caller's network is kept as a prefix (/24 for IPv4, /48 for IPv6), not the full address. Call records are kept for 13 months, and the company's owners and admins can review recent calls in Works Studio.


# Support

How to get help with the Kapi Works API, and what to send.

Email [support@kapiworks.com](mailto:support@kapiworks.com).

## What to send

* The `X-Request-Id` of each failed call. Owners and admins also see request IDs on the API keys page in Works Studio.
* The time of the call, in UTC, and the endpoint.
* The status and the error `code` you received.
* Your company's name, and the receiving company named on the key.

Do not send the API key, or rows of company data. If a key may have been exposed, ask the company's owner or admin to revoke it in Works Studio first.

## Before you write

* [Errors](https://docs.kapiworks.com/errors) lists what to do for each code.
* [Rate limits](https://docs.kapiworks.com/rate-limits) explains `429` answers and the `RateLimit` headers.
* [Keeping data in sync](https://docs.kapiworks.com/guides/sync) covers paging, snapshots and retries.


# Versioning

What can change within v1, and how breaking changes are announced.

The major version is in the address: `/v1`. The contract's revision is 1.2.0; it goes up with each published change, and the [changelog](https://docs.kapiworks.com/changelog) lists each one.

## Changes within v1

v1 grows only by adding. It can add paths, operations, fields, values and error codes without notice, so:

* Ignore fields you do not know.
* Fields that list values, such as `outcome`, `status` and `requirement`, are open: new values can appear. Treat an outcome you do not know as needing review, not as completed.
* Handle an error code you do not know by its HTTP status.

## Breaking changes

Removing a path, an operation, a response field or an error code, or making a field required, would break clients. Changes like these come only with a new major version, `/v2`, announced at least 12 months ahead, here and in the changelog.

## Follow changes

* The [changelog](https://docs.kapiworks.com/changelog), also as an [RSS feed](https://docs.kapiworks.com/changelog/rss.xml).
* The contract at [`https://api.kapiworks.com/v1/openapi.json`](https://api.kapiworks.com/v1/openapi.json), to compare one revision with the next.


# Training records at a moment

`GET https://api.kapiworks.com/v1/training-records`

Every record an employee owes at `as_at`, with its outcome then. Requires `training:read`. Names, work emails and certificate check codes also need `people:read`; without it they are left out.

Scope: `training:read`.

## Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `as_at` | string (date-time) or string (date) | No | RFC 3339 instant with an offset, or a date read as the end of the company's day. Defaults to now. |
| `course` | string (uuid) | No | Optional course UUID. The cursor keeps it. |
| `cursor` | string | No | Opaque next_cursor from the previous page. Send it unchanged to the same endpoint. It pins the snapshot and filters, and grants no access. |
| `limit` | integer | No | Rows per page, 1 to 200. Larger values are treated as 200. A page can hold fewer rows to stay within 3 MiB, so follow next_cursor until it is null. |

## Responses

| Status | Description |
| --- | --- |
| 200 | One page from a pinned company snapshot. |
| 400 | RFC 9457 problem details. Codes: invalid_request, invalid_cursor, key_in_url. Act on `code` or `type`; never parse `detail`. |
| 401 | RFC 9457 problem details. Codes: invalid_token. Act on `code` or `type`; never parse `detail`. |
| 403 | RFC 9457 problem details. Codes: insufficient_scope, ip_not_allowed. Act on `code` or `type`; never parse `detail`. |
| 404 | RFC 9457 problem details. Codes: not_found. Act on `code` or `type`; never parse `detail`. |
| 405 | RFC 9457 problem details. Codes: method_not_allowed. Act on `code` or `type`; never parse `detail`. |
| 422 | RFC 9457 problem details. Codes: before_history, record_too_large. Act on `code` or `type`; never parse `detail`. |
| 429 | RFC 9457 problem details. Codes: rate_limited, concurrent_limit. Act on `code` or `type`; never parse `detail`. |
| 502 | RFC 9457 problem details. Codes: read_failed. Act on `code` or `type`; never parse `detail`. |
| 503 | RFC 9457 problem details. Codes: unavailable, upstream_unavailable, writer_unavailable. Act on `code` or `type`; never parse `detail`. |
| 504 | RFC 9457 problem details. Codes: read_timeout. Act on `code` or `type`; never parse `detail`. |

## Fields of each row

| Field | Type | Meaning |
| --- | --- | --- |
| `deployment_id` | string (uuid) | Kapi deployment UUID. |
| `organization_id` | string (uuid) | Company UUID; the tenant boundary. |
| `learning_space_id` | string (uuid) | Learning-space UUID. |
| `sequence` | integer | Sequence of this stored row version in the company history; snapshot.sequence is the upper boundary for the pull. |
| `valid_from` | string (date-time) | When these facts took effect. |
| `recorded_at` | string (date-time) | When the history writer captured them. |
| `cause` | string | first, change or sweep. |
| `logic_version` | integer | Version of the shared owed-learning decider. |
| `record_id` | string (uuid) | Stable owed-record UUID: person, course and published release, or renewal cycle. |
| `learner_profile_id` | string (uuid) | Employee profile UUID; retained after erasure. |
| `person_name` | string or null | Name from its current source row. Erased person once Kapi erased the person at the company’s instruction. Certificate names are the issued subject name until then. |
| `work_email` | string or null | Address on the current source employee invitation; not the Auth account email. Null for an erased person. |
| `course_id` | string (uuid) | Course UUID. |
| `course_release_id` | string or null (uuid) | Immutable published course version UUID. |
| `renews_certificate_id` | string or null (uuid) | Certificate whose renewal created this owed record. |
| `requirement` | string | required, optional, no_longer_required or superseded. |
| `assigned_at` | string (date-time) | When the requirement began. |
| `due_at` | string or null (date-time) | Deadline instant, or null for no deadline. |
| `due_time_zone` | string | IANA company/deadline time zone. |
| `due_basis` | string | share_deadline, days_after_added, renewal, override or none. |
| `completed_at` | string or null (date-time) | Time Kapi’s server received the completion, including offline completions received later. |
| `completion_receipt_id` | string or null (uuid) | Server completion receipt UUID. |
| `ended_at` | string or null (date-time) | When the record stopped applying. |
| `ended_reason` | string or null | Fixed reason code, never typed text; source_removed when the person was erased. |
| `waived_at` | string or null (date-time) | When an administrator changed the obligation. |
| `waiver_actor_id` | string or null (uuid) | Administrator UUID. |
| `waiver_reason_code` | string or null | Fixed waiver reason code. |
| `outcome` | string | completed_on_time, completed_late, completed_before_requirement, not_due_yet, overdue, missed, excused, waived or optional_not_done. |
| `days_late` | integer or null | Ceiling of elapsed days past the deadline at completion or the end of the period/obligation. |
| `reminders_sent` | integer | Number of completed reminder sends known at the pinned capture. |
| `share_ids` | array of string (uuid) | JSON array of all contributing share UUIDs. |
| `doors` | array of string | JSON array of audience doors; includes employee. |
| `certificate_id` | string or null (uuid) | Certificate UUID. |
| `obligation_change_id` | integer or null | Source obligation-change ID. |

## Example response

```json
{
  "data": [
    {
      "deployment_id": "00000000-0000-4000-8000-000000000001",
      "organization_id": "4f6b2a52-1c1e-4f7a-9d3e-2b7c8a9e0d11",
      "learning_space_id": "8b2d6c3e-5f41-4a8e-9c27-1d0e3f4a5b62",
      "record_id": "a7e3c1b9-2d48-4f6a-8e15-9b0c7d6e5f43",
      "learner_profile_id": "c3a1e7f2-8d64-4b1f-a9e2-6f5d4c3b2a10",
      "person_name": "Amira Okafor",
      "work_email": "amira.okafor@example.com",
      "course_id": "d2f8b4a6-7c1e-4e93-b5a0-3c9e8f7d6a21",
      "course_release_id": "e5c2a9d7-4b3f-4c18-9a6e-2f1d0c9b8a77",
      "renews_certificate_id": null,
      "sequence": 1271,
      "valid_from": "2026-09-12T13:41:09Z",
      "recorded_at": "2026-09-12T13:41:12Z",
      "cause": "change",
      "logic_version": 4,
      "requirement": "required",
      "assigned_at": "2026-09-01T08:00:00Z",
      "due_at": "2026-09-30T22:59:59Z",
      "due_time_zone": "Europe/London",
      "due_basis": "share_deadline",
      "completed_at": "2026-09-12T13:41:09Z",
      "completion_receipt_id": "9c8b7a6f-5e4d-4c3b-8a29-1f0e9d8c7b66",
      "ended_at": null,
      "ended_reason": null,
      "waived_at": null,
      "waiver_actor_id": null,
      "waiver_reason_code": null,
      "outcome": "completed_on_time",
      "days_late": null,
      "reminders_sent": 1,
      "share_ids": [
        "b6d4f2a8-9e1c-4a35-8f72-0e9d8c7b6a54"
      ],
      "doors": [
        "employee"
      ],
      "certificate_id": "f1b9d3e5-6a2c-4d70-8b4e-5c3a2f1e0d98",
      "obligation_change_id": null
    }
  ],
  "total": 1,
  "snapshot": {
    "organization_id": "4f6b2a52-1c1e-4f7a-9d3e-2b7c8a9e0d11",
    "deployment_id": "00000000-0000-4000-8000-000000000001",
    "as_at": "2026-10-09T09:00:00Z",
    "from": null,
    "known_at": "2026-10-09T08:59:41Z",
    "sequence": 1284,
    "capture_sequence": 352,
    "chain_hash": "3f9a1c7e5b2d8f4a6c0e9b1d3f5a7c9e2b4d6f8a0c1e3b5d7f9a2c4e6b8d0f1a",
    "logic": {
      "version": 4,
      "date": "2026-10-06"
    },
    "history_complete_from": "2026-10-06T05:00:00Z",
    "available": true
  },
  "next_cursor": null
}
```

The full contract, with every schema, is at https://api.kapiworks.com/v1/openapi.json.

# Every training record owed during a period

`GET https://api.kapiworks.com/v1/training-records/period`

Every record owed at any time between `from` and `to`, with its outcome at the end of the period or when it ended. Requires `training:read`. Names, work emails and certificate check codes also need `people:read`; without it they are left out.

Scope: `training:read`.

## Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `from` | string (date-time) or string (date) | No | Required on the first page. Period start, inclusive; a date starts the company's day. Omit it with a cursor, which keeps it. At most 400 days before `to`. |
| `to` | string (date-time) or string (date) | No | Required on the first page. Period end, inclusive, as an instant or a company date. Omit it with a cursor, which keeps it. |
| `course` | string (uuid) | No | Optional course UUID. The cursor keeps it. |
| `cursor` | string | No | Opaque next_cursor from the previous page. Send it unchanged to the same endpoint. It pins the snapshot and filters, and grants no access. |
| `limit` | integer | No | Rows per page, 1 to 200. Larger values are treated as 200. A page can hold fewer rows to stay within 3 MiB, so follow next_cursor until it is null. |

## Responses

| Status | Description |
| --- | --- |
| 200 | One page from a pinned company snapshot. |
| 400 | RFC 9457 problem details. Codes: invalid_request, invalid_cursor, key_in_url. Act on `code` or `type`; never parse `detail`. |
| 401 | RFC 9457 problem details. Codes: invalid_token. Act on `code` or `type`; never parse `detail`. |
| 403 | RFC 9457 problem details. Codes: insufficient_scope, ip_not_allowed. Act on `code` or `type`; never parse `detail`. |
| 404 | RFC 9457 problem details. Codes: not_found. Act on `code` or `type`; never parse `detail`. |
| 405 | RFC 9457 problem details. Codes: method_not_allowed. Act on `code` or `type`; never parse `detail`. |
| 422 | RFC 9457 problem details. Codes: before_history, record_too_large. Act on `code` or `type`; never parse `detail`. |
| 429 | RFC 9457 problem details. Codes: rate_limited, concurrent_limit. Act on `code` or `type`; never parse `detail`. |
| 502 | RFC 9457 problem details. Codes: read_failed. Act on `code` or `type`; never parse `detail`. |
| 503 | RFC 9457 problem details. Codes: unavailable, upstream_unavailable, writer_unavailable. Act on `code` or `type`; never parse `detail`. |
| 504 | RFC 9457 problem details. Codes: read_timeout. Act on `code` or `type`; never parse `detail`. |

## Fields of each row

| Field | Type | Meaning |
| --- | --- | --- |
| `deployment_id` | string (uuid) | Kapi deployment UUID. |
| `organization_id` | string (uuid) | Company UUID; the tenant boundary. |
| `learning_space_id` | string (uuid) | Learning-space UUID. |
| `sequence` | integer | Sequence of this stored row version in the company history; snapshot.sequence is the upper boundary for the pull. |
| `valid_from` | string (date-time) | When these facts took effect. |
| `recorded_at` | string (date-time) | When the history writer captured them. |
| `cause` | string | first, change or sweep. |
| `logic_version` | integer | Version of the shared owed-learning decider. |
| `record_id` | string (uuid) | Stable owed-record UUID: person, course and published release, or renewal cycle. |
| `learner_profile_id` | string (uuid) | Employee profile UUID; retained after erasure. |
| `person_name` | string or null | Name from its current source row. Erased person once Kapi erased the person at the company’s instruction. Certificate names are the issued subject name until then. |
| `work_email` | string or null | Address on the current source employee invitation; not the Auth account email. Null for an erased person. |
| `course_id` | string (uuid) | Course UUID. |
| `course_release_id` | string or null (uuid) | Immutable published course version UUID. |
| `renews_certificate_id` | string or null (uuid) | Certificate whose renewal created this owed record. |
| `requirement` | string | required, optional, no_longer_required or superseded. |
| `assigned_at` | string (date-time) | When the requirement began. |
| `due_at` | string or null (date-time) | Deadline instant, or null for no deadline. |
| `due_time_zone` | string | IANA company/deadline time zone. |
| `due_basis` | string | share_deadline, days_after_added, renewal, override or none. |
| `completed_at` | string or null (date-time) | Time Kapi’s server received the completion, including offline completions received later. |
| `completion_receipt_id` | string or null (uuid) | Server completion receipt UUID. |
| `ended_at` | string or null (date-time) | When the record stopped applying. |
| `ended_reason` | string or null | Fixed reason code, never typed text; source_removed when the person was erased. |
| `waived_at` | string or null (date-time) | When an administrator changed the obligation. |
| `waiver_actor_id` | string or null (uuid) | Administrator UUID. |
| `waiver_reason_code` | string or null | Fixed waiver reason code. |
| `outcome` | string | completed_on_time, completed_late, completed_before_requirement, not_due_yet, overdue, missed, excused, waived or optional_not_done. |
| `days_late` | integer or null | Ceiling of elapsed days past the deadline at completion or the end of the period/obligation. |
| `reminders_sent` | integer | Number of completed reminder sends known at the pinned capture. |
| `share_ids` | array of string (uuid) | JSON array of all contributing share UUIDs. |
| `doors` | array of string | JSON array of audience doors; includes employee. |
| `certificate_id` | string or null (uuid) | Certificate UUID. |
| `obligation_change_id` | integer or null | Source obligation-change ID. |

## Example response

```json
{
  "data": [
    {
      "deployment_id": "00000000-0000-4000-8000-000000000001",
      "organization_id": "4f6b2a52-1c1e-4f7a-9d3e-2b7c8a9e0d11",
      "learning_space_id": "8b2d6c3e-5f41-4a8e-9c27-1d0e3f4a5b62",
      "record_id": "a7e3c1b9-2d48-4f6a-8e15-9b0c7d6e5f43",
      "learner_profile_id": "c3a1e7f2-8d64-4b1f-a9e2-6f5d4c3b2a10",
      "person_name": "Amira Okafor",
      "work_email": "amira.okafor@example.com",
      "course_id": "d2f8b4a6-7c1e-4e93-b5a0-3c9e8f7d6a21",
      "course_release_id": "e5c2a9d7-4b3f-4c18-9a6e-2f1d0c9b8a77",
      "renews_certificate_id": null,
      "sequence": 1271,
      "valid_from": "2026-09-12T13:41:09Z",
      "recorded_at": "2026-09-12T13:41:12Z",
      "cause": "change",
      "logic_version": 4,
      "requirement": "required",
      "assigned_at": "2026-09-01T08:00:00Z",
      "due_at": "2026-09-30T22:59:59Z",
      "due_time_zone": "Europe/London",
      "due_basis": "share_deadline",
      "completed_at": "2026-09-12T13:41:09Z",
      "completion_receipt_id": "9c8b7a6f-5e4d-4c3b-8a29-1f0e9d8c7b66",
      "ended_at": null,
      "ended_reason": null,
      "waived_at": null,
      "waiver_actor_id": null,
      "waiver_reason_code": null,
      "outcome": "completed_on_time",
      "days_late": null,
      "reminders_sent": 1,
      "share_ids": [
        "b6d4f2a8-9e1c-4a35-8f72-0e9d8c7b6a54"
      ],
      "doors": [
        "employee"
      ],
      "certificate_id": "f1b9d3e5-6a2c-4d70-8b4e-5c3a2f1e0d98",
      "obligation_change_id": null
    }
  ],
  "total": 1,
  "snapshot": {
    "organization_id": "4f6b2a52-1c1e-4f7a-9d3e-2b7c8a9e0d11",
    "deployment_id": "00000000-0000-4000-8000-000000000001",
    "as_at": "2026-10-09T09:00:00Z",
    "from": null,
    "known_at": "2026-10-09T08:59:41Z",
    "sequence": 1284,
    "capture_sequence": 352,
    "chain_hash": "3f9a1c7e5b2d8f4a6c0e9b1d3f5a7c9e2b4d6f8a0c1e3b5d7f9a2c4e6b8d0f1a",
    "logic": {
      "version": 4,
      "date": "2026-10-06"
    },
    "history_complete_from": "2026-10-06T05:00:00Z",
    "available": true
  },
  "next_cursor": null
}
```

The full contract, with every schema, is at https://api.kapiworks.com/v1/openapi.json.

# Certificates at a moment

`GET https://api.kapiworks.com/v1/certificates`

Employee certificates issued by `as_at`, with their state then. Requires `certificates:read`. Names, work emails and certificate check codes also need `people:read`; without it they are left out.

Scope: `certificates:read`.

## Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `as_at` | string (date-time) or string (date) | No | RFC 3339 instant with an offset, or a date read as the end of the company's day. Defaults to now. |
| `cursor` | string | No | Opaque next_cursor from the previous page. Send it unchanged to the same endpoint. It pins the snapshot and filters, and grants no access. |
| `limit` | integer | No | Rows per page, 1 to 200. Larger values are treated as 200. A page can hold fewer rows to stay within 3 MiB, so follow next_cursor until it is null. |

## Responses

| Status | Description |
| --- | --- |
| 200 | One page from a pinned company snapshot. |
| 400 | RFC 9457 problem details. Codes: invalid_request, invalid_cursor, key_in_url. Act on `code` or `type`; never parse `detail`. |
| 401 | RFC 9457 problem details. Codes: invalid_token. Act on `code` or `type`; never parse `detail`. |
| 403 | RFC 9457 problem details. Codes: insufficient_scope, ip_not_allowed. Act on `code` or `type`; never parse `detail`. |
| 404 | RFC 9457 problem details. Codes: not_found. Act on `code` or `type`; never parse `detail`. |
| 405 | RFC 9457 problem details. Codes: method_not_allowed. Act on `code` or `type`; never parse `detail`. |
| 422 | RFC 9457 problem details. Codes: before_history, record_too_large. Act on `code` or `type`; never parse `detail`. |
| 429 | RFC 9457 problem details. Codes: rate_limited, concurrent_limit. Act on `code` or `type`; never parse `detail`. |
| 502 | RFC 9457 problem details. Codes: read_failed. Act on `code` or `type`; never parse `detail`. |
| 503 | RFC 9457 problem details. Codes: unavailable, upstream_unavailable, writer_unavailable. Act on `code` or `type`; never parse `detail`. |
| 504 | RFC 9457 problem details. Codes: read_timeout. Act on `code` or `type`; never parse `detail`. |

## Fields of each row

| Field | Type | Meaning |
| --- | --- | --- |
| `deployment_id` | string (uuid) | Kapi deployment UUID. |
| `organization_id` | string (uuid) | Company UUID; the tenant boundary. |
| `learning_space_id` | string (uuid) | Learning-space UUID. |
| `certificate_id` | string (uuid) | Certificate UUID. |
| `learner_profile_id` | string (uuid) | Employee profile UUID; retained after erasure. |
| `person_name` | string or null | Name from its current source row. Erased person once Kapi erased the person at the company’s instruction. Certificate names are the issued subject name until then. |
| `course_id` | string (uuid) | Course UUID. |
| `course_release_id` | string or null (uuid) | Immutable published course version UUID. |
| `course_release_version` | integer or null | Published course version number. |
| `certificate_title` | string | Title recorded on the certificate edition. |
| `kind` | string | completion or assessed. |
| `earned_at` | string (date-time) | Completion or assessment time. |
| `issued_at` | string (date-time) | Certificate issue time. |
| `valid_until` | string or null (date-time) | Fixed expiry instant; null means no expiry. |
| `status` | string | Certificate state at the requested moment: valid, expired, revoked or replaced. |
| `changed_at` | string or null (date-time) | Revocation/replacement time, when known at this snapshot. |
| `changed_by` | string or null (uuid) | Actor UUID for revocation/replacement. |
| `supersedes_id` | string or null (uuid) | Earlier certificate corrected by this certificate. |
| `renews_id` | string or null (uuid) | Earlier certificate renewed by this certificate. |
| `check_code` | string | Certificate check code. Returned only with people:read because it opens the public check page, which shows the holder's name. The check page shows nothing once the holder is erased. |
| `live_session_id` | string or null (uuid) | Live session UUID when the person finished the course by attending a live session in full; null otherwise. |
| `live_session_format` | string or null | online or on_site. |
| `live_session_starts_at` | string or null (date-time) | When the live session started. |
| `live_session_ends_at` | string or null (date-time) | When the live session ended. |
| `live_session_minutes` | integer or null | Length of the live session in minutes. |
| `live_session_marked_by` | string or null | Who made the final register mark: trainer, or kapi when Kapi changed it after an ask. |
| `trainer_name` | string or null | Trainer of the live session, from their current Kapi profile; A Kapi expert until they set a name. |

## Example response

```json
{
  "data": [
    {
      "deployment_id": "00000000-0000-4000-8000-000000000001",
      "organization_id": "4f6b2a52-1c1e-4f7a-9d3e-2b7c8a9e0d11",
      "learning_space_id": "8b2d6c3e-5f41-4a8e-9c27-1d0e3f4a5b62",
      "certificate_id": "f1b9d3e5-6a2c-4d70-8b4e-5c3a2f1e0d98",
      "learner_profile_id": "c3a1e7f2-8d64-4b1f-a9e2-6f5d4c3b2a10",
      "person_name": "Amira Okafor",
      "course_id": "d2f8b4a6-7c1e-4e93-b5a0-3c9e8f7d6a21",
      "course_release_id": "e5c2a9d7-4b3f-4c18-9a6e-2f1d0c9b8a77",
      "course_release_version": 3,
      "certificate_title": "Information security essentials",
      "kind": "completion",
      "earned_at": "2026-09-12T13:41:09Z",
      "issued_at": "2026-09-12T13:41:10Z",
      "valid_until": "2027-09-12T13:41:10Z",
      "status": "valid",
      "changed_at": null,
      "changed_by": null,
      "supersedes_id": null,
      "renews_id": null,
      "check_code": "KW7Q4M2X9P",
      "live_session_id": null,
      "live_session_format": null,
      "live_session_starts_at": null,
      "live_session_ends_at": null,
      "live_session_minutes": null,
      "live_session_marked_by": null,
      "trainer_name": null
    }
  ],
  "total": 1,
  "snapshot": {
    "organization_id": "4f6b2a52-1c1e-4f7a-9d3e-2b7c8a9e0d11",
    "deployment_id": "00000000-0000-4000-8000-000000000001",
    "as_at": "2026-10-09T09:00:00Z",
    "from": null,
    "known_at": "2026-10-09T08:59:41Z",
    "sequence": 1284,
    "capture_sequence": 352,
    "chain_hash": "3f9a1c7e5b2d8f4a6c0e9b1d3f5a7c9e2b4d6f8a0c1e3b5d7f9a2c4e6b8d0f1a",
    "logic": {
      "version": 4,
      "date": "2026-10-06"
    },
    "history_complete_from": "2026-10-06T05:00:00Z",
    "available": true
  },
  "next_cursor": null
}
```

The full contract, with every schema, is at https://api.kapiworks.com/v1/openapi.json.

# Employees and invitations during a period

`GET https://api.kapiworks.com/v1/people`

Employees and invitation records that existed between `from` and `to`. Requires `people:read`. Names, work emails and certificate check codes also need `people:read`; without it they are left out.

Scope: `people:read`.

## Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `from` | string (date-time) or string (date) | No | Required on the first page. Period start, inclusive; a date starts the company's day. Omit it with a cursor, which keeps it. At most 400 days before `to`. |
| `to` | string (date-time) or string (date) | No | Required on the first page. Period end, inclusive, as an instant or a company date. Omit it with a cursor, which keeps it. |
| `cursor` | string | No | Opaque next_cursor from the previous page. Send it unchanged to the same endpoint. It pins the snapshot and filters, and grants no access. |
| `limit` | integer | No | Rows per page, 1 to 200. Larger values are treated as 200. A page can hold fewer rows to stay within 3 MiB, so follow next_cursor until it is null. |

## Responses

| Status | Description |
| --- | --- |
| 200 | One page from a pinned company snapshot. |
| 400 | RFC 9457 problem details. Codes: invalid_request, invalid_cursor, key_in_url. Act on `code` or `type`; never parse `detail`. |
| 401 | RFC 9457 problem details. Codes: invalid_token. Act on `code` or `type`; never parse `detail`. |
| 403 | RFC 9457 problem details. Codes: insufficient_scope, ip_not_allowed. Act on `code` or `type`; never parse `detail`. |
| 404 | RFC 9457 problem details. Codes: not_found. Act on `code` or `type`; never parse `detail`. |
| 405 | RFC 9457 problem details. Codes: method_not_allowed. Act on `code` or `type`; never parse `detail`. |
| 422 | RFC 9457 problem details. Codes: before_history, record_too_large. Act on `code` or `type`; never parse `detail`. |
| 429 | RFC 9457 problem details. Codes: rate_limited, concurrent_limit. Act on `code` or `type`; never parse `detail`. |
| 502 | RFC 9457 problem details. Codes: read_failed. Act on `code` or `type`; never parse `detail`. |
| 503 | RFC 9457 problem details. Codes: unavailable, upstream_unavailable, writer_unavailable. Act on `code` or `type`; never parse `detail`. |
| 504 | RFC 9457 problem details. Codes: read_timeout. Act on `code` or `type`; never parse `detail`. |

## Fields of each row

| Field | Type | Meaning |
| --- | --- | --- |
| `deployment_id` | string (uuid) | Kapi deployment UUID. |
| `organization_id` | string (uuid) | Company UUID; the tenant boundary. |
| `learning_space_id` | string (uuid) | Learning-space UUID. |
| `sequence` | integer | Sequence of this stored row version in the company history; snapshot.sequence is the upper boundary for the pull. |
| `valid_from` | string (date-time) | When these facts took effect. |
| `recorded_at` | string (date-time) | When the history writer captured them. |
| `cause` | string | first, change or sweep. |
| `logic_version` | integer | Version of the shared owed-learning decider. |
| `person_id` | string (uuid) | Stable employee or invitation row UUID. |
| `learner_profile_id` | string or null (uuid) | Employee profile UUID; retained after erasure. |
| `invitation_id` | string or null (uuid) | Invitation UUID for invitation rows. |
| `entry_type` | string | employee or invitation; accepted invitations and employees are distinct source rows. |
| `person_name` | string or null | Name from its current source row. Erased person once Kapi erased the person at the company’s instruction. Certificate names are the issued subject name until then. |
| `work_email` | string or null | Address on the current source employee invitation; not the Auth account email. Null for an erased person. |
| `invited_at` | string or null (date-time) | Invitation creation time. |
| `joined_at` | string or null (date-time) | When the person joined Kapi; not an employment start date. |
| `left_at` | string or null (date-time) | When access ended in Kapi. |
| `invitation_expires_at` | string or null (date-time) | Invitation expiry time. |
| `invitation_state` | string or null | Stored invitation state. |
| `invitation_status` | string or null | Invitation state at the requested moment, including expiry. |
| `access_state` | string | active, suspended, ended, erased, invited or staff_practice. erased means Kapi removed the person at the company’s instruction. |
| `team_ids` | array of string (uuid) | JSON array of company team UUIDs. |
| `doors` | array of string | JSON array of audience doors; includes employee. |
| `records_owed` | integer | Open required employee records at the requested moment. |
| `records_completed` | integer | Completed employee records at the requested moment. |
| `no_required_records` | boolean | True when no required records are open. |

## Example response

```json
{
  "data": [
    {
      "deployment_id": "00000000-0000-4000-8000-000000000001",
      "organization_id": "4f6b2a52-1c1e-4f7a-9d3e-2b7c8a9e0d11",
      "learning_space_id": "8b2d6c3e-5f41-4a8e-9c27-1d0e3f4a5b62",
      "person_id": "c3a1e7f2-8d64-4b1f-a9e2-6f5d4c3b2a10",
      "learner_profile_id": "c3a1e7f2-8d64-4b1f-a9e2-6f5d4c3b2a10",
      "invitation_id": null,
      "entry_type": "employee",
      "sequence": 1190,
      "valid_from": "2026-08-28T10:02:00Z",
      "recorded_at": "2026-08-28T10:02:03Z",
      "cause": "change",
      "logic_version": 4,
      "person_name": "Amira Okafor",
      "work_email": "amira.okafor@example.com",
      "invited_at": "2026-08-27T09:15:00Z",
      "joined_at": "2026-08-28T10:02:00Z",
      "left_at": null,
      "invitation_expires_at": null,
      "invitation_state": null,
      "invitation_status": null,
      "access_state": "active",
      "team_ids": [
        "b6d4f2a8-9e1c-4a35-8f72-0e9d8c7b6a54"
      ],
      "doors": [
        "employee"
      ],
      "records_owed": 4,
      "records_completed": 3,
      "no_required_records": false
    }
  ],
  "total": 1,
  "snapshot": {
    "organization_id": "4f6b2a52-1c1e-4f7a-9d3e-2b7c8a9e0d11",
    "deployment_id": "00000000-0000-4000-8000-000000000001",
    "as_at": "2026-10-09T09:00:00Z",
    "from": null,
    "known_at": "2026-10-09T08:59:41Z",
    "sequence": 1284,
    "capture_sequence": 352,
    "chain_hash": "3f9a1c7e5b2d8f4a6c0e9b1d3f5a7c9e2b4d6f8a0c1e3b5d7f9a2c4e6b8d0f1a",
    "logic": {
      "version": 4,
      "date": "2026-10-06"
    },
    "history_complete_from": "2026-10-06T05:00:00Z",
    "available": true
  },
  "next_cursor": null
}
```

The full contract, with every schema, is at https://api.kapiworks.com/v1/openapi.json.

# Course versions referenced by employee records

`GET https://api.kapiworks.com/v1/courses`

The published course versions behind employee training records, with their content fingerprints. Requires `training:read`. Names, work emails and certificate check codes also need `people:read`; without it they are left out.

Scope: `training:read`.

## Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `cursor` | string | No | Opaque next_cursor from the previous page. Send it unchanged to the same endpoint. It pins the snapshot and filters, and grants no access. |
| `limit` | integer | No | Rows per page, 1 to 200. Larger values are treated as 200. A page can hold fewer rows to stay within 3 MiB, so follow next_cursor until it is null. |

## Responses

| Status | Description |
| --- | --- |
| 200 | One page from a pinned company snapshot. |
| 400 | RFC 9457 problem details. Codes: invalid_request, invalid_cursor, key_in_url. Act on `code` or `type`; never parse `detail`. |
| 401 | RFC 9457 problem details. Codes: invalid_token. Act on `code` or `type`; never parse `detail`. |
| 403 | RFC 9457 problem details. Codes: insufficient_scope, ip_not_allowed. Act on `code` or `type`; never parse `detail`. |
| 404 | RFC 9457 problem details. Codes: not_found. Act on `code` or `type`; never parse `detail`. |
| 405 | RFC 9457 problem details. Codes: method_not_allowed. Act on `code` or `type`; never parse `detail`. |
| 422 | RFC 9457 problem details. Codes: before_history, record_too_large. Act on `code` or `type`; never parse `detail`. |
| 429 | RFC 9457 problem details. Codes: rate_limited, concurrent_limit. Act on `code` or `type`; never parse `detail`. |
| 502 | RFC 9457 problem details. Codes: read_failed. Act on `code` or `type`; never parse `detail`. |
| 503 | RFC 9457 problem details. Codes: unavailable, upstream_unavailable, writer_unavailable. Act on `code` or `type`; never parse `detail`. |
| 504 | RFC 9457 problem details. Codes: read_timeout. Act on `code` or `type`; never parse `detail`. |

## Fields of each row

| Field | Type | Meaning |
| --- | --- | --- |
| `row_id` | string (uuid) | Stable course release row UUID. |
| `course_id` | string (uuid) | Course UUID. |
| `course_release_id` | string or null (uuid) | Immutable published course version UUID. |
| `course_title` | string or null | Title from the current source course. |
| `version` | integer or null | Published version number. |
| `published_at` | string or null (date-time) | Time the release was sealed. |
| `publisher_id` | string or null (uuid) | Publisher UUID. |
| `content_sha256` | string or null | Immutable course content fingerprint. |
| `contents_sha256` | string or null | Fingerprint of the list of contained releases. |
| `source_kind` | string | course_release, release_unavailable or lesson_share_without_course_release. |
| `lessons` | array of object | JSON array of lesson release IDs, current source titles, versions and content SHA-256 values. |

## Example response

```json
{
  "data": [
    {
      "row_id": "e5c2a9d7-4b3f-4c18-9a6e-2f1d0c9b8a77",
      "course_id": "d2f8b4a6-7c1e-4e93-b5a0-3c9e8f7d6a21",
      "course_release_id": "e5c2a9d7-4b3f-4c18-9a6e-2f1d0c9b8a77",
      "course_title": "Information security essentials",
      "version": 3,
      "published_at": "2026-08-20T16:30:00Z",
      "publisher_id": "7a6b5c4d-3e2f-4a1b-9c8d-7e6f5a4b3c21",
      "content_sha256": "8d2f6a0c4e9b1d3f5a7c9e2b4d6f8a0c1e3b5d7f9a2c4e6b8d0f1a3c5e7b9d2f",
      "contents_sha256": "1a3c5e7b9d2f4a6c8e0b2d4f6a8c0e2b4d6f8a1c3e5b7d9f0a2c4e6b8d1f3a5c",
      "source_kind": "course_release",
      "lessons": [
        {
          "release_id": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b10",
          "title": "Spotting a phishing email",
          "version": 2,
          "content_sha256": "6c8e0b2d4f6a8c0e2b4d6f8a1c3e5b7d9f0a2c4e6b8d1f3a5c7e9b2d4f6a8c0e"
        }
      ]
    }
  ],
  "total": 1,
  "snapshot": {
    "organization_id": "4f6b2a52-1c1e-4f7a-9d3e-2b7c8a9e0d11",
    "deployment_id": "00000000-0000-4000-8000-000000000001",
    "as_at": "2026-10-09T09:00:00Z",
    "from": null,
    "known_at": "2026-10-09T08:59:41Z",
    "sequence": 1284,
    "capture_sequence": 352,
    "chain_hash": "3f9a1c7e5b2d8f4a6c0e9b1d3f5a7c9e2b4d6f8a0c1e3b5d7f9a2c4e6b8d0f1a",
    "logic": {
      "version": 4,
      "date": "2026-10-06"
    },
    "history_complete_from": "2026-10-06T05:00:00Z",
    "available": true
  },
  "next_cursor": null
}
```

The full contract, with every schema, is at https://api.kapiworks.com/v1/openapi.json.

# Check whether anything changed

`GET https://api.kapiworks.com/v1/snapshot`

Returns the company's latest snapshot in one call, with any scope. If `sequence` and `capture_sequence` match your last complete pull, no recorded facts have changed since. Outcomes that depend on the clock, such as `not_due_yet` becoming `overdue`, can still change, so recompute them from `due_at` or pull again at least daily.

Scope: any scope.

## Responses

| Status | Description |
| --- | --- |
| 200 | The latest snapshot. |
| 400 | RFC 9457 problem details. Codes: invalid_request, invalid_cursor, key_in_url. Act on `code` or `type`; never parse `detail`. |
| 401 | RFC 9457 problem details. Codes: invalid_token. Act on `code` or `type`; never parse `detail`. |
| 403 | RFC 9457 problem details. Codes: insufficient_scope, ip_not_allowed. Act on `code` or `type`; never parse `detail`. |
| 404 | RFC 9457 problem details. Codes: not_found. Act on `code` or `type`; never parse `detail`. |
| 405 | RFC 9457 problem details. Codes: method_not_allowed. Act on `code` or `type`; never parse `detail`. |
| 422 | RFC 9457 problem details. Codes: before_history, record_too_large. Act on `code` or `type`; never parse `detail`. |
| 429 | RFC 9457 problem details. Codes: rate_limited, concurrent_limit. Act on `code` or `type`; never parse `detail`. |
| 502 | RFC 9457 problem details. Codes: read_failed. Act on `code` or `type`; never parse `detail`. |
| 503 | RFC 9457 problem details. Codes: unavailable, upstream_unavailable, writer_unavailable. Act on `code` or `type`; never parse `detail`. |
| 504 | RFC 9457 problem details. Codes: read_timeout. Act on `code` or `type`; never parse `detail`. |

## Example response

```json
{
  "snapshot": {
    "organization_id": "4f6b2a52-1c1e-4f7a-9d3e-2b7c8a9e0d11",
    "deployment_id": "00000000-0000-4000-8000-000000000001",
    "as_at": "2026-10-09T09:00:00Z",
    "from": null,
    "known_at": "2026-10-09T08:59:41Z",
    "sequence": 1284,
    "capture_sequence": 352,
    "chain_hash": "3f9a1c7e5b2d8f4a6c0e9b1d3f5a7c9e2b4d6f8a0c1e3b5d7f9a2c4e6b8d0f1a",
    "logic": {
      "version": 4,
      "date": "2026-10-06"
    },
    "history_complete_from": "2026-10-06T05:00:00Z",
    "available": true
  }
}
```

The full contract, with every schema, is at https://api.kapiworks.com/v1/openapi.json.

# invalid_request

Invalid request. HTTP 400.

## What it means

Check the query values. Send API keys only in the Authorization header.

## What to do

Fix the query and send it again. Unknown or repeated parameters, malformed dates and IDs, and periods longer than 400 days are refused.

## Example response

```json
{
  "type": "https://docs.kapiworks.com/errors/invalid_request",
  "title": "Invalid request",
  "status": 400,
  "detail": "Check the query values. Send API keys only in the Authorization header.",
  "code": "invalid_request"
}
```

Errors use `application/problem+json` (RFC 9457). Act on `code` or `type`; `detail` is for people and can change.

# invalid_cursor

Invalid cursor. HTTP 400.

## What it means

Use the cursor returned by the previous page with the same filters.

## What to do

Start the pull again without a cursor. A cursor works only on the endpoint and filters that produced it, for the same company.

## Example response

```json
{
  "type": "https://docs.kapiworks.com/errors/invalid_cursor",
  "title": "Invalid cursor",
  "status": 400,
  "detail": "Use the cursor returned by the previous page with the same filters.",
  "code": "invalid_cursor"
}
```

Errors use `application/problem+json` (RFC 9457). Act on `code` or `type`; `detail` is for people and can change.

# key_in_url

API key revoked. HTTP 400.

## What it means

The API key was sent in the URL, so Kapi revoked it.

## What to do

A URL can be stored in logs and browser history. Ask the company administrator for a new key, and send it only in the Authorization header.

## Example response

```json
{
  "type": "https://docs.kapiworks.com/errors/key_in_url",
  "title": "API key revoked",
  "status": 400,
  "detail": "The API key was sent in the URL, so Kapi revoked it.",
  "code": "key_in_url"
}
```

Errors use `application/problem+json` (RFC 9457). Act on `code` or `type`; `detail` is for people and can change.

# invalid_token

Invalid API key. HTTP 401.

## What it means

The API key is invalid or no longer active.

## What to do

Stop polling and ask the company administrator for a new key. Unknown, expired and revoked keys get the same answer.

## Headers

A 401 response also sends `WWW-Authenticate`.

## Example response

```json
{
  "type": "https://docs.kapiworks.com/errors/invalid_token",
  "title": "Invalid API key",
  "status": 401,
  "detail": "The API key is invalid or no longer active.",
  "code": "invalid_token"
}
```

Errors use `application/problem+json` (RFC 9457). Act on `code` or `type`; `detail` is for people and can change.

# insufficient_scope

Scope required. HTTP 403.

## What it means

This key does not have the scope required for this register.

## What to do

Ask the company administrator for a key with the scope named in the WWW-Authenticate header. Retrying the same key does not help.

## Headers

A 403 response also sends `WWW-Authenticate`.

## Example response

```json
{
  "type": "https://docs.kapiworks.com/errors/insufficient_scope",
  "title": "Scope required",
  "status": 403,
  "detail": "This key does not have the scope required for this register.",
  "code": "insufficient_scope"
}
```

Errors use `application/problem+json` (RFC 9457). Act on `code` or `type`; `detail` is for people and can change.

# ip_not_allowed

Network not allowed. HTTP 403.

## What it means

This key works only from the networks its company chose.

## What to do

Call from one of the key's networks, or ask the company administrator for a key that allows yours.

## Headers

A 403 response also sends `WWW-Authenticate`.

## Example response

```json
{
  "type": "https://docs.kapiworks.com/errors/ip_not_allowed",
  "title": "Network not allowed",
  "status": 403,
  "detail": "This key works only from the networks its company chose.",
  "code": "ip_not_allowed"
}
```

Errors use `application/problem+json` (RFC 9457). Act on `code` or `type`; `detail` is for people and can change.

# not_found

Not found. HTTP 404.

## What it means

This API address is unavailable.

## What to do

Use a documented path on the API host.

## Example response

```json
{
  "type": "https://docs.kapiworks.com/errors/not_found",
  "title": "Not found",
  "status": 404,
  "detail": "This API address is unavailable.",
  "code": "not_found"
}
```

Errors use `application/problem+json` (RFC 9457). Act on `code` or `type`; `detail` is for people and can change.

# method_not_allowed

Method not allowed. HTTP 405.

## What it means

Use GET or HEAD for this API.

## What to do

The API is read-only. The Allow header lists the methods it accepts.

## Headers

A 405 response also sends `Allow`.

## Example response

```json
{
  "type": "https://docs.kapiworks.com/errors/method_not_allowed",
  "title": "Method not allowed",
  "status": 405,
  "detail": "Use GET or HEAD for this API.",
  "code": "method_not_allowed"
}
```

Errors use `application/problem+json` (RFC 9457). Act on `code` or `type`; `detail` is for people and can change.

# before_history

History unavailable. HTTP 422.

## What it means

Records are not available before collection began.

## What to do

Use history_complete_from as the earliest moment you ask for. Earlier records are unavailable, not empty.

## Example response

```json
{
  "type": "https://docs.kapiworks.com/errors/before_history",
  "title": "History unavailable",
  "status": 422,
  "detail": "Records are not available before collection began.",
  "code": "before_history"
}
```

Errors use `application/problem+json` (RFC 9457). Act on `code` or `type`; `detail` is for people and can change.

# record_too_large

Record too large. HTTP 422.

## What it means

A record exceeds this API's response limit. Use a records pack and report the request ID to Kapi.

## What to do

Use a records pack for this register, and report the request ID to Kapi support.

## Example response

```json
{
  "type": "https://docs.kapiworks.com/errors/record_too_large",
  "title": "Record too large",
  "status": 422,
  "detail": "A record exceeds this API's response limit. Use a records pack and report the request ID to Kapi.",
  "code": "record_too_large"
}
```

Errors use `application/problem+json` (RFC 9457). Act on `code` or `type`; `detail` is for people and can change.

# rate_limited

Too many requests. HTTP 429.

## What it means

This key or its company has used its calls for this minute.

## What to do

Wait at least Retry-After seconds, then add a small random delay. Refused calls do not count against the limit.

## Headers

A 429 response also sends `Retry-After`, `RateLimit-Policy`, `RateLimit`.

## Example response

```json
{
  "type": "https://docs.kapiworks.com/errors/rate_limited",
  "title": "Too many requests",
  "status": 429,
  "detail": "This key or its company has used its calls for this minute.",
  "code": "rate_limited"
}
```

Errors use `application/problem+json` (RFC 9457). Act on `code` or `type`; `detail` is for people and can change.

# concurrent_limit

Too many requests at once. HTTP 429.

## What it means

Two calls are already running for this company.

## What to do

Wait for the running calls to finish, then retry after Retry-After seconds. Run one pull at a time per company.

## Headers

A 429 response also sends `Retry-After`, `RateLimit-Policy`, `RateLimit`.

## Example response

```json
{
  "type": "https://docs.kapiworks.com/errors/concurrent_limit",
  "title": "Too many requests at once",
  "status": 429,
  "detail": "Two calls are already running for this company.",
  "code": "concurrent_limit"
}
```

Errors use `application/problem+json` (RFC 9457). Act on `code` or `type`; `detail` is for people and can change.

# read_failed

Records unavailable. HTTP 502.

## What it means

The records could not be read. Try again.

## What to do

Keep your last complete pull. Retry with exponential backoff, and report the request ID if it keeps failing.

## Example response

```json
{
  "type": "https://docs.kapiworks.com/errors/read_failed",
  "title": "Records unavailable",
  "status": 502,
  "detail": "The records could not be read. Try again.",
  "code": "read_failed"
}
```

Errors use `application/problem+json` (RFC 9457). Act on `code` or `type`; `detail` is for people and can change.

# unavailable

API unavailable. HTTP 503.

## What it means

The API is not configured for this deployment.

## What to do

Contact Kapi support with the request ID. Retrying does not help until the configuration is fixed.

## Headers

A 503 response also sends `Retry-After`.

## Example response

```json
{
  "type": "https://docs.kapiworks.com/errors/unavailable",
  "title": "API unavailable",
  "status": 503,
  "detail": "The API is not configured for this deployment.",
  "code": "unavailable"
}
```

Errors use `application/problem+json` (RFC 9457). Act on `code` or `type`; `detail` is for people and can change.

# upstream_unavailable

Temporarily unavailable. HTTP 503.

## What it means

Kapi could not reach its records service. Try again shortly.

## What to do

Retry after Retry-After seconds with exponential backoff and jitter. Keep your last complete pull meanwhile.

## Headers

A 503 response also sends `Retry-After`.

## Example response

```json
{
  "type": "https://docs.kapiworks.com/errors/upstream_unavailable",
  "title": "Temporarily unavailable",
  "status": 503,
  "detail": "Kapi could not reach its records service. Try again shortly.",
  "code": "upstream_unavailable"
}
```

Errors use `application/problem+json` (RFC 9457). Act on `code` or `type`; `detail` is for people and can change.

# writer_unavailable

History catching up. HTTP 503.

## What it means

Recent changes are still being recorded. Try again shortly.

## What to do

Retry after Retry-After seconds. A pull already in progress can still finish from its cursor.

## Headers

A 503 response also sends `Retry-After`.

## Example response

```json
{
  "type": "https://docs.kapiworks.com/errors/writer_unavailable",
  "title": "History catching up",
  "status": 503,
  "detail": "Recent changes are still being recorded. Try again shortly.",
  "code": "writer_unavailable"
}
```

Errors use `application/problem+json` (RFC 9457). Act on `code` or `type`; `detail` is for people and can change.

# read_timeout

Read timed out. HTTP 504.

## What it means

The page took too long to read. Try again with a smaller page.

## What to do

Retry with a smaller limit, then return to your usual page size.

## Example response

```json
{
  "type": "https://docs.kapiworks.com/errors/read_timeout",
  "title": "Read timed out",
  "status": 504,
  "detail": "The page took too long to read. Try again with a smaller page.",
  "code": "read_timeout"
}
```

Errors use `application/problem+json` (RFC 9457). Act on `code` or `type`; `detail` is for people and can change.