Files
RenewCMS/docs/api.md
T

41 KiB

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, and API keys are covered in 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/, GET /v1/article/{id}, GET /v1/article/{id}/seo and GET /v1/site/{key}/sitemap. They return online articles, plus the drafts the caller's role may read (see Content permissions); any other draft answers 404 Article not found.
  • 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.
  • A session lasts 24 hours. POST /v1/auth/logout revokes it on the server, so a copied cookie stops working at once; other sessions of the same user stay valid. A password reset ends every session and revokes every key of that user.
  • A browser POST, PUT, PATCH or DELETE sent from another origin without an Authorization header answers 403 "Cross-origin requests are not allowed." This blocks cross-site request forgery with the cookie; scripts on other origins must send their key.

Routes a key can call (30)

Group Routes
Articles GET /v1/article/, GET /v1/article/{id}, GET /v1/article/{id}/seo, POST /v1/article/{id}/seo/check, 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/, GET /v1/media/{id}, 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/, GET /v1/site/{key}/sitemap
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. A role with activity.view at all sees every event; a role at own is scoped to its own events, and actor set to someone else answers 403 missing_permission.
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, roles), 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 needs system.edit_retention (403 missing_permission); 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, canonical_url, noindex, cover_image, social_image, excerpt, seo_title, seo_description, social_title, social_description, cover_image_alt, site_name, title_template, default_description, 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; the free-text SEO fields (excerpt, seo_title, seo_description, social_title, social_description, cover_image_alt) do the same under their own field name, while slug, canonical_url, noindex, cover_image, social_image (image file names) and the site fields site_name, title_template, default_description carry before and after. seo_dismissed is not an edit and records nothing. 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).

GET /v1/media/{id} reads one image with the same auth as the list (a verified session or an API key; 403 for an unverified account, 401 without credentials) and writes nothing, so it records no activity event. It answers 200 with the object of one items element of the list, a missing image is 404 "Image not found" and a malformed id is 400 with the id-format message.

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 need settings.manage_sites (settings.delete_site for DELETE): 403 missing_permission, 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. Query q (up to 200 characters, matched in name, key or base_url, ignoring case and accents, % and _ literal), page (a whole number from 1) and size (5, 25, 50 or 100). Without page and size every matching site is returned; with only page, size is 25; with only size, page is 1. The body stays a JSON array ([] when nothing matches, never null) and X-Total-Count holds the number of matching sites before paging. A page past the end is 200 [] with the total. An invalid page, size or q is 400 with the body-mismatch message and no X-Total-Count.
POST /v1/site/ Create. 201 with the site. is_default: true makes it the default. site_name, title_template and default_description are optional and validated like on PATCH.
PATCH /v1/site/{id} Edit name, base_url, default_robots, locale, default_social_image_id, site_name, title_template, default_description. 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 invalid_title_template, 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, site_name over 100 characters, title_template over 200, default_description over 320) is 400 with the validator text.

A site also carries site_name (at most 100 characters, "" by default), title_template (at most 200 characters, %title% | %site% by default; it must contain %title%, case sensitive, otherwise 400 invalid_title_template, an empty template included) and default_description (at most 320 characters, "" by default). They appear on every site response, POST accepts them and PATCH edits them like the other site fields. Existing sites get the defaults when the server starts after the upgrade.

Articles carry site_id. A new article starts on the default site. GET /v1/article/?site=<key> 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, site_id, author_ids and the SEO fields below, and nothing else, such as title: absent fields are untouched, an empty object, an object without those fields, a null value (except on cover_image_id and social_image_id) 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.

Resolved SEO, check, list status and sitemap

RenewCMS renders no page and injects no meta tag: it returns the data a frontend needs.

