Writing content bodies in Markdown¶
Every content item — article, guide, video, course, event, event series, link, track, collection — has a body: the formatted text learners read on its page. You author it by sending Markdown in body_markdown, and you read it back from the body_markdown field on any response.
This is the field to use when a program is writing the content: generating an article from a template, importing documentation from a repository, or having an AI agent draft a description.
curl -X POST https://acme.plusplus.app/api/v2/articles/ \
-H "Authorization: Bearer your_token_here" \
-H "Content-Type: application/json" \
-d '{
"name": "Onboarding: Your First Week",
"body_markdown": "## Before you start\n\nMake sure you have:\n\n- A laptop\n- Access to [the handbook](https://handbook.example.com)\n\n## Day one\n\nMeet your manager and set up your dev environment."
}'
The response includes the stored body, converted back to Markdown:
{
"public_id": "01HXY8C5FZ7N4S0M2D3J6Q7K9P",
"name": "Onboarding: Your First Week",
"body_markdown": "## Before you start\n\nMake sure you have:\n\n- A laptop\n- Access to [the handbook](https://handbook.example.com)\n\n## Day one\n\nMeet your manager and set up your dev environment.",
"description": "Before you start Make sure you have: A laptop Access to the handbook Day one Meet your manager and set up your dev environment."
}
description is the plain-text version, derived automatically. It powers previews and search. You never write it directly.
What Markdown is supported¶
| Supported | Not supported |
|---|---|
| Headings, paragraphs, bold, italic | Strikethrough (~~text~~) |
| Bullet and numbered lists, including nesting | Task list checkboxes (- [ ]) |
| Links and images | Inline text colors and highlights |
| Blockquotes, horizontal rules | Raw HTML of any kind |
| Fenced code blocks with a language | Footnotes, definition lists |
| Pipe tables |
Anything unsupported is not an error — it comes through as literal text. ~~gone~~ stays ~~gone~~ in the body, and - [ ] todo becomes a normal bullet reading [ ] todo.
Updating a body¶
body_markdown replaces the entire body. There is no partial or append update.
curl -X PATCH https://acme.plusplus.app/api/v2/articles/01HXY8C5FZ7N4S0M2D3J6Q7K9P/ \
-H "Authorization: Bearer your_token_here" \
-H "Content-Type: application/json" \
-d '{ "body_markdown": "## Updated\n\nThe whole body is now this." }'
To clear a body, send an empty string or null. To leave it untouched, omit the field.
Read-modify-write loses formatting¶
Round-tripping is not lossless
Bodies are stored as rich text, not as your original Markdown. body_markdown is regenerated from that rich text on every read, so what you get back is an equivalent document, not the exact string you sent.
This matters if you read a body, change one sentence, and write it back. Anything the editor supports but Markdown cannot express is dropped in that cycle — including checklist items, text colors and highlights. A body authored in the PlusPlus editor and then round-tripped through this field will lose those.
Formatting that Markdown can express survives, though incidental details may shift: list indentation and table column padding get normalized, and table alignment is not preserved. A PlusPlus video embedded in the editor comes back as a plain link to the video page.
Embedding a PlusPlus video¶
Put the URL of a video page on your own PlusPlus site on a line of its own, as a paragraph by itself:
Watch the kickoff before the workshop:
https://learn.example.com/a/videos/3d4226a3-7ce6-4508-abbe-13f766094bbc_kickoff
Then complete the exercises.
That paragraph becomes an embedded video player on the content page. The video must exist on the same site. A URL inside a sentence, a URL for another site, or a URL for a video that does not exist stays plain text.
If you need to preserve the author's exact source, keep it in your own system and treat PlusPlus as the render target — write to body_markdown, don't read from it.
Content is sanitized¶
Bodies are treated as untrusted input, because they render into pages, calendar invites and emails.
- Raw HTML is stripped.
<script>,<iframe>, event handlers likeonerror, and everything else are removed before storage. Scripting elements are removed along with their contents. - Links are restricted to
http,httpsandmailto. Any other scheme (javascript:,data:,vbscript:) is replaced with#. - Images are not re-hosted.
stores that URL as-is. If the source goes away, the image breaks. To host an image in PlusPlus, upload it through the dashboard editor. - PlusPlus-hosted images come back as signed URLs. They expire after a set time (currently 1–2 days). Fetch the item again for fresh URLs — don't store them or hand them to a system that will load them later.
- Bodies are capped at 100,000 characters of Markdown source. Larger payloads are rejected with
422.
Type-specific behavior¶
Most content types just store and display the body. Three do something extra:
- Events — the body is the event description. It renders into the calendar invite and into the event-update email sent to facilitators, attendees and the wait list. Changing it re-pushes the calendar event and re-notifies attendees, the same as changing any other event field. Don't rewrite bodies in bulk on live events unless you intend to email everyone.
- Event series — the body applies to the series record only. It is not copied onto events already scheduled from the series, nor onto events created from it later.
- Guides — the body is a catalog summary, not the guide's lesson content. Lesson content comes from the uploaded ZIP package (see Building a guide). Most integrations should leave a guide's body unset.
Bulk updates¶
The bulk update endpoints (PATCH /events/bulk/, PATCH /event-series/bulk/) reject body_markdown and description with a 422. Bodies are edited one resource at a time, through that resource's own PATCH.
Errors¶
| Status | code |
Cause |
|---|---|---|
400 |
content_body_conflict |
Both body_markdown and the deprecated description were sent in one request. |
400 |
invalid_content_body |
The Markdown could not be converted into a valid document. |
422 |
validation_error |
body_markdown is longer than the 100,000-character cap. |
The deprecated description field on writes¶
description used to be accepted on update payloads. It never actually saved anything — the field was silently discarded. It now works on both create and update payloads, writing plain text (one paragraph per line) into the body.
Prefer body_markdown. Sending both in one request is rejected with 400 content_body_conflict.