Files

14 KiB

Reading articles

Every route that returns an article accepts the optional format query parameter and returns the article body in that format. Articles are stored as HTML (Tiptap output). Storage and the request bodies of POST /v1/article/ and PATCH /v1/article/{id} stay HTML.

PATCH /v1/article/{id} takes body, site_id, author_ids (the whole ordered list of author ids, see docs/api.md) and the SEO fields listed below, and nothing else. The request must contain at least one of them, and null answers 400 except for cover_image_id and social_image_id. Behaviour change: {} and {"body": null} used to set an empty body and now answer 400; send {"body": ""} to empty a body.

Routes that honour format

  • GET /v1/article/
  • GET /v1/article/{id}
  • POST /v1/article/
  • PATCH /v1/article/{id}
  • PATCH /v1/article/{id}/publish
  • PATCH /v1/article/{id}/unpublish

Visibility is unchanged: anonymous and unverified callers only see online articles, and an offline article answers 404 Article not found.

Pagination, search and counts

GET /v1/article/ always answers one page inside an envelope, with or without query parameters:

{
  "items": [],
  "total": 30,
  "page": 1,
  "size": 25,
  "counts": { "all": 30, "online": 30, "draft": 0, "suggestions": 4 }
}
Parameter Values Default
page integer >= 1 1
size 5, 25, 50 or 100 25
q up to 200 characters, matched anywhere in the title, ignoring case and accents none
status online or draft none
site the key of a site; an unknown key gives an empty page, an empty value is 400 none
seo ready, suggestions or hidden; any other value, an empty one included, is 400 none
sort title, status, created_at or updated_at, then :asc or :desc (lowercase, for example title:asc) updated_at:desc
  • items is [] when empty, never null. Each item is the article object below.
  • Items follow sort (default updated_at:desc) across the whole list before paging. title ignores case, status orders on online state (status:asc puts drafts first), and every ordering ends with id descending so pages never overlap.
  • total is the number of visible articles matching q and status, before paging. page and size echo the values applied.
  • counts ignores q, status, site, seo, sort, page and size, so tab counts do not move while searching. suggestions is the number of visible articles whose seo_status is suggestions (hidden and ready articles and dismissed codes are not counted); it equals the total of ?seo=suggestions sent with no other filter by the same caller. For anonymous and unverified callers all equals online and draft is 0, and status=draft answers items: [] and total: 0.
  • Each item also carries seo_status and seo_suggestions (see SEO status and suggestions). seo filters on the status before paging, so total follows it while counts do not.
  • A page beyond the last answers 200 with items: [] and the real total.
  • An invalid page, size, status, sort or an over-long q answers 400 with the plain-text message The request cannot be processed due to a mismatch in the format of the body. An invalid format is reported first.

Breaking change: this route used to answer a bare JSON array. Clients must read the items property.

The format parameter

Value body body_format
empty or absent markdown string markdown
markdown markdown string markdown
html the stored HTML string html
json JSON array of blocks json

Values are case-insensitive. When format is repeated, the first value is used. Any other value answers 400 with the plain-text message The format must be one of: markdown, html, json. before any article is read or written. An invalid id is reported first, ahead of the format.

Breaking change: the default used to be HTML. Clients that need HTML must send ?format=html. The web interface does so.

Article object

{
  "id": 1,
  "title": "Backyard Renovation Update",
  "body": "## Plan\n\nReplace the **old** deck.",
  "body_format": "markdown",
  "is_online": true,
  "site_id": 1,
  "authors": [],
  "slug": "backyard-renovation-update",
  "excerpt": "",
  "seo_title": "",
  "seo_description": "",
  "cover_image_id": null,
  "cover_image_alt": "",
  "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"
}

Every single-article read (GET /v1/article/{id}, POST, PATCH, publish, unpublish) carries the twelve SEO fields. Each item of GET /v1/article/ carries slug and excerpt only, and none of the other ten.

SEO fields

Field Type Limit and rule
slug string ^[a-z0-9]+(-[a-z0-9]+)*$, 1 to 100 characters, unique per site. "" regenerates it from the title.
excerpt string 300 characters
seo_title string 120 characters
seo_description string 320 characters
cover_image_id number or null an existing image id (0 is refused)
cover_image_alt string 200 characters, independent of the cover
social_title string 120 characters
social_description string 320 characters
social_image_id number or null an existing image id (0 is refused)
canonical_url string "" or an absolute https URL with a host, at most 2048 characters
noindex boolean only a flag: the article stays online
seo_dismissed array of strings any of title_long, title_empty, desc_short, desc_long, cover_alt, cover_none, slug_long, no_au, no_h, body_alt, short; duplicates are collapsed, order kept

Limits are counted in characters (runes), not bytes. Absent fields are untouched; text fields and noindex are cleared with "", false and [], the two image ids with null.

The slug

A slug is unique per site (site_id, slug), never globally. At creation it is generated from the title: lowercase, accents folded, every run of other characters becomes one dash, dashes trimmed, cut to 100 characters without a trailing dash, and article when no letter or digit is left. A slug already used on the site gets -2, -3... silently, the base being truncated so the result fits 100 characters. The same rule backfills existing articles on the first start after the upgrade, in id order within each site.

Editing a slug that another article of the target site already uses (the new site when site_id is sent in the same request; moving an article also re-checks its current slug there) answers 409 with the plain-text body slug_taken, and the response header X-Slug-Suggestion holds the first free <slug>-N from 2 on that site, fitting 100 characters. X-Slug-Suggestion is set on this 409 only and on no other response. A request with slug: "" that regenerates onto another article's slug is refused the same way and never suffixed silently. Re-sending an article's own slug is not a conflict. A refused request changes nothing.