Route Who Purpose
GET /v1/article/{id}/seo public for online articles; a verified session or API key also reads the drafts its role may read The resolved object { title, description, canonical_url, robots, og, twitter, image, authors, published_at, modified_at, fallbacks, json_ld }. It never carries the body, the id or seo_dismissed, ignores format, and writes nothing. An offline article for any other caller, and a missing one, is 404 Article not found.
POST /v1/article/{id}/seo/check verified session or API key (401 without credentials, 403 unverified) Evaluates the article without saving. The body is {} or any of the twelve SEO fields of PATCH plus body; each replaces the saved value for this check only. site_id, author_ids, unknown fields and null (except on cover_image_id and social_image_id) are 400 with the body-mismatch message. The other validation is the one of PATCH (invalid_slug, invalid_url as 422, image_not_found, validator text). Answers { status, suggestions, slug } where suggestions is [{ code, field, params }] and slug is { available, suggestion }. A taken slug is a 200 with available: false and the first free <slug>-N in suggestion, never a 409.
GET /v1/site/{key}/sitemap public A JSON array [{ url, modified_at, noindex }] of the online articles of the site with that key, by id ascending. url is base_url/slug (never the canonical override). page (default 1) and size (25, 50, 100 or 500, default 100); an invalid value is 400 with the body-mismatch message. The total is in the X-Total-Count header. An unknown key, an id included, is 404 site_not_found.

GET /v1/article/ also takes seo=ready|suggestions|hidden (any other value, an empty one included, is 400 with the body-mismatch message, after the format check). It is applied before paging and composes with q, status, site, sort, page and size; total follows it and counts ignore it. Every item carries seo_status (ready, suggestions or hidden) and seo_suggestions (the number of suggestions that are not dismissed). The filter is evaluated in memory over every matching article, so its cost grows with the number of articles on the site; without it only the returned page is assessed.

X-Total-Count is added to Access-Control-Expose-Headers next to Link, so a browser on an allowed origin can read it.

Article SEO fields

An article carries twelve search and sharing fields, all edited through PATCH /v1/article/{id} and returned by every single-article read (GET /v1/article/{id}, POST, PATCH, publish, unpublish): slug, excerpt, seo_title, seo_description, cover_image_id, cover_image_alt, social_title, social_description, social_image_id, canonical_url, noindex and seo_dismissed. Text fields are "" when empty, the two image ids are null when unset, noindex is false and seo_dismissed is [] by default. Each item of GET /v1/article/ carries excerpt and slug and none of the other ten. The limits are counted in characters: excerpt 300, seo_title 120, seo_description 320, cover_image_alt 200, social_title 120, social_description 320, canonical_url 2048; going over is 400 with the validator text. cover_image_id and social_image_id accept null (and only those two do); 0 is 400 with the validator text and an unknown image is 400 image_not_found. canonical_url is "" or an absolute https URL with a host, otherwise 422 invalid_url. seo_dismissed is a list of the codes title_long, title_empty, desc_short, desc_long, cover_alt, cover_none, slug_long, no_au, no_h, body_alt and short: an unknown code is 400 with the validator text and duplicates are collapsed with the order kept. noindex is only a flag, it does not take the article offline.

The slug matches ^[a-z0-9]+(-[a-z0-9]+)*$ with 1 to 100 characters (400 invalid_slug) and is unique per site, never globally. It is generated from the title when the article is created: lowercase, accents folded, every run of other characters becomes one dash, dashes trimmed, cut to 100 characters, and article when no letter or digit is left; a slug already used on the site gets -2, -3... silently. The same rule backfills the existing articles when the server starts after the upgrade (in id order within each site, so the oldest article keeps the plain slug). Sending slug: "" regenerates the slug from the title. A slug used by another article on the target site (the new site when site_id is sent too, and a move re-checks the current slug) is 409 with the plain-text body slug_taken; the response then carries the header X-Slug-Suggestion holding the first free <slug>-N from 2 on that site, fitting 100 characters. The header is set on this 409 only, never on another response. A regeneration that collides is refused the same way, never suffixed silently. Saving an article with its own slug is not a collision. Publishing never changes a slug, a deleted article frees its slug.

