export type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE' export type AccessLevel = 'Public' | 'Verified user' | 'Owner or admin' | 'Owner only' export interface ApiField { name: string type: string required: boolean rule: string } export interface ApiError { status: number caption: string } export type BodyKind = 'none' | 'json' | 'multipart' export interface ApiEndpoint { id: string method: HttpMethod path: string description: string access: AccessLevel pathId: number | null query: ApiField[] bodyKind: BodyKind body: ApiField[] bodySample: Record | null errors: ApiError[] response: string status: number rateLimited: boolean } export interface ApiGroup { key: string title: string description: string endpoints: ApiEndpoint[] } export interface SessionRow { method: HttpMethod path: string description: string answers403: boolean } export interface SessionGroup { title: string rows: SessionRow[] } export interface PublicRow { method: HttpMethod path: string description: string } export interface ErrorRow { status: number meaning: string messages: string[] note?: string } export interface EndpointExamples { fetch: string curl: string response: string } export const API_KEY_PLACEHOLDER = 'rcms_Kf3x9QwL0aBc…' export const NO_CONTENT_RESPONSE = '204 No Content' export const SESSION_REQUIRED_MESSAGE = 'This action needs a real session.' export const OWNER_REQUIRED_MESSAGE = 'Only the owner can do this.' export const OWNER_OR_ADMIN_MESSAGE = 'Only owners and admins can do this.' export const LOGIN_FAMILY_CAPTION = 'Made for the browser. A key is ignored here, with no 403.' export const RATE_LIMIT_SENTENCE = 'This route is rate limited. Too many requests answer 429.' const BODY_MISMATCH = 'The request cannot be processed due to a mismatch in the format of the body.' const UNAUTHORIZED = 'You must be logged in to access this resource.' const VERIFY_REQUIRED = 'You must verify your account to access this resource.' const ID_FORMAT = 'The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).' const FORMAT_INVALID = 'The format must be one of: markdown, html, json.' const PURGE_INTERVAL = 'Enter a whole number of days between 1 and 365.' const PURGE_CONFLICT = 'The library changed, so nothing was purged. Check the new summary and try again.' const PURGE_BUSY = 'A purge is already running.' const TOO_MANY_REQUESTS = 'Too many requests. Please try again later.' const FORMAT_QUERY: ApiField = { name: 'format', type: 'string', required: false, rule: 'markdown, html or json. Default markdown.', } const PAGE_QUERY: ApiField = { name: 'page', type: 'number', required: false, rule: 'A whole number from 1. Default 1.', } const SIZE_QUERY: ApiField = { name: 'size', type: 'number', required: false, rule: '5, 25, 50 or 100. Default 25.', } const ARTICLE_LIST_QUERY: ApiField[] = [ FORMAT_QUERY, PAGE_QUERY, SIZE_QUERY, { name: 'q', type: 'string', required: false, rule: 'Up to 200 characters, matched anywhere in the title, ignoring case and accents.', }, { name: 'status', type: 'string', required: false, rule: 'online or draft. Default both.', }, { name: 'sort', type: 'string', required: false, rule: 'field:direction, with field title, status, created_at or updated_at and direction asc or desc. Default updated_at:desc.', }, { name: 'site', type: 'string', required: false, rule: 'The key of a site. Only its articles are listed. An unknown key answers an empty page.', }, { name: 'seo', type: 'string', required: false, rule: 'ready, suggestions or hidden. Only the articles with that SEO status are listed, before paging; counts stay unfiltered. Any other value is refused.', }, ] const SITEMAP_QUERY: ApiField[] = [ { name: 'page', type: 'number', required: false, rule: 'A whole number from 1. Default 1.', }, { name: 'size', type: 'number', required: false, rule: '25, 50, 100 or 500. Default 100.', }, ] const CHECK_BODY: ApiField[] = [ { name: 'body', type: 'string', required: false, rule: 'Replaces the saved body for this check only. null is refused.', }, { name: 'slug', type: 'string', required: false, rule: 'Same rules as PATCH. An empty string regenerates it from the title. null is refused.', }, { name: 'excerpt', type: 'string', required: false, rule: 'Up to 300 characters. null is refused.', }, { name: 'seo_title', type: 'string', required: false, rule: 'Up to 120 characters. null is refused.', }, { name: 'seo_description', type: 'string', required: false, rule: 'Up to 320 characters. null is refused.', }, { name: 'cover_image_id', type: 'number | null', required: false, rule: 'The id of an existing image. null means no cover.', }, { name: 'cover_image_alt', type: 'string', required: false, rule: 'Up to 200 characters. null is refused.', }, { name: 'social_title', type: 'string', required: false, rule: 'Up to 120 characters. null is refused.', }, { name: 'social_description', type: 'string', required: false, rule: 'Up to 320 characters. null is refused.', }, { name: 'social_image_id', type: 'number | null', required: false, rule: 'The id of an existing image. null means no social image.', }, { name: 'canonical_url', type: 'string', required: false, rule: 'An absolute https address with a host, up to 2048 characters. null is refused.', }, { name: 'noindex', type: 'boolean', required: false, rule: 'null is refused.', }, { name: 'seo_dismissed', type: 'string[]', required: false, rule: 'Dismissed suggestion codes, replacing the saved list for this check only. null is refused.', }, ] const MEDIA_QUERY: ApiField[] = [ { name: 'q', type: 'string', required: false, rule: 'Search text.' }, { name: 'hash', type: 'string', required: false, rule: 'SHA-256 of the content, lowercased.', }, { name: 'filter', type: 'string', required: false, rule: 'all, unused or duplicates. Default all.', }, { name: 'sort', type: 'string', required: false, rule: 'newest, oldest, name, largest or total_size. Default newest.', }, { name: 'variants', type: 'string', required: false, rule: 'with or without. Default both.', }, PAGE_QUERY, SIZE_QUERY, ] const json = (value: unknown) => JSON.stringify(value, null, 2) const ARTICLE_TITLE = 'How we price a bathroom renovation' const article = (overrides: Record = {}) => ({ id: 12, title: ARTICLE_TITLE, body: 'A bathroom renovation is priced by room size, plumbing changes and finish level.', body_format: 'markdown', is_online: true, site_id: 1, slug: 'how-we-price-a-bathroom-renovation', excerpt: 'What drives the price of a bathroom renovation.', seo_title: '', seo_description: '', cover_image_id: 4, cover_image_alt: 'A tiled bathroom wall', social_title: '', social_description: '', social_image_id: null, canonical_url: '', noindex: false, seo_dismissed: [], created_at: '2026-03-12T09:14:00Z', updated_at: '2026-09-10T07:00:00Z', ...overrides, }) const SAMPLE_SITE_KEY = 'renew-blog' const resolvedSeo = () => ({ title: `${ARTICLE_TITLE} | Renew`, description: 'What drives the price of a bathroom renovation.', canonical_url: 'https://blog.renew.example/how-we-price-a-bathroom-renovation', robots: 'index, follow', og: { type: 'article', title: `${ARTICLE_TITLE} | Renew`, description: 'What drives the price of a bathroom renovation.', image: 'https://blog.renew.example/static/uploadedImages/3f9c2b7e.jpg', image_alt: 'A tiled bathroom wall', url: 'https://blog.renew.example/how-we-price-a-bathroom-renovation', site_name: 'Renew', locale: 'en', }, twitter: { card: 'summary_large_image', title: `${ARTICLE_TITLE} | Renew`, description: 'What drives the price of a bathroom renovation.', image: 'https://blog.renew.example/static/uploadedImages/3f9c2b7e.jpg', image_alt: 'A tiled bathroom wall', }, image: { url: 'https://blog.renew.example/static/uploadedImages/3f9c2b7e.jpg', alt: 'A tiled bathroom wall', source: 'cover', }, authors: [ { name: 'Inès Duarte', url: 'https://ines-duarte.example', same_as: ['https://github.com/ines-duarte'], }, ], published_at: '2026-03-12T09:14:00Z', modified_at: '2026-09-10T07:00:00Z', fallbacks: { title: 'title', description: 'excerpt', image: 'cover', canonical: 'site' }, json_ld: { '@context': 'https://schema.org', '@type': 'Article', headline: ARTICLE_TITLE, description: 'What drives the price of a bathroom renovation.', image: ['https://blog.renew.example/static/uploadedImages/3f9c2b7e.jpg'], author: [ { '@type': 'Person', name: 'Inès Duarte', url: 'https://ines-duarte.example', sameAs: ['https://github.com/ines-duarte'], }, ], datePublished: '2026-03-12T09:14:00Z', dateModified: '2026-09-10T07:00:00Z', mainEntityOfPage: { '@type': 'WebPage', '@id': 'https://blog.renew.example/how-we-price-a-bathroom-renovation', }, }, }) const seoCheck = () => ({ status: 'suggestions', suggestions: [ { code: 'desc_short', field: 'seo_description', params: { length: 49, min: 70 } }, { code: 'no_au', field: 'author_ids', params: {} }, ], slug: { available: false, suggestion: 'how-we-price-a-bathroom-renovation-2' }, }) const sitemapPage = () => [ { url: 'https://blog.renew.example/how-we-price-a-bathroom-renovation', modified_at: '2026-09-10T07:00:00Z', noindex: false, }, { url: 'https://blog.renew.example/choosing-tiles-for-a-small-bathroom', modified_at: '2026-09-09T07:00:00Z', noindex: true, }, ] const LIST_ITEM_OMITTED_KEYS = [ 'seo_title', 'seo_description', 'cover_image_id', 'cover_image_alt', 'social_title', 'social_description', 'social_image_id', 'canonical_url', 'noindex', 'seo_dismissed', ] const articleListItem = (overrides: Record = {}) => ({ ...Object.fromEntries( Object.entries(article(overrides)).filter(([key]) => !LIST_ITEM_OMITTED_KEYS.includes(key)), ), seo_status: 'suggestions', seo_suggestions: 2, }) const mediaItem = (overrides: Record = {}) => ({ id: 4, filename: 'north-wall.jpg', url: '/static/uploadedImages/3f9c2b7e-5d1a-4c8e-9b0f-6a2d4e8c1b73.jpg', size: 3900000, content_type: 'image/jpeg', width: 4032, height: 3024, sha256: 'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9', uploaded_by: 'marta', created_at: '2026-09-10T07:00:00Z', used_in: 2, duplicate: false, ...overrides, }) const site = (overrides: Record = {}) => ({ id: 1, name: 'Renew blog', key: 'renew-blog', base_url: 'https://blog.renew.example', default_social_image_id: 4, default_robots: 'index', locale: 'en', is_default: true, article_count: 30, site_name: 'Renew', title_template: '%title% | %site%', default_description: 'Notes from a renovation site.', ...overrides, }) const author = (overrides: Record = {}) => ({ id: 2, name: 'Inès Duarte', slug: 'ines-duarte', bio: 'Site manager and restoration carpenter.', avatar_id: null, website: 'https://ines-duarte.example', social_links: [], user_id: 3, is_linked: true, article_count: 9, ...overrides, }) const me = { id: 1, username: 'marta', email: 'marta@renew.example', is_verified: true, created_at: '2026-01-05T08:30:00Z', updated_at: '2026-09-10T07:00:00Z', display_name: 'Marta Alves', role: 'owner', } const invite = (overrides: Record = {}) => ({ id: 7, kind: 'invite', email: 'lea.martin@renew.example', status: 'pending', created_at: '2026-09-10T07:00:00Z', expires_at: '2026-09-13T07:00:00Z', ...overrides, }) const e = (status: number, caption: string): ApiError => ({ status, caption }) const articleErrors = (...own: ApiError[]) => [...own, e(400, FORMAT_INVALID), e(401, UNAUTHORIZED)] const verified = (errors: ApiError[]) => [...errors, e(403, VERIFY_REQUIRED)] const ownerOrAdminErrors = (...own: ApiError[]) => [ ...own, e(401, UNAUTHORIZED), e(403, OWNER_OR_ADMIN_MESSAGE), ] const articlePage = () => ({ items: [ articleListItem(), articleListItem({ id: 13, title: 'Choosing tiles for a small bathroom', body: 'Small rooms show every grout line.', is_online: false, slug: 'choosing-tiles-for-a-small-bathroom', excerpt: '', updated_at: '2026-09-09T07:00:00Z', seo_status: 'ready', seo_suggestions: 0, }), ], total: 30, page: 1, size: 25, counts: { all: 30, online: 28, draft: 2, suggestions: 3 }, }) const articleIdErrors = [e(400, ID_FORMAT), e(404, 'Article not found')] const none: ApiField[] = [] const PURGE_SETTINGS = { auto_purge_enabled: true, auto_purge_interval_days: 30, last_purge_at: '2026-09-10T07:00:00Z', last_purge_merged: 2, last_purge_articles_updated: 5, enabled_at: '2026-08-11T07:00:00Z', next_purge_at: '2026-10-09T07:00:00Z', } function endpoint( id: string, method: HttpMethod, path: string, description: string, access: AccessLevel, errors: ApiError[], response: string, extra: Partial = {}, ): ApiEndpoint { return { id, method, path, description, access, pathId: path.includes('{id}') ? 12 : null, query: none, bodyKind: 'none', body: none, bodySample: null, errors, response, status: response === NO_CONTENT_RESPONSE ? 204 : 200, rateLimited: false, ...extra, } } const ownerOnly = ( id: string, method: HttpMethod, path: string, description: string, errors: ApiError[], response: string, extra: Partial = {}, ) => endpoint( id, method, path, description, 'Owner only', [...errors, e(403, OWNER_REQUIRED_MESSAGE), e(429, TOO_MANY_REQUESTS)], response, { rateLimited: true, pathId: path.includes('{id}') ? 7 : null, ...extra, }, ) export const API_GROUPS: ApiGroup[] = [ { key: 'articles', title: 'Articles', description: "Read, write and publish articles. Titles can't be changed after creation.", endpoints: [ endpoint( 'art-list', 'GET', '/v1/article/', 'List one page of articles, newest update first, with the total and the tab counts (all, online, draft and suggestions). Anonymous callers get online articles only.', 'Public', articleErrors(e(400, BODY_MISMATCH)), json(articlePage()), { query: ARTICLE_LIST_QUERY }, ), endpoint( 'art-get', 'GET', '/v1/article/{id}', 'Get one article. Drafts answer 404 to anonymous callers.', 'Public', articleErrors(...articleIdErrors), json(article()), { query: [FORMAT_QUERY] }, ), endpoint( 'art-create', 'POST', '/v1/article/', 'Create a draft article.', 'Verified user', verified( articleErrors( e(400, BODY_MISMATCH), e(400, 'Article title must be between 3 and 200 characters.'), ), ), json( article({ id: 13, title: 'Choosing tiles for a small bathroom', body: 'Small rooms show every grout line.', is_online: false, }), ), { query: [FORMAT_QUERY], bodyKind: 'json', body: [ { name: 'title', type: 'string', required: true, rule: "3 to 200 characters. Can't be changed later.", }, { name: 'body', type: 'string', required: true, rule: 'Up to 10 000 characters.', }, ], bodySample: { title: 'Choosing tiles for a small bathroom', body: 'Small rooms show every grout line.', }, status: 201, }, ), endpoint( 'art-update', 'PATCH', '/v1/article/{id}', 'Update the body of an article, move it to another site, or edit its SEO fields. Send at least one field; absent fields are untouched.', 'Verified user', verified( articleErrors( e(400, BODY_MISMATCH), e(400, 'invalid_slug'), e(400, 'image_not_found'), ...articleIdErrors, e(404, 'site_not_found'), e( 409, 'slug_taken (the X-Slug-Suggestion header holds the first free slug)', ), e(422, 'invalid_url'), ), ), json(article({ body: 'Updated text.', is_online: false })), { query: [FORMAT_QUERY], bodyKind: 'json', body: [ { name: 'body', type: 'string', required: false, rule: 'Up to 10 000 characters. An empty string empties the body; null is refused.', }, { name: 'site_id', type: 'number', required: false, rule: 'The id of an existing site, 1 or more. null is refused.', }, { name: 'slug', type: 'string', required: false, rule: 'Lowercase letters, digits and single dashes, 1 to 100 characters, unique on the site. An empty string regenerates it from the title. null is refused.', }, { name: 'excerpt', type: 'string', required: false, rule: 'Up to 300 characters. An empty string clears it; null is refused.', }, { name: 'seo_title', type: 'string', required: false, rule: 'Up to 120 characters. An empty string clears it; null is refused.', }, { name: 'seo_description', type: 'string', required: false, rule: 'Up to 320 characters. An empty string clears it; null is refused.', }, { name: 'cover_image_id', type: 'number | null', required: false, rule: 'The id of an existing image, 1 or more. null removes the cover.', }, { name: 'cover_image_alt', type: 'string', required: false, rule: 'Up to 200 characters. An empty string clears it; null is refused.', }, { name: 'social_title', type: 'string', required: false, rule: 'Up to 120 characters. An empty string clears it; null is refused.', }, { name: 'social_description', type: 'string', required: false, rule: 'Up to 320 characters. An empty string clears it; null is refused.', }, { name: 'social_image_id', type: 'number | null', required: false, rule: 'The id of an existing image, 1 or more. null removes the social image.', }, { name: 'canonical_url', type: 'string', required: false, rule: 'An absolute https address with a host, up to 2048 characters. An empty string clears it; null is refused.', }, { name: 'noindex', type: 'boolean', required: false, rule: 'Only a flag: the article stays online. null is refused.', }, { name: 'seo_dismissed', type: 'string[]', required: false, rule: 'Dismissed suggestion codes: title_long, title_empty, desc_short, desc_long, cover_alt, cover_none, slug_long, no_au, no_h, body_alt, short. Duplicates are collapsed; null is refused.', }, ], bodySample: { body: 'Updated text.', site_id: 2, slug: 'bathroom-pricing', excerpt: 'What drives the price.', cover_image_id: 4, }, }, ), endpoint( 'art-pub', 'PATCH', '/v1/article/{id}/publish', 'Publish an article.', 'Verified user', verified(articleErrors(...articleIdErrors)), json(article({ is_online: true })), { query: [FORMAT_QUERY] }, ), endpoint( 'art-unpub', 'PATCH', '/v1/article/{id}/unpublish', 'Unpublish an article.', 'Verified user', verified(articleErrors(...articleIdErrors)), json(article({ is_online: false })), { query: [FORMAT_QUERY] }, ), endpoint( 'art-del', 'DELETE', '/v1/article/{id}', 'Delete an article.', 'Verified user', verified(articleErrors(...articleIdErrors)), NO_CONTENT_RESPONSE, { query: [FORMAT_QUERY] }, ), endpoint( 'art-check', 'POST', '/v1/article/{id}/seo/check', 'Check the SEO of an article without saving anything. Send {} for the saved state, or fields that replace the saved values for this check only.', 'Verified user', verified([ e(400, BODY_MISMATCH), e(400, 'invalid_slug'), e(400, 'image_not_found'), e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Article not found'), e(422, 'invalid_url'), ]), json(seoCheck()), { bodyKind: 'json', body: CHECK_BODY, bodySample: { seo_title: 'Bathroom costs, explained', noindex: false }, }, ), ], }, { key: 'seo', title: 'Search and sharing', description: "RenewCMS doesn't render pages or add meta tags. These endpoints give your frontend the resolved SEO data and the list of published URLs.", endpoints: [ endpoint( 'art-seo', 'GET', '/v1/article/{id}/seo', 'Get the resolved SEO object of an article: title with the site template applied, description, canonical_url, robots, og, twitter, image, authors, published_at, modified_at, fallbacks and json_ld. Drafts answer 404 to anonymous callers.', 'Public', [e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Article not found')], json(resolvedSeo()), ), endpoint( 'site-sitemap', 'GET', '/v1/site/{key}/sitemap', 'List the published URLs of a site, by id ascending, as an array of { url, modified_at, noindex }. The total is in the X-Total-Count header.', 'Public', [e(400, BODY_MISMATCH), e(401, UNAUTHORIZED), e(404, 'site_not_found')], json(sitemapPage()), { query: SITEMAP_QUERY }, ), ], }, { key: 'media', title: 'Media', description: 'The image library. One image can be used in many articles.', endpoints: [ endpoint( 'med-list', 'GET', '/v1/media/', 'List images.', 'Verified user', verified([e(400, 'The media query is invalid.'), e(401, UNAUTHORIZED)]), json({ items: [mediaItem()], total: 1, page: 1, size: 25, counts: { all: 1, unused: 0, duplicates: 0 }, }), { query: MEDIA_QUERY }, ), endpoint( 'med-get', 'GET', '/v1/media/{id}', 'Get one image, with the same fields as an item of the list.', 'Verified user', verified([e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Image not found')]), json(mediaItem()), { pathId: 4 }, ), endpoint( 'med-up', 'POST', '/v1/media/', 'Upload an image (multipart).', 'Verified user', verified([ e(400, 'the image could not be read'), e(401, UNAUTHORIZED), e(413, 'the file exceeds the maximum upload size'), e(415, 'the file must be a PNG, JPEG, WEBP, or SVG image'), ]), json(mediaItem()), { pathId: null, status: 201, bodyKind: 'multipart', body: [ { name: 'file', type: 'file', required: true, rule: 'PNG, JPEG, WEBP or SVG.', }, ], bodySample: { file: '@north-wall.jpg' }, }, ), endpoint( 'med-ren', 'PATCH', '/v1/media/{id}', 'Rename an image. The URL stays the same.', 'Verified user', verified([ e(400, 'Enter a filename.'), e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Image not found'), ]), json(mediaItem({ filename: 'north-wall-01.jpg' })), { pathId: 4, bodyKind: 'json', body: [ { name: 'filename', type: 'string', required: true, rule: 'The new filename.', }, ], bodySample: { filename: 'north-wall-01.jpg' }, }, ), endpoint( 'med-del', 'DELETE', '/v1/media/{id}', 'Delete an image. Articles that use it show a broken image.', 'Verified user', verified([e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Image not found')]), NO_CONTENT_RESPONSE, { pathId: 4 }, ), endpoint( 'med-merge', 'POST', '/v1/media/merge', 'Merge identical copies into one image.', 'Verified user', verified([ e( 400, 'Choose an image to keep and at least one other image to merge into it.', ), e(401, UNAUTHORIZED), e(409, 'Some of the images to merge changed. Refresh and try again.'), ]), json({ articles_updated: 5, sites_updated: 1, merged: 2 }), { bodyKind: 'json', body: [ { name: 'keep_id', type: 'number', required: true, rule: 'Greater than 0. The image to keep.', }, { name: 'merge_ids', type: 'number[]', required: true, rule: 'At least 1, each greater than 0. Identical copies to delete. Articles switch to the kept image.', }, ], bodySample: { keep_id: 4, merge_ids: [9, 11] }, }, ), endpoint( 'med-use', 'GET', '/v1/media/{id}/usage', 'List the articles, authors and sites that use an image, with the places where each one uses it.', 'Verified user', verified([e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Image not found')]), json([ { type: 'article', id: 12, title: ARTICLE_TITLE, published: true, fields: ['body', 'cover'], }, { type: 'author', id: 2, title: 'Inès Duarte', fields: ['avatar'] }, { type: 'site', id: 1, title: 'Renew blog', fields: ['default_social_image'] }, ]), { pathId: 4 }, ), endpoint( 'med-purge-get', 'GET', '/v1/media/purge-settings', 'Get the automatic duplicate purge settings.', 'Owner or admin', ownerOrAdminErrors(), json(PURGE_SETTINGS), ), endpoint( 'med-purge-set', 'PATCH', '/v1/media/purge-settings', 'Turn the automatic purge on or off, or change its interval.', 'Owner or admin', ownerOrAdminErrors(e(400, BODY_MISMATCH), e(400, PURGE_INTERVAL)), json(PURGE_SETTINGS), { bodyKind: 'json', body: [ { name: 'auto_purge_enabled', type: 'boolean', required: false, rule: 'Leave out to keep the current value.', }, { name: 'auto_purge_interval_days', type: 'number', required: false, rule: '1 to 365. Leave out to keep the current value.', }, ], bodySample: { auto_purge_enabled: true, auto_purge_interval_days: 30, }, }, ), endpoint( 'med-purge-prev', 'GET', '/v1/media/purge-preview', 'Preview what a purge would merge. Nothing changes.', 'Owner or admin', ownerOrAdminErrors(), json({ groups: 1, copies: 2, articles: 5, list: [ { keep_id: 4, filename: 'north-wall.jpg', url: '/static/uploadedImages/3f9c2b7e-5d1a-4c8e-9b0f-6a2d4e8c1b73.jpg', used_in: 5, merged: 2, }, ], more: 0, fingerprint: '9c1f5e0a7d3b', }), ), endpoint( 'med-purge-run', 'POST', '/v1/media/purge', 'Merge every group of identical copies. Send the preview fingerprint to refuse a changed library.', 'Owner or admin', ownerOrAdminErrors( e(400, BODY_MISMATCH), e(409, PURGE_CONFLICT), e(409, PURGE_BUSY), ), json({ groups_total: 1, groups_purged: 1, merged: 2, articles_updated: 5, sites_updated: 1, failed_groups: 0, }), { bodyKind: 'json', body: [ { name: 'fingerprint', type: 'string', required: false, rule: 'The fingerprint of the preview. Without it, the purge runs on the current library.', }, ], bodySample: { fingerprint: '9c1f5e0a7d3b' }, }, ), ], }, { key: 'users', title: 'Users and invites', description: 'People who can sign in, and pending invitations.', endpoints: [ endpoint( 'usr-list', 'GET', '/v1/user/', 'List users and invites.', 'Verified user', verified([e(401, UNAUTHORIZED)]), json([ { id: 1, kind: 'user', username: 'marta', display_name: 'Marta Alves', email: 'marta@renew.example', role: 'owner', status: 'accepted', created_at: '2026-01-05T08:30:00Z', }, { id: 7, kind: 'invite', email: 'lea.martin@renew.example', status: 'pending', created_at: '2026-09-10T07:00:00Z', }, ]), ), endpoint( 'usr-me', 'PATCH', '/v1/user/me', 'Update my profile.', 'Verified user', verified([ e(400, BODY_MISMATCH), e(400, 'Display name must be 60 characters or fewer.'), e(401, UNAUTHORIZED), ]), json({ ...me, display_name: 'Marta Alves' }), { bodyKind: 'json', body: [ { name: 'display_name', type: 'string', required: true, rule: 'Up to 60 characters.', }, ], bodySample: { display_name: 'Marta Alves' }, }, ), ownerOnly( 'usr-inv', 'POST', '/v1/user/invite', 'Invite a user by email.', [ e(400, BODY_MISMATCH), e(401, UNAUTHORIZED), e(409, 'This address already has an account.'), e(409, "There's already a pending invite for this address."), ], json(invite()), { status: 201, bodyKind: 'json', body: [ { name: 'email', type: 'string', required: true, rule: 'A valid email address.', }, ], bodySample: { email: 'lea.martin@renew.example' }, }, ), ownerOnly( 'usr-cancel', 'DELETE', '/v1/user/invite/{id}', 'Cancel a pending invite.', [e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Invite not found')], NO_CONTENT_RESPONSE, ), ownerOnly( 'usr-renew', 'POST', '/v1/user/invite/{id}/renew', 'Renew an expired invite.', [ e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Invite not found'), e(409, 'This invite has already been accepted.'), ], json(invite()), ), endpoint( 'auth-me', 'GET', '/v1/auth/me', "Get the current user. With a key, the key's owner.", 'Verified user', [e(401, UNAUTHORIZED)], json(me), ), ], }, { key: 'settings', title: 'Settings', description: 'Read the site settings, including the activity log retention.', endpoints: [ endpoint( 'set-get', 'GET', '/v1/settings', 'Get settings.', 'Verified user', verified([e(401, UNAUTHORIZED)]), json({ version: 'v3.0.0-beta', activity_retention_days: 90, activity_retention_max_gb: 5, activity_log_size_bytes: 3435973837, activity_last_cleanup_at: '2026-09-10T03:00:00Z', activity_last_cleanup_removed: 12, activity_next_cleanup_at: '2026-09-11T03:00:00Z', }), ), ], }, { key: 'sites', title: 'Sites', description: 'The sites an article can belong to. Any verified user can read them; changes are made in the browser by the owner.', endpoints: [ endpoint( 'site-list', 'GET', '/v1/site/', 'List the sites, the default first and then by name, each with its article count. Without page or size every matching site is returned; the number of matching sites is in the X-Total-Count header.', 'Verified user', verified([e(400, BODY_MISMATCH), e(401, UNAUTHORIZED)]), json([ site(), site({ id: 2, name: 'Renew shop', key: 'renew-shop', base_url: 'https://shop.renew.example', default_social_image_id: null, default_robots: 'noindex', locale: null, is_default: false, article_count: 4, site_name: '', title_template: '%title% - %site%', default_description: '', }), ]), { query: [ PAGE_QUERY, SIZE_QUERY, { name: 'q', type: 'string', required: false, rule: 'Up to 200 characters, matched in the name, the key or the base URL, ignoring case and accents.', }, ], }, ), ], }, { key: 'authors', title: 'Authors', description: 'The people credited on articles. Any verified user can read them; changes are made in the browser by an owner or admin.', endpoints: [ endpoint( 'author-list', 'GET', '/v1/author/', 'List the authors by name, each with its article count. The total is also in the X-Total-Count header.', 'Verified user', verified([e(400, 'The page, size or search is not valid.'), e(401, UNAUTHORIZED)]), json({ items: [ author(), author({ id: 7, name: 'Camille Roche', slug: 'camille-roche', bio: 'Freelance writer on lime and stone.', website: null, user_id: null, is_linked: false, article_count: 3, }), ], total: 2, page: 1, size: 25, }), { query: [ PAGE_QUERY, SIZE_QUERY, { name: 'q', type: 'string', required: false, rule: 'Up to 200 characters, matched in the name or the slug.', }, ], }, ), ], }, ] const row = ( method: HttpMethod, path: string, description: string, answers403 = true, ): SessionRow => ({ method, path, description: answers403 ? description : `${description}. ${LOGIN_FAMILY_CAPTION}`, answers403, }) export const SESSION_GROUPS: SessionGroup[] = [ { title: 'System · owner only', rows: [ row('GET', '/v1/system/update', 'Update status'), row('POST', '/v1/system/update/check', 'Check for update'), row('POST', '/v1/system/update/download', 'Download update'), row('POST', '/v1/system/update/restart', 'Restart'), ], }, { title: 'Sign in and account', rows: [ row('POST', '/v1/auth/login', 'Log in', false), row('POST', '/v1/auth/logout', 'Log out', false), row('POST', '/v1/auth/register', 'Register', false), row('POST', '/v1/auth/verify', 'Verify email'), row('DELETE', '/v1/auth/register', 'Cancel registration'), row('POST', '/v1/auth/password-reset', 'Reset password', false), ], }, { title: 'Sites · owner only', rows: [ row('POST', '/v1/site/', 'Create a site'), row('PATCH', '/v1/site/{id}', 'Edit a site'), row('PATCH', '/v1/site/{id}/default', 'Make a site the default'), row('DELETE', '/v1/site/{id}', 'Delete a site, moving its articles with reassign_to'), ], }, { title: 'Authors · owner and admin', rows: [ row('POST', '/v1/author/', 'Create an author'), row( 'PATCH', '/v1/author/{id}', 'Edit an author, or your own author as a verified user', ), row( 'DELETE', '/v1/author/{id}', 'Delete an author, moving its articles with reassign_to', ), row('POST', '/v1/author/me', 'Create your own author profile, as any verified user'), ], }, { title: 'Settings · owner only', rows: [row('PATCH', '/v1/settings', 'Change the activity log retention')], }, { title: 'Activity log', rows: [ row('GET', '/v1/activity', 'List activity events'), row('GET', '/v1/activity/summary', 'Activity summary, owner and admin'), row('GET', '/v1/activity/export', 'Export activity as CSV, owner and admin'), ], }, { title: 'API key management', rows: [ row('GET', '/v1/api-key', 'List keys'), row('POST', '/v1/api-key', 'Create a key'), row('DELETE', '/v1/api-key/{id}', 'Revoke a key'), ], }, ] export const PUBLIC_AUTH_ROWS: PublicRow[] = [ { method: 'GET', path: '/v1/', description: 'Hello world.' }, { method: 'GET', path: '/v1/auth/registration', description: 'Whether registration is open.', }, { method: 'POST', path: '/v1/auth/password-reset/confirm', description: 'Set a new password with the emailed code.', }, { method: 'GET', path: '/v1/auth/invite', description: 'Check an invite code.', }, { method: 'POST', path: '/v1/auth/invite/accept', description: 'Accept an invite.', }, { method: 'GET', path: '/healthz', description: 'Plain text "ok". Outside /v1.', }, ] export const ERROR_ROWS: ErrorRow[] = [ { status: 400, meaning: 'Bad request', messages: [BODY_MISMATCH, 'The expiry must be a date in the future.'], }, { status: 401, meaning: 'Unauthorized', messages: [UNAUTHORIZED] }, { status: 403, meaning: 'Forbidden', messages: [SESSION_REQUIRED_MESSAGE, OWNER_REQUIRED_MESSAGE, VERIFY_REQUIRED], note: 'Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.', }, { status: 404, meaning: 'Not found', messages: ['Article not found', 'Image not found', 'Invite not found', 'API key not found'], }, { status: 409, meaning: 'Conflict', messages: [ "There's already a pending invite for this address.", 'This invite has already been accepted.', ], }, { status: 429, meaning: 'Too many requests', messages: [TOO_MANY_REQUESTS], }, { status: 500, meaning: 'Server error', messages: ['Internal server error'], }, ] export const ERROR_SAMPLE = `HTTP/1.1 403 Forbidden\nContent-Type: text/plain\n\n${SESSION_REQUIRED_MESSAGE}` export const AUTH_EXAMPLE_PATH = '/v1/auth/me' function resolvedPath(item: Pick) { const keyed = item.path.replace('{key}', SAMPLE_SITE_KEY) return item.pathId === null ? keyed : keyed.replace('{id}', String(item.pathId)) } export function endpointExamples(item: ApiEndpoint, origin: string): EndpointExamples { const url = `${origin}${resolvedPath(item)}` const isPublic = item.access === 'Public' const isJson = item.bodyKind === 'json' && item.bodySample !== null const isForm = item.bodyKind === 'multipart' const authHeader = `Authorization: Bearer ${API_KEY_PLACEHOLDER}` const curlParts = [`curl ${item.method === 'GET' ? '' : `-X ${item.method} `}"${url}"`] if (!isPublic) curlParts.push(` -H "${authHeader}"`) if (isJson) { curlParts.push(` -H "Content-Type: application/json"`) curlParts.push(` -d '${JSON.stringify(item.bodySample)}'`) } if (isForm) curlParts.push(` -F "file=@north-wall.jpg"`) const options: string[] = [] if (item.method !== 'GET') options.push(`method: '${item.method}'`) const headers = [ isPublic ? null : `Authorization: 'Bearer ${API_KEY_PLACEHOLDER}'`, isJson ? `'Content-Type': 'application/json'` : null, ].filter((header) => header !== null) if (headers.length > 0) options.push(`headers: { ${headers.join(', ')} }`) if (isJson) options.push(`body: JSON.stringify(${JSON.stringify(item.bodySample)})`) if (isForm) options.push('body: form') const formSetup = isForm ? "const form = new FormData()\nform.append('file', file)\n\n" : '' const call = `const res = await fetch('${url}'${ options.length > 0 ? `, {\n ${options.join(',\n ')}\n})` : ')' }` return { fetch: formSetup + call, curl: curlParts.join(' \\\n'), response: item.response, } } export function authExamples(origin: string) { const url = `${origin}${AUTH_EXAMPLE_PATH}` return { curl: `curl "${url}" \\\n -H "Authorization: Bearer ${API_KEY_PLACEHOLDER}"`, fetch: `const res = await fetch('${url}', {\n headers: { Authorization: 'Bearer ${API_KEY_PLACEHOLDER}' }\n})\nconst me = await res.json()`, } } export function allEndpoints() { return API_GROUPS.flatMap((group) => group.endpoints) }