Publishing, unpublishing and noindex never change a slug. A deleted article frees its slug.

Resolved SEO object

GET /v1/article/{id}/seo answers the data a frontend renders in its <head>. Online articles are public; a draft needs a verified session or API key and is otherwise 404 Article not found. The query is ignored (no format). Keys:

title, description, canonical_url, robots, og (type, title, description, image, image_alt, url, site_name, locale), twitter (card, title, description, image, image_alt), image (url, alt, source), authors (name, url, same_as), published_at, modified_at, fallbacks and json_ld (a ready schema.org Article).

The fallback chain, first non-empty wins:

Output Chain fallbacks value
title seo_title, then the article title, then the site title_template (%title%, %site% replaced by site_name, result trimmed) seo_title, title
description seo_description, excerpt, the first sentence of the body (tags removed), the site default_description seo_description, excerpt, body, site, none
image social_image_id, cover_image_id (with cover_image_alt), the site default_social_image_id; url is the site base_url plus the image path, "" and none when there is none social, cover, site, none
canonical_url canonical_url, then site base_url + / + slug canonical_url, site
robots noindex, nofollow when noindex is true, otherwise from the site default_robots (index, follow or noindex, nofollow) none
og and twitter title and description social_title and social_description, then the resolved values none

og.type is article, og.locale is the site locale ("" when none), twitter.card is summary_large_image with an image and summary without. published_at is the creation time of an online article and null for a draft; modified_at is updated_at. authors[].url is the author website ("" when none) and same_as lists the social link URLs only. json_ld.headline is the meta title without the site template, and author, image and datePublished are left out when empty.

SEO status and suggestions

POST /v1/article/{id}/seo/check and the list use one rule set. Soft ranges never block saving or publishing; lengths are counted in characters.

Code Field Fires when params
title_long seo_title the meta title (seo_title, else the title) is over 60 characters length, max
title_empty seo_title the meta title is empty after trimming none
desc_short seo_description the resolved description is under 70 characters length, min
desc_long seo_description the resolved description is over 160 characters length, max
cover_alt cover_image_alt there is a cover and its alt text is empty none
cover_none cover_image_id there is no cover none
slug_long slug the slug is over 75 characters length, max
no_au author_ids no author is credited none
no_h body the text has at least 1000 characters and no h2 to h6 none
body_alt body an image of the body has no alt text count
short body the text has fewer than 300 characters length, min

Suggestions come in that order. Codes in seo_dismissed are never reported or counted. The status is hidden when noindex is true, else suggestions when at least one suggestion remains, else ready. The list items carry seo_status and seo_suggestions (the count, also for a hidden article).

Markdown output

Headings, bold, italic, links, bullet and ordered lists, blockquotes, fenced code blocks, rules, images and inline code are converted. Anything else is reduced to its text. script and style content is removed, and the source HTML is never passed through; the only HTML in the output is the alignment wrapper described below. List indentation attributes are dropped. Links with a scheme other than http, https, mailto or tel are reduced to their text. Characters that markdown would read as formatting (*, _, `, [, ], <, \) and line starts that would read as a heading, list, quote or rule (#, -, +, >, 1.) are backslash-escaped, so plain text stays plain text. A line break becomes a backslash followed by a newline.

Text alignment

Markdown has no syntax for alignment, so centered, right-aligned and justified paragraphs and headings are wrapped in a div with an inline style. The blank lines around the content make CommonMark parse the inside as markdown:

<div style="text-align: center">

A **centered** paragraph

</div>

Left alignment is the default and produces no wrapper. Your markdown renderer must allow raw HTML for the alignment to show (html: true in markdown-it); otherwise the wrapper tags appear as text, and a client that does not want them can request ?format=json instead, where the alignment is a field.

JSON block schema

body is a native JSON array. Every block has a type.

Block Fields
heading level (1 to 6), align (only when center, right or justify), children (text nodes)
paragraph align (only when center, right or justify), children (text nodes, empty array for an empty paragraph)
list ordered (boolean), items (array of items, each an array of blocks)
blockquote children (blocks)
code language (empty string when unknown), text
hr none
image src, alt (empty string when missing)

Text node: {"type":"text","text":"...","marks":[...]}. marks is always an array, listed outermost first. A mark is {"type":"bold"}, {"type":"italic"} or {"type":"link","href":"..."}. A line break becomes a "\n" inside the text. Adjacent text with identical marks is merged. Inline code, underline, strike-through and other formatting degrade to plain text. An image inside a paragraph becomes its own block and splits the paragraph. An empty body is [].

Example for <h2>Plan</h2><p>Replace the <strong>old</strong> deck.</p>:

[
  {"type":"heading","level":2,"children":[{"type":"text","text":"Plan","marks":[]}]},
  {"type":"paragraph","children":[
    {"type":"text","text":"Replace the ","marks":[]},
    {"type":"text","text":"old","marks":[{"type":"bold"}]},
    {"type":"text","text":" deck.","marks":[]}
  ]}
]

Errors

Status Body
400 The server expects the ID to be in the format of an unsigned 32-bit integer (uint32). for an invalid id
400 The format must be one of: markdown, html, json. for an unsupported format
400 invalid_slug, image_not_found or the validator text for an over-long SEO field, cover_image_id: 0 or an unknown seo_dismissed code
404 Article not found
409 slug_taken, with the header X-Slug-Suggestion: <first free slug> (this response only)
422 invalid_url for a canonical_url that is not an absolute https URL with a host
500 Internal server error when the body cannot be converted