New error tokens are plain text from the handler: 409 slug_taken, 400 invalid_slug, 422 invalid_url, 400 image_not_found and 400 invalid_title_template (sites). Edits that change a SEO field are recorded as article.edit events (slug, canonical_url, noindex, cover_image, social_image with before and after; the free-text fields as a size delta only); a refused slug is recorded as failed.

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. 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.

An article's cover_image_id and social_image_id count the same way. used_in counts every article that uses the image in its body, as cover or as social image once, plus the authors using it as an avatar and the sites using it as default social image. Deleting an image never fails because of its uses: the covers and social images pointing at it are set to null (the cover_image_alt and the bodies are untouched), and a failed delete leaves every reference in place. A merge or a purge repoints the covers and social images of the copies to the kept image, and an article rewritten in its body and its cover counts once in articles_updated.

GET /v1/media/{id}/usage stays an array. Every item keeps the keys it had and gains type (article, author or site) and fields. An article item is { "type": "article", "id", "title", "published", "fields" } with fields from body, cover and social (one item per article, in that order); an author item is { "type": "author", "id", "title": <name>, "fields": ["avatar"] } and a site item is { "type": "site", "id", "title": <name>, "fields": ["default_social_image"] }; author and site items have no published key. The array is [] for an unused image.

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 }, with total repeated in the X-Total-Count header on every 200; a bad query is 400 with the body-mismatch message and no header.
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. 201 with the author plus slug_adjusted: true when the stored slug differs from the plain slug of the name (a collision suffix was added), false otherwise. No other author response carries this field.
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 missing_permission for POST / and DELETE, 403 author_rights for a PATCH by a role that holds authors.edit at own 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, and GET /v1/media/{id}/usage lists the author with the field avatar. 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; a role without the needed permission gets 403 missing_permission. Behaviour, caching and the public ?v=<name> contract are in 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": "<name>", "image_id": <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/<stored name>?v=<name>. 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".

Roles

Routes under /v1/role, behind the verified chain. Reads accept a key; every write is session only (a key answers 403 "This action needs a real session."). The permission is checked before the id, the body and any lookup.

Route Permission Purpose
GET /v1/role roles.view Every role by id, built-ins first.
GET /v1/role/{id} roles.view One role.
POST /v1/role roles.manage Create a role (201). built_in and locked in the body are ignored.
PUT /v1/role/{id} roles.manage Replace name, description and grants. version is required and must match.
DELETE /v1/role/{id} roles.manage Delete a role with no members (204).
POST /v1/role/{id}/reassign roles.manage Move every member to {"target_role_id": N}; answers {"moved": n}.
POST /v1/role/{id}/reset roles.manage and roles.edit_owner_only Restore a built-in role to its seeded grants and description; body {"version": N}.

A role is { id, name, description, built_in, locked, version, grants, members, updated_at, updated_by }. grants is the normalised effective list: catalogue order, unknown keys and owner-only keys on an unlocked role dropped, duplicates resolved to the most restrictive scope, and a per-type entry (content.update:events, scope none included) right after its wildcard. A response is always valid as the grants of a PUT. members counts users by their effective role (the legacy owner and admin strings count for the built-in role of that name). updated_at (RFC 3339, UTC) and updated_by ({ id, name }, the display name else the username) are null for a role never edited since seeding, and updated_by is also null once the saving user is deleted.

Refusals are plain text, one snake_case code per body:

