Articles
An article is the rich content body used across Monotree. Handbook and course chapters own one (their article_id), and announcements, events, calendar entries, certificates and staff cards expose an article_id too. An article has a title, a subtitle, and an ordered list of typed elements — the content blocks.
Articles are addressed by their own id, so these endpoints are the same for every content type; the scopes they take follow the article's owner (see owned articles). The body convenience field on announcements, events, calendar entries and staff cards only works while the article is simple (empty or a single paragraph) — once it carries rich content, body writes return 422 to protect it.
Required scope: read:articles, write:articles (plus write:media when attaching media). An article owned by other content also takes the owner's scope, below.
Owned articles take the owner's scope
An article that belongs to other content is that content's text, so the token also needs the owner's scope:
- reading it (
GET /articles/{id},GET /articles/{id}/markdown) takes the owner'sread:orwrite:scope - writing it (
PATCH /articles/{id},PUT /articles/{id}/elements) takes the owner'swrite:scope
Standalone articles (no owner yet) and POST /articles take only the article scopes.
| Owner | Scope noun |
|---|---|
| handbook chapter | handbooks |
| course chapter | courses |
| announcement | announcements |
| event | events |
| calendar entry | calendar_entries |
| staff card | staff_cards |
| certificate | certificates |
| achievement | achievements |
Without it the request is refused with 403:
{
"error": "forbidden",
"message": "This article belongs to a handbook chapter; reading it requires the read:handbooks or write:handbooks scope."
}A write without the owner's write scope gets "… writing it requires the write:handbooks scope."
The article of a handbook or course chapter is also refused, read or write, while the handbooks or courses feature is off for the company: 403 with the message The handbooks feature is not enabled. (or courses), the same answer the handbooks and courses endpoints give.
Endpoints
/api/open/v1/articlesCreate a standalone article to attach at creation time
/api/open/v1/articles/{id}Get an article with its ordered elements
/api/open/v1/articles/{id}/markdownThe article flattened to one markdown document
/api/open/v1/articles/{id}Update the article title/subtitle
/api/open/v1/articles/{id}/elementsReplace the article's elements (bulk save)
Build first, attach at create
Announcements and calendar entries publish by default the moment they are created (events default to drafts) — enriching published content afterwards briefly exposes half-built pages. Either create as a draft (is_published: false), enrich, then publish — or: POST /articles { "title?", "subtitle?", "translations?" } creates a standalone article, fill it via PUT /articles/{id}/elements, then pass article_id when creating the announcement/event/calendar entry. The content is complete the instant it goes live.
article_id and body are mutually exclusive (422), and the article must not already belong to other content (422).
Create and update
POST /articles and PATCH /articles/{id} take the same fields, all optional. POST returns 201 with the new, empty article; PATCH changes only the fields you send. Elements are written with bulk save.
| Field | Type | Notes |
|---|---|---|
title | string or null | Max 255 characters. Written to the default content locale. |
subtitle | string or null | Max 512 characters. Written to the default content locale. |
translations | object | { "title": { "da": "…" }, "subtitle": { "da": "…" } }. See Translations. |
Element types
Writable types and their structure:
| Type | Structure | Notes |
|---|---|---|
paragraph | { "type": "paragraph", "body": "markdown" } | Body is markdown in and out (bold, italic, links, lists; other markdown flattens to text). |
header | { "type": "header", "body": "plain text" } | A section heading. |
image_gallery | { "type": "image_gallery", "title": "optional", "media_ids": [1, 2] } | 1–10 image media. |
video_gallery | { "type": "video_gallery", "title": "optional", "media_ids": [3] } | 1–10 video media. |
document | { "type": "document", "name": "optional", "media_ids": [4] } | 1–10 document media (pdf, docx, …); text is extracted for search. |
body takes up to 65,000 characters; title and name up to 255.
Read-only types — returned on GET, never created or modified through the API:
| Type | Shape |
|---|---|
reference | { "type": "reference", "referenced_type": "Handbook", "referenced_id": 3 } — an in-app link to other content. |
gif | { "type": "gif", "url": "https://…" } |
Media in elements
Upload media first via POST /media (the token also needs the write:media scope), then reference it by id in media_ids. Media must be uploaded by the same token, and the media type must match the element type (422 otherwise).
Translations
Text fields read and write the customer's default content locale; a field-keyed translations object carries the other locales and is returned on every read:
| Where | Shape |
|---|---|
| article | "translations": { "title": { "da": "…" }, "subtitle": { "da": "…" } } |
paragraph / header | "translations": { "body": { "da": "…" } } — paragraph values are markdown, like body |
document | "translations": { "name": { "da": "…" } } |
| handbook / course | "translations": { "title": { "da": "…" }, "subtitle": { "da": "…" } } — the content's own title/subtitle, on POST/PATCH /handbooks and /courses |
Merge semantics: locales you omit keep their value. Locale keys must be activated languages (422 otherwise), and writing a translation automatically activates that locale on the owning content (handbook, course, …). Galleries have no translatable text fields.
Reads always return the full translations map on the fields above — list endpoints included. Two places read the default content locale only: chapter title/subtitle (in chapter responses and the chapter summaries on the handbook/course) — fetch the chapter's article for the translations — and the body convenience field. For one language at a time, use the markdown export with ?locale=.
Bulk save
PUT /articles/{id}/elements takes the complete desired state of the article, in order:
- items with an element
idupdate that element in place; an id that is not an element of this article answers404and nothing is saved - items without an id create a new element at that position
- writable elements missing from the array are deleted; the final order is the array order
- read-only elements are never deleted — include them by id to position them, or omit them and they keep their place after your items
curl -X PUT https://customer.monotree.com/api/open/v1/articles/91/elements \
-H "Authorization: Bearer mono_your_token_here" \
-H "Content-Type: application/json" \
-d '{
"elements": [
{ "id": 501, "type": "header", "body": "First day" },
{ "type": "paragraph", "body": "New **markdown** paragraph", "translations": { "body": { "da": "Nyt **markdown** afsnit" } } },
{ "id": 503, "type": "image_gallery", "title": "Photos", "media_ids": [123] }
]
}'Response
{
"data": {
"id": 91,
"title": "Getting Started",
"subtitle": "First steps",
"translations": { "title": { "en": "Getting Started", "da": "Kom godt i gang" }, "subtitle": { "en": "First steps" } },
"elements": [
{ "id": 501, "type": "header", "body": "First day", "translations": { "body": { "en": "First day" } } },
{ "id": 502, "type": "paragraph", "body": "Hello **world**", "translations": { "body": { "en": "Hello **world**", "da": "Hej **verden**" } } },
{ "id": 503, "type": "image_gallery", "title": "Photos",
"media": [ { "id": 123, "type": "image", "url": "https://…", "mime_type": "image/jpeg",
"filename": "team.jpg", "size": 12345, "width": 1200, "height": 800, "tag": "media", "created_at": "…" } ] }
],
"created_at": "2026-07-01T09:00:00+02:00",
"updated_at": "2026-07-01T09:00:00+02:00"
}
}Markdown export
GET /articles/{id}/markdown flattens any article into one markdown document — convenient for search indexes or LLMs. Pass ?locale= to read a specific activated language (see locales); defaults to the customer's default content locale. An unknown or inactive locale returns 422:
{
"error": "validation_error",
"message": "The given data was invalid.",
"errors": { "locale": ["Locale xx is unknown or not activated."] }
}{
"data": {
"id": 91,
"title": "Getting Started",
"locale": "da",
"markdown": "# Getting Started\n\nFirst steps\n\n### Welcome aboard\n\n…"
}
}