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's read: or write: scope
  • writing it (PATCH /articles/{id}, PUT /articles/{id}/elements) takes the owner's write: scope

Standalone articles (no owner yet) and POST /articles take only the article scopes.

OwnerScope noun
handbook chapterhandbooks
course chaptercourses
announcementannouncements
eventevents
calendar entrycalendar_entries
staff cardstaff_cards
certificatecertificates
achievementachievements

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

POST
/api/open/v1/articles

Create a standalone article to attach at creation time

GET
/api/open/v1/articles/{id}

Get an article with its ordered elements

GET
/api/open/v1/articles/{id}/markdown

The article flattened to one markdown document

PATCH
/api/open/v1/articles/{id}

Update the article title/subtitle

PUT
/api/open/v1/articles/{id}/elements

Replace 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.

FieldTypeNotes
titlestring or nullMax 255 characters. Written to the default content locale.
subtitlestring or nullMax 512 characters. Written to the default content locale.
translationsobject{ "title": { "da": "…" }, "subtitle": { "da": "…" } }. See Translations.

Element types

Writable types and their structure:

TypeStructureNotes
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:

TypeShape
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:

WhereShape
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 id update that element in place; an id that is not an element of this article answers 404 and 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…"
  }
}