Status Body When
400 unknown_permission, invalid_scope, owner_only_permission, duplicate_permission, unknown_content_type A grant is not valid (unknown_content_type for a content.*:{type} key that is neither article nor a content type).
400 invalid_role_name, built_in_name, not_built_in, invalid_reassign Blank name after trim; renaming a built-in role; resetting a custom role; a bad reassign target (self, the Owner role, unknown).
403 role_locked Editing, deleting or moving from the Owner role.
403 missing_permission The usual gate, plus X-Missing-Permission: roles.edit_owner_only when a built-in role is edited or reset without that permission.
403 own_role Editing, deleting or moving members out of the caller's own role.
403 escalation The caller would grant, or touch a role holding, more than they hold. X-Escalation-Key names the first offending key, such as content.publish or content.publish:events.
404 role_not_found Unknown role id.
409 role_name_taken, role_conflict, role_has_members, role_built_in Name already used (case-insensitive); stale version; the role still has members; deleting or moving members out of a built-in role.

Names are trimmed and unique case-insensitively. The order of checks on a write is: permission, id, body, role lookup, locked, built-in rules, own role, existing role covered by the caller, version, grant validation, new grants covered by the caller, name taken, write. Only the Owner holds roles.edit_owner_only, so only the Owner edits the Admin and Writer roles; owner-only keys are refused on every role a client can write. A save with nothing changed answers 200 at the same version and records nothing. Members see an edit on their next request, and so do the keys they created.

Activity: role.create, role.edit, role.delete and role.reassign in the roles category, target { type: "role", id, name }. role.edit carries name and description changes and the params added, removed, changed ({ key, from, to }) and members; role.reassign carries from, to and moved and is recorded only when moved is above 0. A refusal records nothing.

GET /v1/auth/me lists per-type entries in access.permissions (scope none included), right after their wildcard. Deleting a content type removes its per-type rows from every role (the affected roles' version goes up by one), and creating one with the key of a deleted type clears any stale row for that key.

Content permissions

Articles and the entries of custom content types are checked against the content.* permissions of the caller's role, which is read on every request (a key follows its creator's role).

Route Permission Scope check after the lookup
GET /v1/article/, GET /v1/article/{id}, GET /v1/article/{id}/seo public, content.read widens what drafts show online articles, plus drafts when content.read is all, or the caller's own drafts when it is own
POST /v1/article/{id}/seo/check content.read the article must be visible, else 404
POST /v1/article/ content.create none; created_by is the caller and the article is always a draft
PATCH /v1/article/{id} content.update own needs created_by to be the caller
DELETE /v1/article/{id} content.delete same
PATCH /v1/article/{id}/publish, /unpublish content.publish same; PATCH never changes is_online
GET /v1/content-type/{key}/entry, GET .../entry/{id} content.read for {key} own lists only the caller's entries (total, page, size and q follow) and answers 404 entry_not_found for another's entry
POST /v1/content-type/{key}/entry content.create for {key} created_by is the caller
PUT, DELETE .../entry/{id} content.update, content.delete for {key} own needs created_by to be the caller

Each action reads its own permission: publish does not imply update, update does not imply read. own means created_by equals the caller; an unattributed item (created_by 0) is owned by nobody. A permission can also be set for one content type (content.update:blog, content.update:article for articles); that row replaces the wildcard for the type, whether it lowers or raises it, and a none row denies. Only the wildcard applies to a type with no row. An id that does not exist is 404 even for a caller at own.

A refusal is 403 missing_permission with X-Missing-Permission set to the base key (content.update, never content.update:blog). The permission is checked before the id and the body are read, then the scope after the lookup, and a refused call writes nothing and records no activity event. The verification and token checks still come first. Creating a content type whose key is article, author, site, media or user is 409 content_type_key_taken. Owner-only permissions only count on the locked Owner role: the same key placed by hand on another role is ignored and GET /v1/auth/me does not list it.

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.

A caller whose role lacks the permission a route needs gets 403 with the body missing_permission and the header X-Missing-Permission set to the permission key (for example users.invite). The role is read on every request, so an API key follows the current role of its creator.

Notes

  • Images in body use paths like /static/uploadedImages/<uuid>.png. Prefix them with the base URL.
  • Browsers can call the API only from origins in the server's CORS setting.