# API reference The full, searchable reference is the Integration page of the running app (`/integration`). It renders `web/src/data/apiCatalogue.ts`, which is the single source for this page. This file is for readers without the UI. ## Base URL The origin that serves the app, for example `https://cms.example`. Every path starts with `/v1`, for example `https://cms.example/v1/article/`. ## Authentication - Public routes need nothing: `GET /v1/article/` and `GET /v1/article/{id}`. They return online articles only. - Everything else needs a signed-in browser session (the `jwt` cookie) or an API key: `Authorization: Bearer rcms_...`. Create keys at `/integration/api-keys`. A key acts as its creator, with that user's role, and shows in full once. - A key cannot call `POST /v1/auth/verify`, `DELETE /v1/auth/register`, `/v1/api-key/*` or `/v1/system/*`. Those answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes. - Each key can make 120 requests per minute. Beyond that: 429 with a `Retry-After` header. ## Routes a key can call (24) | Group | Routes | | --- | --- | | Articles | GET /v1/article/, GET /v1/article/{id}, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id} | | Media | GET /v1/media/, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage, GET /v1/media/purge-settings (owner or admin), PATCH /v1/media/purge-settings (owner or admin), GET /v1/media/purge-preview (owner or admin), POST /v1/media/purge (owner or admin) | | Users and invites | GET /v1/user/, PATCH /v1/user/me, POST /v1/user/invite (owner only), DELETE /v1/user/invite/{id} (owner only), POST /v1/user/invite/{id}/renew (owner only), GET /v1/auth/me | | Settings | GET /v1/settings | ## Activity log The activity routes are session only: a key answers 403 "This action needs a real session." and a key never reads the audit trail. Events are append-only; there is no `POST`, `PUT`, `PATCH` or `DELETE` under `/v1/activity`. | Route | Who | Purpose | | --- | --- | --- | | `GET /v1/activity` | Any verified user | Filtered, paged events. Owner and admin see every event; any other role is scoped to its own events, and `actor` set to someone else answers 403 "Only owners and admins can do this." | | `GET /v1/activity/summary` | Owner, admin | Counts for the range, the previous period, active people and seven-bucket series. | | `GET /v1/activity/export` | Owner, admin | The same filters as the list without `page` or `size`, as a `text/csv; charset=utf-8` attachment (`when,actor,action,target,outcome,source,ip`). Cells starting with `=`, `+`, `-` or `@` get a leading quote. | Query: `q`, `actor` (`user:ID`, `key:ID`, `system`), `category` (`articles`, `media`, `users`, `api_keys`, `auth`, `system`), `outcome` (`all`, `success`, `failed`), `security` (`true`), `range` (`today`, `7d`, `30d`, `custom`; default `7d`), `from` and `to` (dates, with `custom`), `page` (from 1) and `size` (25, 50 or 100). Any invalid value answers 400 with the body-mismatch message. Dates are UTC. The list answers `{ items, total, page, size }`, newest first (`created_at`, then `id`). `GET /v1/settings` also returns `activity_retention_days` (30, 90, 365 or 0 for forever, default 90), `activity_retention_max_gb` (0 for no size limit), `activity_log_size_bytes`, `activity_last_cleanup_at` and `activity_last_cleanup_removed`. `PATCH /v1/settings` with `{"activity_retention_days": 30}`, `{"activity_retention_max_gb": 5}` or both is session only and owner only (403 "Only the owner can do this."); any other value answers 400. The daily cleanup removes events past the age limit, then the oldest events until the log fits the size limit. Whichever limit is reached first wins. ### What is recorded Every real action is recorded by its use case, once, after the change was made (outcome `success`) or after the use case refused it (outcome `failed`, with a plain `failure_reason`). Nothing is recorded for a read (no `GET`, no list, no public article read, no status poll), for a request that fails validation, or for a target that does not exist (404). A failed audit write is logged and never changes the response or the change itself. | Category | Actions | | --- | --- | | `articles` | `article.create`, `article.edit`, `article.publish`, `article.unpublish`, `article.delete` | | `media` | `media.upload`, `media.rename`, `media.delete`, `media.merge`, `media.purge` | | `users` | `user.register`, `user.verify`, `user.update_profile`, `invite.create`, `invite.cancel`, `invite.renew`, `invite.accept` | | `system` | `system.media_purge` (scheduled), `system.update_checked`, `system.update_downloaded`, `system.restart_requested`, `system.activity_cleanup`, `system.activity_retention_changed` | Target types: `article`, `image`, `user`, `invite`, `api_key` and `release` (id and name are the tag). Only the fields `title`, `online`, `body`, `filename` and `display_name` appear in `changes`, with the kinds `text` and `body`; a body edit carries a signed character count delta and never the text. A change that leaves a value as it was (same body, same publish state, same file name, same display name) records nothing. Params: `media.merge` and `media.purge` and `system.media_purge` carry `merged` and `articles_updated`, plus `failed_groups` when a group failed (still outcome `success`); `system.update_checked` carries `update_available`; `system.restart_requested` carries `kind` and, for an update, `tag` (it has no target). `system.update_downloaded` is recorded when the job reaches `ready` or `failed`, with the origin captured at request time; a refusal at the start is recorded immediately. A failed registration or invite acceptance has an `anonymous` actor named by the attempted username. `failure_reason` is one fixed sentence per domain error, matched with `errors.Is`, at most 255 characters, for example "The title must be between 3 and 200 characters.", "That file name is not valid.", "The merge request is not valid.", "A purge is already running.", "Registration is closed on this site.", "That invitation has expired.", "The release feed could not be reached." or "The update could not be downloaded.". Any other error becomes "Something went wrong on our side. Please try again."; the text of the error never reaches the event, the API or the CSV. Passwords, hashes, codes, article bodies, file bytes, storage paths and asset urls are never recorded. Article routes accept `?format=markdown|html|json` (default `markdown`). `GET /v1/media/` accepts `q`, `hash`, `cursor`, `filter`, `sort` and `limit`. ## Errors Every error is a plain-text body with one sentence; the status is in the HTTP status line. The statuses are 400, 401, 403, 404, 409, 429 and 500. Messages are listed on the Integration page. ## Notes - Images in `body` use paths like `/static/uploadedImages/.png`. Prefix them with the base URL. - Browsers can call the API only from origins in the server's CORS setting.