# 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. Reading articles (pagination, `format`, body schemas) has its own page, [api/articles.md](api/articles.md), and API keys are covered in [api/api-keys.md](api/api-keys.md). ## 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. The site writes (`POST`, `PATCH` and `DELETE` under `/v1/site`) and the author writes (`POST`, `PATCH` and `DELETE` under `/v1/author`, `POST /v1/author/me` included) are session only too. - Each key can make 120 requests per minute. Beyond that: 429 with a `Retry-After` header. ## Routes a key can call (26) | 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), GET /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants/{name} (owner or admin, session only), DELETE /v1/media/{id}/variants/{name} (owner or admin, session only) | | 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 (with the linked `author`) | | Settings | GET /v1/settings | | Sites | GET /v1/site/ | | Authors | GET /v1/author/ | ## 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`, `sites`, `authors`), `outcome` (`all`, `success`, `failed`), `security` (`true`), `range` (`today`, `7d`, `30d`, `all`, `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`, `media.variant_create`, `media.variant_update`, `media.variant_delete`, `media.variant_enable`, `media.variant_disable`, `media.variant_purge`, `media.variant_generate_all`, `media.variant_purge_image`, `media.variant_purge_all`, `media.variant_purge_unused` | | `users` | `user.register`, `user.verify`, `user.update_profile`, `invite.create`, `invite.cancel`, `invite.renew`, `invite.accept` | | `sites` | `site.create`, `site.edit`, `site.set_default`, `site.delete` | | `authors` | `author.create`, `author.edit`, `author.delete` | | `system` | `system.media_purge` (scheduled), `system.update_checked`, `system.update_downloaded`, `system.restart_requested`, `system.activity_cleanup`, `system.activity_retention_changed` | Target types: `article`, `site`, `author`, `image`, `image_variant` (id and name are the variant name), `user`, `invite`, `api_key` and `release` (id and name are the tag). Only the fields `title`, `online`, `body`, `site`, `name`, `base_url`, `default_robots`, `locale`, `default_social_image`, `slug`, `bio`, `website`, `social_links`, `avatar`, `user`, `authors`, `filename`, `display_name`, `width`, `height`, `fit`, `format` and `quality` appear in `changes` (a site key is never a change; moving an article records `site` with the site names; changing the credited authors records `authors` with the names in order; the `user` change of an author holds usernames, never an email), 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`); `media.variant_generate_all` carries `generated`, `failed` and `causes` (a list of `too_large`, `unsupported_format` or `failed`) and is `failed` only when nothing was generated and something failed; `media.variant_purge_image` carries `purged_files` and `purged_bytes`; both have an `image` target and are recorded only when files were generated or removed; `media.variant_purge_all` and `media.variant_purge_unused` (no target) carry `purged_files` and `purged_bytes`, the latter also `older_than_days`; a partial purge adds `failed_files` and stays `success`, and a purge that removed nothing while some files failed is `failed`; nothing is recorded when no file was removed or failed; `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`, `filter` (`all`, `unused` or `duplicates`), `sort` (`newest`, `oldest`, `name`, `largest` or `total_size`, the original plus its generated variants), `variants` (`with` or `without` generated variant files), `page` (a whole number from 1, default 1) and `size` (5, 25, 50 or 100, default 25). The body is `{ "items": [...], "total", "page", "size", "counts" }`: `total` is the number of images matching `q`, `hash`, `filter` and `variants` before paging, `page` and `size` echo the values applied, and `counts` (`all`, `unused`, `duplicates`) stays library-wide, ignoring every filter and the paging. Ordering is applied to the whole matching list before slicing, with the image id as the final tie-break, so pages never overlap or skip. A page past the last answers 200 with `items: []` and the real `total`. Any invalid `page`, `size`, `filter`, `sort`, `variants` or `hash` is 400 "The media query is invalid.". Every item carries `variant_count` and `variant_size` (the index rows of the image and their bytes, 0 when none). Breaking change: `next_cursor` is gone and no `X-Total-Count` header is sent. `cursor` and `limit` are no longer parameters; they are ignored, never an error, and have no effect on paging. ## Sites Every article belongs to exactly one site, and exactly one site is the default. A site is `{ id, name, key, base_url, default_social_image_id, default_robots, locale, is_default, article_count }`: `name` is 1 to 100 characters, `key` is 2 to 32 characters of lowercase letters, digits and single dashes (`^[a-z0-9]+(-[a-z0-9]+)*$`) and never changes, `base_url` is `https` (or `http` for `localhost`, `127.0.0.1` and `[::1]`) with no path, query or user info (a trailing slash is stripped, uniqueness is not enforced), `default_robots` is `index` (default) or `noindex`, `locale` is `null` or a tag such as `en` or `fr-CA` (an empty string means none) and `default_social_image_id` is `null` or the id of an image. Read access needs a verified session or an API key; every write is session only and owner only (403 "Only the owner can do this.", or "This action needs a real session." with a key). The role is checked before the body. | Route | Purpose | | --- | --- | | `GET /v1/site/` | The sites, default first and then by name, each with `article_count`. | | `POST /v1/site/` | Create. 201 with the site. `is_default: true` makes it the default. | | `PATCH /v1/site/{id}` | Edit `name`, `base_url`, `default_robots`, `locale`, `default_social_image_id`. Absent fields are untouched. `key` may only be repeated unchanged; `is_default` is refused. | | `PATCH /v1/site/{id}/default` | Make the site the default, atomically. Setting the current default is a no-op. | | `DELETE /v1/site/{id}?reassign_to={id}` | 204. A site with articles needs `reassign_to`; the articles move and the site is deleted in one transaction. `reassign_to` is ignored when the site has no article. | Errors are plain text whose body is exactly the token: 409 `site_key_taken`, 409 `site_has_articles`, 403 `default_site` (checked before the articles), 400 `key_immutable`, 400 `invalid_site_url`, 400 `invalid_locale`, 400 `image_not_found`, 400 `use_set_default`, 400 `invalid_reassign` (missing number, the site itself or not a whole number) and 404 `site_not_found`. Refused writes except `site_not_found` and `image_not_found` are recorded as failed events. A body that cannot be decoded is 400 with the body-mismatch message, a malformed id is 400 with the uint32 message, and a field that fails validation (`name`, `key` format, `default_robots`) is 400 with the validator text. Articles carry `site_id`. A new article starts on the default site. `GET /v1/article/?site=` keeps only the articles of that site and composes with `status`, `q`, `sort`, `size` and `page`; `counts` stay global, an unknown key answers 200 with no item and an empty `site=` is 400 with the body-mismatch message. `PATCH /v1/article/{id}` takes `body` and `site_id` (an API key may send it too) and nothing else, such as `title`: absent fields are untouched, an empty object, an object without those fields, a `null` value or a malformed value is 400 with the body-mismatch message, `site_id: 0` is 400 with the validator text and an unknown site is 404 `site_not_found`. Moving an article never changes `is_online`. `{"body": ""}` still sets an empty body, but `{}` and `{"body": null}` no longer do: they are 400. A site's `default_social_image_id` counts as a use of that image: it raises `used_in` and keeps the image out of the `unused` filter, while `GET /v1/media/{id}/usage` keeps listing articles only. Deleting the image sets the field to `null` and succeeds. `POST /v1/media/merge` and `POST /v1/media/purge` repoint the sites of the merged copies to the kept image and report it as `sites_updated` next to `articles_updated`. ## Authors An author is a public byline, with or without an account. It is `{ id, name, slug, bio, avatar_id, website, social_links, user_id, is_linked, article_count }`, plus `user_email` for owners and admins only (absent for every other caller). `name` is 1 to 100 characters after trimming, `slug` matches `^[a-z0-9]+(-[a-z0-9]+)*$` with 1 to 100 characters and is unique (generated from the name when omitted: accents folded, punctuation runs become one dash, `author` when nothing is left), `bio` has at most 280 characters (counted in runes), `avatar_id` is `null` or an image id, `website` is `null` or an `https` URL with a host (an empty string means none, at most 2048 characters), `social_links` holds at most 5 `{ "type", "url" }` with `type` one of `x`, `linkedin`, `github`, `mastodon`, `custom` and an `https` URL. `user_id` is the unique link to a user. Zero authors on an article is allowed and never blocks publishing. | Route | Who | Purpose | | --- | --- | --- | | `GET /v1/author/` | verified session or API key | The authors newest first. Query `q` (name or slug, accent and case insensitive), `page` (default 1), `size` (5, 25, 50 or 100, default 25). Body `{ items, total, page, size }`; a bad query is 400 with the body-mismatch message. | | `POST /v1/author/` | owner or admin, session only | Create. 201 with the author. `user_id` links an account that has no author. | | `POST /v1/author/me` | any verified session | Create the caller's own author from the display name (or the username). A taken slug gets `-2`, `-3`... silently. | | `PATCH /v1/author/{id}` | owner or admin on any author; any verified session on the author linked to it; session only | Edit `name`, `slug`, `bio`, `avatar_id`, `website`, `social_links`, `user_id`. Absent fields are untouched, `avatar_id`, `website` and `user_id` accept `null`, `social_links` is replaced as a whole. `user_id` links or unlinks the account and is owner or admin only. | | `DELETE /v1/author/{id}?reassign_to={id\|none}` | owner or admin, session only | 204. An author credited on articles needs `reassign_to`: an author id (that author takes the deleted one's position and is never duplicated on an article) or `none` (the credit is removed). The move and the delete are one transaction. `reassign_to` is ignored when the author has no article. The linked account is untouched. | The role checks use the existing bodies: 403 "Only owners and admins can do this." for `POST /` and `DELETE`, 403 `author_rights` for a `PATCH` by an account that is not owner or admin on an author that is not its own (or that sends `user_id`), and 403 "This action needs a real session." for a key. Errors are plain text whose body is exactly the token: 404 `author_not_found`, 404 `user_not_found`, 409 `author_slug_taken`, 409 `author_has_articles`, 409 `user_already_linked`, 409 `author_already_linked`, 400 `invalid_slug`, 400 `invalid_reassign` (own id, not a whole number, negative or empty), 400 `image_not_found`, 422 `bio_too_long`, 422 `invalid_url` and 422 `too_many_links`. A body that cannot be decoded, an empty `PATCH` or one that nulls a required field is 400 with the body-mismatch message, a malformed id is 400 with the uint32 message and other validation failures (`name`, link `type`) are 400 with the validator text. Writes refused for a reason other than a missing target or an invalid value are recorded as failed events. Articles carry `authors`, the ordered credits (the first is the primary byline), each with only `id`, `name`, `slug`, `bio`, `avatar_id`, `website` and `social_links`; it is `[]` when there is none and it is visible on online articles to anonymous readers. `PATCH /v1/article/{id}` also takes `author_ids`, the whole ordered list: it replaces the credits, `[]` empties them, an unknown id is 404 `author_not_found`, a duplicate, `null` or a malformed list is 400 with the body-mismatch message and `0` is 400 with the validator text. Absent means unchanged. A new article is credited to the creator's linked author when there is one. `GET /v1/auth/me` returns the existing user fields plus `author`: the caller's linked author (`id`, `name`, `slug`, `bio`, `avatar_id`, `website`, `social_links`, `article_count`) or `null`. An author's `avatar_id` counts as a use of that image like a site's social image: it raises `used_in` and keeps the image out of the `unused` filter, while `GET /v1/media/{id}/usage` keeps listing articles only. Deleting the image sets `avatar_id` to `null`. `POST /v1/media/merge` and `POST /v1/media/purge` repoint the avatars of the merged copies and report it as `authors_updated`. ## Image variants Owners and admins manage named variants of the stored images. These routes are session only (no API key) and sit behind the verified-user check; any other role gets 403 "Only owners and admins can do this." Behaviour, caching and the public `?v=` contract are in [image-variants.md](image-variants.md). | Route | Purpose | | --- | --- | | `GET /v1/image-variants` | `{ "items": [...] }`, ordered by name. | | `POST /v1/image-variants` | Create. 201 with the variant. 409 when the name exists. | | `PUT /v1/image-variants/{name}` | Replace the definition (the name in the body is ignored). 200 with the variant and `purged_files`. | | `PATCH /v1/image-variants/{name}` | `{ "enabled": true }` or `{ "enabled": false }`. Keeps the generated files. | | `DELETE /v1/image-variants/{name}` | 204. Removes the definition and its generated files. | | `POST /v1/image-variants/{name}/purge` | `{ "purged_files", "purged_bytes" }`. Keeps the definition. | | `GET /v1/image-variants/cache` | Cache statistics, see below. | | `POST /v1/image-variants/cache/purge` | Purge generated files by scope, see below. | A variant is `{ name, width, height, fit, format, quality, generate_on_upload, enabled, generated_files, cache_size, created_at, updated_at }`. `width` and `height` are 1 to 8000 or `null` (at least one is required), `fit` is `cover`, `contain` or `inside` (default `cover`), `format` is `original`, `jpeg`, `png` or `webp` (default `original`), `quality` is 1 to 100 (default 82, `null` for `png`), `generate_on_upload` defaults to `false`, `enabled` to `true`, and `cache_size` is in bytes. A `PUT` that omits `generate_on_upload` or `enabled` keeps the stored value. Errors are plain text: 400 for a body that is not valid JSON, 404 "Variant not found", 409 "A variant named “thumb” already exists. Choose another name." and 422 with one sentence for the first invalid field (name, width and height, fit, format, quality). ### Cache statistics and purge by scope `GET /v1/image-variants/cache` answers `{ generated_files, disk_used, variants_defined, variants_enabled, variants_disabled, rejected_requests, rejected_window_days, unused: { files, bytes, days }, by_variant: [{ name, enabled, files, bytes }], top_images: [{ id, filename, url, files, bytes }] }`. `by_variant` lists every defined variant by bytes descending then name; `top_images` lists the ten images using the most variant bytes (ties by id, images that no longer exist are skipped). `unused` counts the files of enabled variants whose reference time (the last public request, or the creation time when never requested) is older than 30 days. `rejected_requests` counts the public requests refused with 400 over today and the six previous UTC days. Pending request tracking is flushed before the read. Arrays are never `null`. `POST /v1/image-variants/cache/purge` takes `{ "scope": "all" | "unused" | "variant" | "image", "variant": "", "image_id": }` (`variant` for the scope `variant`, `image_id` for `image`; a target given for another scope is ignored) and answers `{ scope, purged_files, purged_bytes, failed_files }`. A purge that fails on some files still answers 200 with `failed_files` above 0; the failed rows are kept so the same request can be sent again. Errors are plain text: 400 for a body that is not valid JSON, 422 "Scope must be all, unused, variant or image." or "A variant name or an image id is required for this scope.", 404 "Variant not found" or "Image not found", 500 when the rows cannot be listed. ### Variants of one image Owners and admins only, session only: a request with an API key is 401. All ids are image ids. | Route | Purpose | | --- | --- | | `GET /v1/media/{id}/variants` | `{ "unsupported": null, "items": [...] }` for the enabled variants in name order. `unsupported` is `"svg"` (with `items: []`) for an SVG. | | `POST /v1/media/{id}/variants` | Generates every enabled variant that is missing. `{ "generated", "failed", "failures": [{ "name", "cause" }], "items": [...] }`, 200 even when some fail. | | `POST /v1/media/{id}/variants/{name}` | Generates one enabled variant. 200 with the item. | | `DELETE /v1/media/{id}/variants/{name}` | Removes the generated file. `{ "purged_files", "purged_bytes" }`, also for a disabled variant. | An item is `{ name, width, height, fit, format, output_width, output_height, output_format, generated, size, url }`: `width` and `height` are the settings (`null` when auto), `output_*` the size and format the file has or will have (never above the source), `generated` is true only when the file exists with the variant's current settings, `size` is its bytes or `null`, and `url` is `/static/uploadedImages/?v=`. A `cause` is `too_large`, `unsupported_format` or `failed`. A file generated here is the same file the public url serves, and the other way round. Uploads generate the variants flagged `generate_on_upload` (enabled ones, raster sources only) before answering; a failure there is logged and never fails the upload. Errors are plain text: 400 for a malformed id, 404 "Image not found" or "Variant not found" (also for a disabled variant on `POST`), 413 "The source image is too large to generate this variant.", 415 "The source image can't be read, so this variant can't be generated.", 422 "Variants aren't generated for this format." and 500 "Internal server error". ## 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.