10 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}/publishPATCH /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 }
}
| 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 |
sort |
title, status, created_at or updated_at, then :asc or :desc (lowercase, for example title:asc) |
updated_at:desc |
itemsis[]when empty, nevernull. Each item is the article object below.- Items follow
sort(defaultupdated_at:desc) across the whole list before paging.titleignores case,statusorders on online state (status:ascputs drafts first), and every ordering ends withiddescending so pages never overlap. totalis the number of visible articles matchingqandstatus, before paging.pageandsizeecho the values applied.countsignoresqandstatus, so tab counts do not move while searching. For anonymous and unverified callersallequalsonlineanddraftis0, andstatus=draftanswersitems: []andtotal: 0.- A page beyond the last answers
200withitems: []and the realtotal. - An invalid
page,size,status,sortor an over-longqanswers400with the plain-text messageThe request cannot be processed due to a mismatch in the format of the body.An invalidformatis 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.
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 |