Concepts
Rich text
One document, three views, and a node schema that is ours rather than an editor's.
A richtext field stores a document and serves three views of it.
"body": {
"html": "<h2 id=\"install\">Install</h2><p>Run <code>npm i</code>…</p>",
"json": { "version": 2, "content": [ /* nodes */ ] },
"plain": "Install Run npm i …",
"toc": [ { "level": 2, "text": "Install", "id": "install" } ]
}
html: semantic HTML, ready to render. External links carryrel="nofollow noopener". Images carrywidth/heightwhen known and become<figure><img><figcaption>when captioned. A callout is<aside class="callout callout-info">. A table is a real<table>with<thead>and<th>. An embed is a responsive<iframe>for a known provider and a plain link otherwise. Never a third-party script.json: the stored document, with image URLs and embed URLs resolved and linkreladded. Build your own renderer on this when you want React components instead ofdangerouslySetInnerHTML.plain: text only. For meta descriptions, previews and search. Image captions and table cells are included; alt text and embed URLs are not.toc: the headings in order, with the same ids the rendered HTML puts on them, so#idlands. Build in-page navigation from this rather than parsing the HTML.
The node schema
Current version: 2. Both 1 and 2 are accepted on write.
Block nodes: paragraph, heading, bulletList, orderedList,
listItem, blockquote, codeBlock, callout, table, tableRow,
tableCell, tableHeader, image, embed, horizontalRule.
Marks: bold, italic, underline, strike, code, highlight,
link.
Heading levels: 2, 3 and 4. Level 1 belongs to the page, not to the body of a field.
listItem appears only inside a list, and tableRow/tableCell/tableHeader
only inside a table. Every other node may appear at the top level.
Versions
Send "version": 2. A document sent as version 1 is accepted, because schema 2
is a superset of schema 1, and stored as 2. A version above 2 is refused.
A document stored at version 1 keeps saying 1 when you read it, and renders exactly as it always did. Callouts, tables, embeds and image captions are the schema 2 additions.
Images inside a document
An image node takes either assetId or src: exactly one, never
both and never neither.
assetIdis amed_…id from this project’s media library. It resolves to a URL on every read, and on a project with a private bucket gainsurl_expires_at.srcis an absolutehttp(s)URL to an image hosted elsewhere, stored verbatim. Nothing else is accepted: nodata:, no protocol-relative//host, no/path, no#fragment.
Reading one back, it carries "source": "library" or "external" and a url.
An external image’s URL is its stored src: it never expires, we never fetch
it, and the server it lives on is not ours, so it can move, disappear or block
hotlinking. That is the trade-off of pasting a URL in rather than uploading.
alt is for a screen reader; caption is printed under the picture and counts
toward the reading time.
Callouts, tables and embeds
Callouts take { "variant": "info" | "warning" | "success" | "danger" }
and hold paragraphs and lists. The variant is the callout’s meaning and
becomes class="callout callout-…" on the rendered <aside>.
Tables hold tableRow; a row holds tableCell or tableHeader; a cell
takes colspan and rowspan (positive integers, default 1). Leading rows made
entirely of tableHeader become the <thead>.
Embeds take a { "url" }, held to the same rule as a link href.
provider and embedId are derived by the server and any values you send
for them are discarded. youtube, vimeo, x and codepen get a responsive
iframe; anything else is generic and renders as a link.
Writing a document
Unknown marks are dropped on save. Unknown node types are refused with
an error naming what is allowed (unknown_richtext_node), because silently
dropping a node loses content somebody wrote.
link marks take { "href" }. rel and target are set by the renderer and
never stored.
If you are generating documents programmatically, /v1/introspection returns
this whole contract under rich_text: every node type, mark, heading level,
callout variant and embed provider, as arrays you can validate against.
Updated at, Wednesday, September 2, 2026