Introduction
The Mentra Public API v1 is a RESTful API providing read and write access to learning content. Structured content lives in named Sections, each an ordered tree of nodes whose shape — how deep it nests, what a level is called, which types may parent which — comes from the Section's template. Templates are not a fixed set: the built-in ones include a three-level Programme (Programme → Module → Lesson), a shallow Knowledge Base (Category → Article) and a flat set of Pages, and a tenant can also use a template its own editors authored. Read a Section's vocabulary from GET /sections rather than assuming one. Alongside Sections there are standalone content items, which belong to no tree.
Key Features
- Multi-language support - Content available in any locale you translate into, not limited to a fixed list (see Locale Parameter)
- AI-generated extensions - Mnemonics, quizzes, and real-world scenarios
- Headless CMS - Use anywhere: web, mobile, IoT, voice assistants
- Pagination & filtering - Efficient content discovery and retrieval
- Cache-friendly - ETag and Last-Modified headers for performance
- Write API - Create, update, and bulk-import content programmatically with
write:contentscoped API keys - Content Export - Export all content in bulk-import-compatible format for backups, tenant duplication, and round-trip migrations
- Bulk Operations - Import and delete up to 100 items per request with dry-run validation, cascade deletion, and per-item error reporting
- Migration-friendly - Idempotent upserts via
externalIdand Markdown/HTML auto-conversion - Migration Guides - Copy-paste Python scripts to import from AWS S3 and GitHub with folder-to-hierarchy mapping and dry-run support
- Media API - Upload and manage images via API with ImageKit CDN delivery and on-the-fly transforms (resize, crop, auto-WebP)
- Webhooks - Real-time notifications for content updates
- OpenAPI Specification - Interactive API Explorer or download raw JSON (for developer tools)
Using this API with an AI assistant?
Point your LLM at one of these plain-text endpoints rather than at this page — each contains the full, up-to-date API reference without the surrounding markup:
- https://www.mentra.systems/llms-full.txt — full API docs (recommended)
- https://www.mentra.systems/llms.txt — short index per llmstxt.org
- https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/openapi.json — machine-readable OpenAPI spec
Tip: paste https://www.mentra.systems/llms-full.txt into Claude, ChatGPT, or Cursor and it will have everything it needs to write working integration code.
Authentication
All content and data endpoints require authentication via API Keys. You can provide the key using either the standard Authorization header or the custom X-Api-Key header.
Two kinds of endpoints are keyless. GET /health, GET /openapi.json, GET /changelog, and the tenant GET /api/public/{tenant_id}/llms.txt / llms-full.txt feeds take no key by design — the latter are built for LLM/answer-engine crawlers, which don't carry API keys. POST /telemetry/public/track (see Telemetry) also takes no API key. Instead it takes your workspace's public telemetry write key, which decides the workspace an event is recorded against.
How to Get an API Key
- Sign up or log in to Mentra
- Navigate to the API Keys page
- Create a new API key with the appropriate scopes
- Copy the key immediately! It is only shown once.
⚠️ Important: The key format ismn_live_PREFIX.SECRET. Make sure to copy the entire string, including the dot and the long secret part.
Authentication Header Format
Option 1: Bearer Token (Standard)
Authorization: Bearer mn_live_...Option 2: X-Api-Key (Simpler)
X-Api-Key: mn_live_...API Key Scopes
Each API key is granted one or more scopes that control what it can access. See the full Scopes Reference below for endpoint-level detail.
Scopes Reference
The table below lists every scope, what it grants, and which endpoint groups require it.
| Scope | Description | Endpoint Groups |
|---|---|---|
read:content | Read access to published structured and standalone content. | Sections (read), Standalone List/Get, Slug Lookup, Content Export |
read:preview | Access to draft / unpublished content. Only checked when publishedOnly=false. | Same as read:content — additive modifier |
read:analytics | Access to usage analytics and learner metrics. | Analytics endpoints future |
write:content | Create, update, and delete content (standalone + hierarchy). Also required for Bulk Import and Bulk Delete. | Standalone POST/PUT/DELETE, Level 1–4 POST/PUT/DELETE, Bulk Import, Bulk Delete |
write:media | Upload, manage, and delete media assets (images, videos). | Media Upload (URL), Media Upload (File), Media Delete, Media List |
⚠️ Tip: Grant only the scopes your integration needs. A read-only frontend should only use read:content. Add read:preview only for staging or preview environments.
Base URL
All API requests are made to the following base URL:
https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1💡 Tip: The URL shown here resolves automatically for the environment you're viewing the docs in.
Quick Start
Get started in 60 seconds with these ready-to-use examples:
Pagination
All list endpoints return paginated results. Use the page and pageSize query parameters to navigate through results.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
page | integer | 1 | 1-based page index. Must be ≥ 1. |
pageSize | integer | 20 | Items per page. Range: 1–100. |
Response Envelope
Every list response includes the following pagination metadata alongside the items array:
{
"items": [ ... ], // Array of content DTOs for the current page
"page": 1, // Current page number
"pageSize": 20, // Requested page size
"total": 57, // Total items matching the query (all pages)
"hasNext": true, // Whether a next page exists
"nextPage": 2 // Next page number, or null if last page
}Example: Iterating All Pages
import requests
API_KEY = "mn_live_..."
BASE = "https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1"
headers = {"X-Api-Key": API_KEY}
page = 1
all_items = []
while True:
resp = requests.get(
f"{BASE}/standalone",
headers=headers,
params={"locale": "en-US", "page": page, "pageSize": 50},
)
data = resp.json()
all_items.extend(data["items"])
if not data["hasNext"]:
break
page = data["nextPage"]
print(f"Fetched {len(all_items)} of {data['total']} items")💡 Tip: For bulk operations, prefer the Export endpoint (up to 100 items per page) rather than paginating through individual list endpoints.
Locale Parameter
Multi-Language Support
The locale parameter is REQUIRED for all content endpoints. It determines which language version of the content to return.
Content delivery is not limited to a fixed list — locale accepts any BCP-47 code you've published content under. On published reads (publishedOnly=true, the default) of /standalone, /sections, and /surfaces, it's matched prefix-aware (?locale=nb matches content published as nb-NO). Preview reads (publishedOnly=false) and slug lookup use the exact locale you request instead, with no prefix fallback.
Translating content into a new locale works from the CMS's language picker, which offers the codes below — each gets a correctly-named target language in the translation prompt. The underlying translate API will accept other BCP-47 codes too: ones shaped language-REGION still name a language in the prompt (built from the raw subtags, not necessarily this list's polished naming); other shapes (a bare language tag, or extra subtags) fall back to a generic placeholder with no language named at all. Either way it's gated by your plan's language quota, not by locale identity.
Locale Codes Recognized Out of the Box
Used to resolve a tenant's default locale, to name the language in AI prompts, and as the CMS's translation-target list.
Format
Locales follow the BCP-47 standard: language-region
Examples
https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/sections/YOUR_SECTION_KEY/tree?locale=nb-NO
https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/standalone/CONTENT_ID?locale=en-US
API Endpoints
Read Endpoints read:content
Standalone Content
Independent content items not tied to the hierarchical structure. Perfect for blog posts, articles, or standalone lessons.
/standaloneReturns metadata only for multiple items (title, summary, keywords, slug, timestamps).
Query Parameters
locale (required) - Content language (any locale you publish under — see Locale Parameter, not limited to en-US/nb-NO)publishedOnly (optional) - Filter published content (default: true)page (optional) - Page number (default: 1)pageSize (optional) - Items per page (default: 20, max: 100)https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/standalone?locale=nb-NO&publishedOnly=true&page=1&pageSize=20
/standalone/{id} Returns full content including HTML, plain text, media, SEO metadata, and AI-generated extensions.
Query Parameters
locale (required) - Content language (any locale you publish under — see Locale Parameter, not limited to en-US/nb-NO)include (optional) - Comma-separated list of extra fields (e.g. layout to get resolved page builder blocks)https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/standalone/{contentId}?locale=nb-NO&include=layoutStructured Content — Sections
Fetch a named structured-content group by its stable key. This replaces the retired per-level read routes.
/sectionsDiscovery — lists each Section by key with its label, template vocabulary, and advisory basePath hint.
https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/sections
{
"items": [
{
"key": "training",
"label": "Training",
"templateId": "learning_programme",
"basePath": "/training",
"template": {
"vocabulary": {
"programme": { "singular": "Programme", "plural": "Programmes" },
"module": { "singular": "Module", "plural": "Modules" },
"lesson": { "singular": "Lesson", "plural": "Lessons" }
},
"depth": 3,
"nodeTypes": [
{ "key": "programme", "singular": "Programme", "plural": "Programmes", "capabilities": ["content_bearing", "nav_root"] },
{ "key": "module", "singular": "Module", "plural": "Modules", "capabilities": ["content_bearing"] },
{ "key": "lesson", "singular": "Lesson", "plural": "Lessons", "capabilities": ["content_bearing"] }
]
}
}
]
}/sections/{key}/treeReturns the Section's ordered node tree in one call — each node carrying its per-locale slug, filtered to the requested locale's published, public content.
Query Parameters
locale (optional) - Content language (any locale you publish under — see Locale Parameter, not limited to en-US/nb-NO)publishedOnly (optional) - Filter published content (default: true; false requires read:preview)https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/sections/training/tree?locale=en-US
/sections/{key}/nodes/{id}One node's full content body — rendered content (HTML / TipTap JSON / plain text), SEO and schema.org metadata, media, and ancestor breadcrumbs. The tree gives you navigation cheaply; this renders the page. It is the structured peer of GET /standalone/{id}, and shares its include= vocabulary for the AI extensions — but not all of it: node detail adds children, and has no layout, which only standalone resolves. An unrecognised token is ignored rather than rejected, so include=layout here returns no error and no layout.
Query Parameters
locale (optional) - Content language (any locale you publish under — see Locale Parameter, not limited to en-US/nb-NO)include (optional) - Comma-separated opt-ins: quiz, scenarios, mnemonics, video, sources, children. Each is null unless its token is present — the node's direct children come back only with include=children.publishedOnly (optional) - Filter published content (default: true; false requires read:preview)https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/sections/training/nodes/{id}?locale=en-US&include=children,sourcesPages
List your published page-builder pages, or fetch one by its slug, ready to render on your site.
/pages/{slug}Returns the page's blocks in page order, its schema.org jsonLd, the SEO fields metaTitle, metaDescription and ogImage, and a canonicalUrl on your tenant's public URL (null when none is set).
Data-backed blocks come back resolved. A dynamic_content block gains resolved.items — the items it selects that are published in the requested locale — and a content_body block naming one specific article gains resolved.item. Blocks that need a content item to render against (meta_info, context_navigation, other content_body blocks, and any block with dynamicMappings) are returned unresolved with contextBound: true.
A draft page answers 404 page_not_found, the same as a slug no page uses. The slug matched is the published one, so a renamed page keeps its old URL until it is published again. Revalidate with If-None-Match; If-Modified-Since is ignored. Responses are Cache-Control: private and vary by API key, so don't share them across keys in a URL-keyed cache.
Query Parameters
locale (optional) - Language for the data-backed blocks' items (any locale you publish under — see Locale Parameter); a malformed tag is a 422publishedOnly (optional) - Default true; false returns the working copy and requires read:preview; its data-backed blocks then show unpublished articles and edits toohttps://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/pages/about-us?locale=en-US
/pagesLists your published pages for routes or a sitemap, sorted by slug. Each item is a summary with no blocks: id, slug, title, status, canonicalUrl, publishedAt and updatedAt, taken from the published version. Every listed slug is one GET /pages/{slug} serves; drafts and taken-down pages are not listed. Responses carry an ETag for If-None-Match, with the same caching headers as above.
Query Parameters
page / pageSize (optional) - Pagination (default 1 / 20, max pageSize 100); the response carries total, hasNext and nextPagepublishedOnly (optional) - Default true; false lists working copies at their working slugs, drafts included, and requires read:previewhttps://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/pages?page=1&pageSize=20
Write Endpoints write:content
write:content scope. Create one in the API Keys settings page.Structured content is written Section-addressed: /sections/{key}/nodes names a Section and a node type from its template, so any template shape is writable — branching, flat, deeper than four levels, or authored by your team.
The level-addressed write routes were removed and now return 404. Every Section, whatever its template, is written through these routes: read its node type keys from GET /sections and send the one you need as nodeTypeKey. An explicit "parentId": null is refused only when it would actually move the node — updating one that currently has a parent. Updating a node already at the Section root changes nothing and succeeds. On a create it means what omitting it means, “at the Section root”, so a non-root type is refused either way — with invalid_root_type, not reparent_unsupported. Omitting the field is still the safer habit on updates, since it means “leave the parent alone”. The Section must already exist.
PUT and DELETE check the Section, not the node type. PUT/DELETE /sections/{key}/nodes/{id} carry no expected type, so they check only that the node belongs to the named Section — a call with the wrong id resolves and deletes that node. If your client could mismatch an id and the type it means, check the node's nodeTypeKey first, or delete through /bulk-delete with a nodeTypeKey, which refuses a mismatch.
Create Content
Create standalone or hierarchy content via the API. Supports Markdown, HTML, or TipTap JSON input with automatic format conversion.
/standaloneCreate a standalone content item. Returns 201 Created on success.
curl -X POST "https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/standalone" \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Getting Started with AI",
"locale": "en-US",
"content": "# Introduction\nAI is transforming...",
"contentFormat": "markdown",
"externalId": "wp-post-42",
"tags": ["ai", "beginner"]
}'Request Body Fields
The two routes take different bodies, and both reject unknown fields with a 422 rather than ignoring them — a field from the wrong list is an error, not a no-op.
POST /standalone
title (required) - Content titlelocale (optional, default: en) - Content languagecontent (optional) - Content body (string or JSON)contentFormat (optional) - One of markdown, html, json. Auto-converts to internal format.externalId (optional) - Your system’s unique ID. Enables idempotent upsert.tags (optional) - Array of tag stringskeywords (optional) - Array of keyword stringssummary (optional) - Short descriptioncoverImageUrl (optional) - Cover image URLslug (optional) - URL slug. Generated from the title when omitted.visibilityTags (optional) - Array of audience tagsseoMetadata (optional) - SEO overridesPOST /sections/{sectionKey}/nodes — not the list above
nodeTypeKey (required) - A node type from that Section’s template. No default; omitting it is a 422.title (required) - Node titleparentId (optional) - A node in the same Section whose type allows this one as a child. Omit it to create at the Section root.locale (optional, default: en) - Content languagecontent, contentFormat, summary, coverImageUrl, keywords, externalId, slug, visibilityTags, seoMetadata - as aboveorder (optional) - Sort position within the parenttags. The Section body has no such field, so sending it is a 422. keywords and visibilityTags are the node equivalents.Idempotent Upsert via externalId
Include an externalId and a matching existing item is updated rather than duplicated, returning 200 OK instead of 201 Created.
The two routes match on different scopes, and the difference makes duplicates rather than errors. POST /standalone matches tenant-wide. POST /sections/{sectionKey}/nodes matches only within that Section and that nodeTypeKey — the same id under a different Section or node type is a different node and gets created. A re-import aimed at the wrong Section or type silently writes a second node instead of updating the one you meant. Identity for a node is the triple (Section, node type, externalId).
This is the recommended pattern for CMS migrations from Contentful, Strapi, Sanity, or WordPress. Use your source system’s ID as the externalId so you can re-run imports safely.
Update Content
Update existing content by its Firestore ID. Supports partial updates — only provided fields are modified.
Re-parenting is not supported on any write surface, and no endpoint moves an item. Re-sending an item's unchanged parentId is always fine. The surfaces differ in how they read an explicit null:
PUT /sections/{key}/nodes/{id} — any parentId you actually send that differs returns 400 reparent_unsupported, an explicit null included: on a child that reads as "move to the Section root", which is still a move. Omit the field to leave the parent alone.POST /import — an existing item's parent cannot be changed here either. What you get back depends on onConflict:update — a node item sending "parentId": null for a node that currently has a parent comes back inside a 200 as action: "failed", so branch on the row's action, not on the status code. Its error is the human-readable message, not the reparent_unsupported code — rows carry no machine-readable code, so don't match on one. If the node is already at the Section root, null matches its current parent, so nothing is being moved and the update succeeds.skip — existing items are skipped before any parent check runs, so the parent is never validated.fail — the whole request aborts with 409 before any item is processed.To place a leaf under a different parent, re-create it in the new position and delete the original. A subtree takes two passes in opposite directions: build the new one top-down, since every create needs a parent that already exists (bulk import resolves only parents created earlier in the same batch), then delete the old one leaf-first, since the delete below is refused while children exist.
Give the replacements fresh identifiers. The old subtree is still present while you build the new one, so a create that reuses the original's externalId is not a create at all — every create path resolves that key first and turns the request into an update of the original, which then refuses the new parent with reparent_unsupported. An explicit slug reused the same way returns slug_conflict. Build with temporary values, delete the originals, then reassign the real externalId and slug with a normal update once the keys are free.
Two uniqueness rules can turn an update into a 409. Their scopes differ — read the one that applies to your surface rather than assuming a tenant-wide namespace:
slug_conflict — a slug you supply explicitly is unique per tenant and locale, across every level and Section. The same slug in a different locale is fine, so a multilingual site can keep one slug per translation. (A slug generated from the title auto-suffixes -2, -3 instead of conflicting — only an explicit slug raises.)external_id_conflict — the scope depends on the surface. For a Section node it is unique per Section and node type, so the same externalId may be reused for the same node type in a different Section. For /standalone it is per tenant. Assigning one already held inside its own scope returns 409 and writes nothing./standalone/{id} |/sections/{key}/nodes/{id} curl -X PUT "https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/standalone/CONTENT_ID" \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Updated Title",
"content": "<h1>New Content</h1><p>Updated via API.</p>",
"contentFormat": "html"
}'Delete Content
Permanently delete a content item and all its translations.
Structured deletes never cascade and never orphan. Deleting an item that still has children returns 409 node_has_children and removes nothing — this holds for the Section node routes. Standalone items have no children and are unaffected.
To remove a whole subtree, /bulk-delete with cascade: true on a node item does the walk for you, in any Section. Without it, clear the subtree leaf-first with DELETE /sections/{key}/nodes/{id}.
/standalone/{id} |/sections/{key}/nodes/{id} curl -X DELETE "https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/standalone/CONTENT_ID" \ -H "X-Api-Key: YOUR_API_KEY"
Structured Content — Section Nodes
Create, update, and delete a node by naming its Section key and a node type from that Section's template — the write peer of GET /sections/{key}/tree. Where the retired level-addressed routes addressed four fixed rungs, these place a node at whatever depth the template permits.
/sections/{sectionKey}/nodesCreates a node in the Section. nodeTypeKey is required and must be a node type of that Section's template — read the vocabulary from GET /sections rather than assuming it. Omit parentId to create at the Section root; otherwise it must reference a node in the same Section whose type allows this one as a child. Returns 201 Created, or 200 OK when an externalId already in use for that Section and node type makes it an upsert.
On that upsert path, omit parentId to keep an existing child where it is. The create route forwards whether you sent the field, not its value, so a serializer that always emits "parentId": null turns a re-POST of an existing child into a move-to-root request and gets 400 reparent_unsupported instead of the 200 upsert.
Body Fields
nodeTypeKey (required) - A node type of the Section's templatetitle (required)parentId (optional) - Omit for a Section-root nodeexternalId (optional) - Upsert key, unique per Section + node typecontent, contentFormat, keywords, seoMetadata - content body (see the capability note below)summary, coverImageUrl, slug, locale, order, visibilityTags# A "training" Section built from the built-in Programme template
# (programme → module → lesson). The parent is a module node.
curl -X POST "https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/sections/training/nodes" \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"nodeTypeKey": "lesson",
"parentId": "MODULE_NODE_ID",
"externalId": "lesson-invite",
"title": "Inviting your team",
"slug": "inviting-your-team",
"content": "## Invite a colleague\n\n1. Open Settings → Team…",
"contentFormat": "markdown",
"locale": "en-US"
}'/sections/{sectionKey}/nodes/{nodeId}Partial update. A node's type is immutable and moves are not supported: a changed parentId returns 400 reparent_unsupported.
/sections/{sectionKey}/nodes/{nodeId}Deletes the node and its attached content. Does not cascade — a node that still has children returns 409 node_has_children.
Only content_bearing types take a body
A template's node types declare whether they can carry a content body. Sending content, contentFormat, keywords, or seoMetadata to a container type returns 400 not_content_bearing rather than silently dropping it. title, summary and coverImageUrl are always accepted, so container nodes stay writable.
Sections and their templates are authored in the Mentra app, not over this API. An unknown Section key returns 404 section_not_found; a node type that isn't in the template returns 400 unknown_node_type.
Bulk Import write:content
Bulk Import Content
Import up to 100 items in a single request. Supports mixed content types with per-item error reporting: standalone items, and node items, which carry a sectionKey plus a nodeTypeKey from that Section's template so any template shape is importable. Both values come from GET /sections; sectionKey / nodeTypeKey are accepted only with type: "node" and rejected on a standalone item rather than ignored. Any other type rejects the whole request with 400 invalid_item_type.
/importConflict Resolution Strategies
skip — Existing items are left unchanged (no update)update — Existing items are updated with new data (upsert)fail — If ANY item exists, the entire import is aborted with 409curl -X POST "https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/import" \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"items": [
{
"type": "standalone",
"externalId": "wp-post-1",
"title": "First Article",
"content": "# Hello World",
"contentFormat": "markdown",
"locale": "en-US"
},
{
"type": "node",
"sectionKey": "knowledge-base",
"nodeTypeKey": "category",
"externalId": "kb-basics",
"title": "Basics",
"locale": "en-US"
},
{
"type": "node",
"sectionKey": "knowledge-base",
"nodeTypeKey": "article",
"externalId": "kb-getting-started",
"parentExternalId": "kb-basics",
"title": "Getting started",
"slug": "getting-started",
"content": "## Welcome\n\nThis article walks you through your first steps.",
"contentFormat": "markdown",
"locale": "en-US"
}
],
"onConflict": "update"
}'Parent Resolution
Use parentExternalId to reference parent items by their external ID. Parents are resolved within the batch first, then from existing Firestore data. Order items so parents appear before children. For a node item the parent is looked up in the same Section, among the node types the template allows as its parent (in the example above, a Section built from the knowledge_base template, an article's parent is a category). If two eligible parent types share one externalId, add parentNodeTypeKey to name the right one.
Dry-Run Mode
Set dryRun: true in the request body to validate your import without writing any data to Firestore. The response uses preview actions:
would_create — Item does not exist, would be createdwould_update — Item exists and would be updated (onConflict: update)would_skip — Item exists and would be skipped (onConflict: skip)Use dry-run to validate data, check for conflicts, and preview the outcome before committing.
{
"totalItems": 3,
"created": 2,
"updated": 1,
"skipped": 0,
"failed": 0,
"dryRun": false,
"results": [
{ "externalId": "wp-post-1", "id": "abc123", "type": "standalone", "action": "created",
"error": null, "sectionKey": null, "nodeTypeKey": null },
{ "externalId": "kb-basics", "id": "def456", "type": "node", "action": "updated",
"error": null, "sectionKey": "knowledge-base", "nodeTypeKey": "category" },
{ "externalId": "kb-getting-started", "id": "ghi789", "type": "node", "action": "created",
"error": null, "sectionKey": "knowledge-base", "nodeTypeKey": "article" }
]
}Migration Guide
Migrating from another CMS? The Write API is designed for seamless, repeatable imports.
1. Map your content IDs
Use your source CMS’s unique identifiers as externalId values. This makes imports idempotent — running the same script twice won’t create duplicates.
2. Choose your content format
Export your content as Markdown or HTML and set contentFormat accordingly. Mentra automatically converts it to the internal editor format.
3. Use bulk import with conflict strategy
For initial imports, use onConflict: \"fail\" to catch unexpected duplicates. For incremental syncs, use onConflict: \"update\" to upsert changes.
4. Build hierarchy top-down
Create the Section's root nodes first (for example category), then their children (article, referencing the parent via parentId or parentExternalId), and so on. In bulk imports, order parents before children.
import requests
import json
API_URL = "https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1"
API_KEY = "YOUR_API_KEY"
headers = {"X-Api-Key": API_KEY, "Content-Type": "application/json"}
# Export from your CMS (example: WordPress posts)
posts = get_wordpress_posts() # your export function
# Batch into groups of 100
for batch in chunks(posts, 100):
payload = {
"items": [
{
"type": "standalone",
"externalId": f"wp-{post['id']}",
"title": post["title"],
"content": post["content"],
"contentFormat": "html",
"locale": "en-US",
"tags": post.get("tags", [])
}
for post in batch
],
"onConflict": "update"
}
resp = requests.post(f"{API_URL}/import", headers=headers, json=payload)
result = resp.json()
print(f"Created: {result['created']}, Updated: {result['updated']}")Media API write:media
write:media scope. Uploaded images are stored as originals and served via ImageKit CDN with on-the-fly optimization and transforms.CDN Image Transforms
All image URLs in API responses (content coverImageUrl, media cdnUrl, and inline content images) are served via ImageKit CDN. You can append transformation parameters to any CDN URL for responsive, optimized delivery.
URL Format
CDN URLs follow the pattern: https://ik.imagekit.io/MentraIntelligence/tr:TRANSFORMS/path/to/image.jpg
To add transforms to a cdnUrl, insert tr:PARAMS after the endpoint base.
# Thumbnail (200x200 square crop)
https://ik.imagekit.io/MentraIntelligence/tr:w-200,h-200,c-at_max,fo-auto/{tenantId}/media/originals/image.jpg
# Card size (600px wide, auto height)
https://ik.imagekit.io/MentraIntelligence/tr:w-600,fo-auto,f-webp,q-85/{tenantId}/media/originals/image.jpg
# Content width (800px, quality optimized)
https://ik.imagekit.io/MentraIntelligence/tr:w-800,f-webp,q-80/{tenantId}/media/originals/image.jpg
# Auto-format (browser-negotiated WebP/AVIF)
https://ik.imagekit.io/MentraIntelligence/tr:f-auto,q-80/{tenantId}/media/originals/image.jpgTransform Parameters
w-{N} — Width in pixelsh-{N} — Height in pixelsc-at_max — Crop mode: fit within boundsfo-auto — Smart focus (auto-detect subject)f-webp / f-auto — Force WebP or auto-negotiate best formatq-{N} — Quality (1–100, default 80)Best Practice: Responsive Images
Use the cdnUrl from API responses as-is for full quality, or append transform parameters for responsive srcset generation. ImageKit automatically negotiates the best format (WebP/AVIF) based on the browser’s Accept header when using f-auto.
Upload Media via URL
Download an image from a public URL and store it in your tenant’s media library. The original file is stored as-is — optimization and format conversion happens on-the-fly via the CDN.
/media/upload-urlRequest Body
sourceUrl (required) - Public HTTP/HTTPS URL of the image to downloadfilename (optional) - Override the filename (auto-detected from URL if omitted)alt (optional) - Alt text for accessibilitytags (optional) - Array of tag strings for categorizationcurl -X POST "https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/media/upload-url" \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"sourceUrl": "https://example.com/hero-image.jpg",
"alt": "Hero banner for course landing page",
"tags": ["hero", "banner"]
}'{
"mediaId": "abc123def456",
"originalUrl": "https://storage.googleapis.com/.../originals/hero-image.jpg",
"processedUrl": "https://storage.googleapis.com/.../originals/hero-image.jpg",
"cdnUrl": "https://ik.imagekit.io/MentraIntelligence/tr:f-auto,q-80/{tenantId}/media/originals/hero-image.jpg",
"filename": "hero-image.jpg",
"contentType": "image/jpeg",
"sizeBytes": 245760,
"alt": "Hero banner for course landing page",
"tags": ["hero", "banner"],
"createdAt": "2025-02-24T12:00:00Z"
}Recommended: Use cdnUrl
Always prefer cdnUrl over originalUrl or processedUrl for displaying images. The CDN URL provides global edge caching, automatic format negotiation, and on-the-fly transforms.
Upload Media File
Upload a binary image file directly via multipart/form-data. Ideal for programmatic uploads from scripts or CI/CD pipelines.
/media/uploadForm Fields
file (required) - Binary image filealt (optional) - Alt text for accessibilitytags (optional) - Comma-separated tag string (e.g. "hero,banner,course")curl -X POST "https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/media/upload" \ -H "X-Api-Key: YOUR_API_KEY" \ -F "file=@./my-image.png" \ -F "alt=Course thumbnail" \ -F "tags=thumbnail,course"
Supported Formats & Limits
Formats: JPEG, PNG, GIF, WebP, SVG, BMP, TIFF
Max file size: 20 MB per upload
Optimization: Originals stored as-is. CDN applies on-the-fly format conversion and compression via cdnUrl.
Get Media Metadata
Retrieve metadata and public URLs for a specific media asset.
/media/{mediaId} curl "https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/media/MEDIA_ID" \ -H "X-Api-Key: YOUR_API_KEY"
{
"mediaId": "abc123def456",
"tenantId": "tenant_xyz",
"originalUrl": "https://storage.googleapis.com/.../originals/image.jpg",
"processedUrl": "https://storage.googleapis.com/.../originals/image.jpg",
"cdnUrl": "https://ik.imagekit.io/MentraIntelligence/tr:f-auto,q-80/{tenantId}/media/originals/image.jpg",
"filename": "image.jpg",
"contentType": "image/jpeg",
"sizeBytes": 245760,
"alt": "Descriptive alt text",
"tags": ["hero", "banner"],
"createdAt": "2025-02-24T12:00:00Z",
"source": "api_url"
}Response Fields
originalUrl — Direct Firebase Storage URL (legacy, not CDN-optimized)processedUrl — Storage URL (legacy, kept for backward compatibility)cdnUrl — Recommended. ImageKit CDN URL with auto-format and quality optimizationList Media Assets
List all media assets for your tenant with pagination and optional tag filtering. Each item includes a cdnUrl for optimized delivery.
/mediaQuery Parameters
limit (optional) - Number of items per page (1–100, default: 20)offset (optional) - Number of items to skip (default: 0)tag (optional) - Filter by tag (e.g. tag=hero)curl "https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/media?limit=10&tag=thumbnail" \ -H "X-Api-Key: YOUR_API_KEY"
{
"items": [
{
"mediaId": "abc123",
"filename": "thumbnail.jpg",
"processedUrl": "https://storage.googleapis.com/.../originals/thumbnail.jpg",
"cdnUrl": "https://ik.imagekit.io/MentraIntelligence/tr:f-auto,q-80/{tenantId}/media/originals/thumbnail.jpg",
"sizeBytes": 102400,
"tags": ["thumbnail"],
"createdAt": "2025-02-24T12:00:00Z"
}
],
"total": 1,
"limit": 10,
"offset": 0
}Delete Media Asset
Permanently delete a media asset, including the original file from storage. The freed storage quota is returned to your tenant.
/media/{mediaId} curl -X DELETE "https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/media/MEDIA_ID" \ -H "X-Api-Key: YOUR_API_KEY"
{
"mediaId": "abc123def456",
"deleted": true,
"message": "Media asset deleted successfully",
"bytesFreed": 348160
}Surfaces (Placements)
A Surface is a named content slot on your site — a front-page teaser row, a pricing-page highlight strip — whose contents editors curate in Mentra. Each entry inside it is a Placement: a reference to one standalone content item, optionally time-boxed with an active window.
Resolve a Surface by Key
Wire a surface key into a page section once — from then on, editors control the slot entirely from the CMS
The surface's key (e.g. front-page-teasers) is the contract between Mentra and your site. All curation logic is evaluated server-side at read time: the API returns only the currently-active, published, ordered items — no client-side filtering, window math, or sorting required.
Endpoints
GET /surfaces — list the tenant's surface keys + labels (discovery)
GET /surfaces/{key} — resolve a surface: its currently-active contentPaths are relative to the base URL (see Base URLs). The optional locale query parameter serves only items published in that locale, BCP 47 prefix-aware (?locale=nb matches nb-NO).
Example
curl "https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/surfaces/front-page-teasers?locale=nb-NO" \ -H "X-Api-Key: YOUR_API_KEY"
Response
Surface metadata plus the resolved items in curated order. Each item is the reduced content summary shape returned by GET /standalone/summary — not the full detail object (no body content, media, or seoMetadata). For richer rendering, follow up with GET /standalone/{id}?locale={item.locale} per item — pass the item's locale, since the detail endpoint defaults an omitted locale to the document's original language.
{
"key": "front-page-teasers",
"label": "Front page teasers",
"maxItems": 4,
"ordering": "manual",
"items": [
{
"id": "a1b2c3d4e5f6",
"slug": "derfor-vinner-radardata",
"title": "Derfor vinner radardata over magefølelsen",
"summary": "Kort ingress som brukes i teaser-kortet …",
"coverImageUrl": "https://ik.imagekit.io/…/cover.webp",
"locale": "nb-NO",
"tags": ["innsikt"],
"publishedAt": "2026-07-10T08:31:12Z",
"author": { "id": "auth_123", "name": "Kari Nordmann" }
}
]
}Resolution Rules
The items array is computed fresh on every (non-cached) request, in this order:
- Active window. A placement with
activeFrom/activeUntilis served only whileactiveFrom <= now < activeUntil(inclusive start, exclusive end; a missing bound is unbounded). Windows take effect on the next request — there is no publish job to wait for. - Published in the requested locale. Unpublished, deleted, and visibility-restricted content is silently skipped, never an error.
- Ordering.
manualserves the editor's hand-curated order;newestserves pinned placements first, then the rest by content recency. maxItemsis applied last, capping the surviving ordered list.
Empty vs. 404
A known key with zero active items returns 200 with "items": [] (the slot is configured but currently empty — render nothing or a fallback). An unknown key returns 404 with the machine-readable code surface_not_found:
{
"error": {
"code": "surface_not_found",
"message": "Surface not found",
"request_id": "a1b2c3d4-…"
}
}Caching & Revalidation
- Revalidate with
If-None-Match(ETag) only — the resolved item set is time-windowed, soIf-Modified-Sinceis ignored by the endpoint. - Subscribe to the
surface.updatedwebhook for push-based invalidation: it fires on every surface mutation and its payload carries thesurfaceKey. surface.updatedalone is not sufficient invalidation. Publishing or unpublishing a placed item changes the surface response but emitscontent.published/content.unpublishedinstead — revalidate on those too. And some changes emit no webhook at all: a scheduled window opening or closing on its own, a visibility-tags-only change to a placed item, and creating a new surface (it starts empty, but a cached discovery list or a cached 404 for its key stays stale). If you cache aggressively, keep time-based revalidation (a modest TTL or ISR-stylerevalidate) as the safety net for those.
<FrontPageTeasers> component — build one <Surface> component that takes a key and reuse it for every slot. Adding a new curated section then becomes a CMS action plus one line of markup, not a development task.component Surface(key, locale):
response = GET {base_url}/surfaces/{key}?locale={locale}
with header X-Api-Key
with cached ETag via If-None-Match
if response is 404: # slot not configured — a wiring problem
log warning; render nothing
if response.items is empty: # configured but currently empty
render nothing (or a fallback)
render section:
heading = response.label
for item in response.items: # already filtered, ordered, capped
render card(item.title, item.summary, item.coverImageUrl,
link to /articles/{item.slug})
# Usage — every curated slot is one line:
page "/" renders: Surface("front-page-teasers", locale)
page "/pricing" renders: Surface("pricing-highlights", locale)
# Webhook handler — one generic revalidation path for all surfaces:
on webhook "surface.updated" (payload):
revalidate pages rendering payload.data.surfaceKeyContent Discovery
Resolve content by human-readable slugs instead of Firestore IDs. Perfect for building SEO-friendly URL routing on your frontend.
Slug Lookup
Resolve a slug to a standalone or legacy level content item
/content/by-slug/{slug}Searches standalone content and the nodes of every Section for a document matching the given slug and locale. Returns minimal metadata for routing. A standalone hit can then be fetched in full from /standalone/{id}. A node hit — a node in any of the tenant's Sections — additionally carries the sectionKey of the Section it belongs to — fetch the node's full body via GET /sections/{sectionKey}/nodes/{id} (or its tree via GET /sections/{sectionKey}/tree). Only content with a public read path is returned: a node whose ancestors are not all published and public is not returned.
Query Parameters
locale (optional) — Locale code (e.g. en-US). If the slug is not found for the requested locale, falls back to the document's originalLocale. Defaults to en-US.contentType (optional) — Limit search to standalone or node. When omitted, standalone content is searched first, then nodes. Any other value returns 400 invalid_content_type.publishedOnly (optional) — Filter to published content only. Defaults to true. Set to false with a preview-scoped API key to include drafts.Locale Fallback Chain
GET https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/content/by-slug/getting-started-with-ai?locale=en-US
{
"id": "abc123def456",
"contentType": "standalone",
"slug": "getting-started-with-ai",
"resolvedLocale": "en-US",
"title": "Getting Started with AI",
"summary": "An introduction to artificial intelligence...",
"coverImageUrl": "https://example.com/cover.jpg",
"externalId": "wp-post-42",
"createdAt": "2024-11-21T10:00:00Z",
"updatedAt": "2025-03-15T14:30:00Z",
"publishedAt": "2025-01-10T09:00:00Z"
}SEO-Friendly URL Routing Pattern
Use slug lookup to power clean URLs like /blog/getting-started-with-ai, then fetch full content by the returned ID:
// Step 1: Resolve slug to content metadata
const slug = window.location.pathname.split('/').pop();
const res = await fetch(
`${BASE_URL}/content/by-slug/${slug}?locale=en-US`,
{ headers: { 'X-Api-Key': API_KEY } }
);
const meta = await res.json();
// e.g. meta.contentType = "standalone", meta.id = "abc123"
// node hits (meta.contentType = "node") also carry meta.sectionKey
// Step 2: Both content types have a per-item body endpoint. Standalone reads
// from /standalone/{id}; a structured node reads from its Section, addressed
// by meta.sectionKey + meta.id.
if (meta.contentType === 'standalone') {
const full = await fetch(
`${BASE_URL}/standalone/${meta.id}?locale=${meta.resolvedLocale}`,
{ headers: { 'X-Api-Key': API_KEY } }
);
const content = await full.json();
} else {
const full = await fetch(
`${BASE_URL}/sections/${meta.sectionKey}/nodes/${meta.id}?locale=${meta.resolvedLocale}`,
{ headers: { 'X-Api-Key': API_KEY } }
);
const content = await full.json();
// content.contentHtml, content.seoMetadata, content.breadcrumbs, …
}getting-started-with-ai for en-US, kom-i-gang-med-ki for nb-NO).Content Export read:content
Export All Content
Export your entire tenant's content library in a format that can be re-imported via the Bulk Import endpoint. This enables round-trip migrations, backups, and tenant duplication.
/exportQuery Parameters
type (optional) — Filter by item type:standalone, node. Omit for everything; any other value returns 400 invalid_type_filter.publishedOnly (optional, default: true) — Published, publicly visible content only. Set false for a full backup including drafts; that requires the read:preview scope and returns 403 without it.page (optional) — Page number (default: 1)pageSize (optional) — Items per page, 1–100 (default: 100)curl "https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/export?pageSize=50" \ -H "X-Api-Key: YOUR_API_KEY"
{
"totalItems": 42,
"items": [
{
"type": "standalone",
"id": "abc123",
"externalId": "wp-post-1",
"title": "Introduction to Learning",
"content": "Plain text content...",
"contentJson": { "type": "doc", "content": [...] },
"contentHtml": "<h1>Introduction</h1><p>...</p>",
"contentFormat": "json",
"summary": "A brief summary...",
"coverImageUrl": "https://ik.imagekit.io/...",
"tags": ["intro", "basics"],
"locale": "en-US"
},
{
"type": "node",
"id": "def456",
"sectionKey": "training",
"nodeTypeKey": "programme",
"externalId": "prog-onboarding",
"title": "Customer Onboarding",
"slug": "customer-onboarding",
"order": 1,
"parentId": null,
"parentExternalId": null,
"parentNodeTypeKey": null,
"locale": "en-US"
},
{
"type": "node",
"id": "ghi789",
"sectionKey": "training",
"nodeTypeKey": "module",
"externalId": "mod-workspace",
"title": "Setting up your workspace",
"slug": "setting-up-your-workspace",
"order": 1,
"parentId": null,
"parentExternalId": "prog-onboarding",
"parentNodeTypeKey": "programme",
"locale": "en-US"
}
],
"page": 1,
"pageSize": 50,
"hasNext": false,
"nextPage": null
}Round-Trip Workflow
The export format mirrors the import format (items above are abbreviated — every item also carries the body, SEO, visibility, publish-state and timestamp fields). Items come out ordered standalone first, then every Section's node items parents-before-children. A node item's parentId is always null: a source-tenant id would name nothing in the target, so the parent travels as parentExternalId + parentNodeTypeKey. The import Section must already exist in the target tenant under the same key. Export-only fields (id, contentJson, contentHtml, isPublished, timestamps) are dropped on import. To migrate content between tenants:
- Export from source tenant with
GET /export - Validate with
POST /importusingdryRun: true - Import for real with
dryRun: false
import requests
SOURCE_URL = "https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1"
TARGET_URL = "https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1" # or different tenant
SOURCE_KEY = "SOURCE_TENANT_API_KEY"
TARGET_KEY = "TARGET_TENANT_API_KEY"
# 1. Export all content
page = 1
all_items = []
while True:
resp = requests.get(
f"{SOURCE_URL}/export?page={page}&pageSize=100",
headers={"X-Api-Key": SOURCE_KEY}
)
data = resp.json()
all_items.extend(data["items"])
if not data["hasNext"]:
break
page = data["nextPage"]
print(f"Exported {len(all_items)} items")
# 2. Dry-run import to target
resp = requests.post(
f"{TARGET_URL}/import",
headers={"X-Api-Key": TARGET_KEY, "Content-Type": "application/json"},
json={"items": all_items[:100], "onConflict": "update", "dryRun": True}
)
result = resp.json()
print(f"Dry-run: {result['created']} would create, {result['updated']} would update")
# 3. Import for real
resp = requests.post(
f"{TARGET_URL}/import",
headers={"X-Api-Key": TARGET_KEY, "Content-Type": "application/json"},
json={"items": all_items[:100], "onConflict": "update", "dryRun": False}
)
result = resp.json()
print(f"Done: {result['created']} created, {result['updated']} updated")Bulk Delete write:content
Bulk Delete Content
Delete up to 100 items in a single request. Each item can be identified by its Mentra id (Firestore document ID) or by externalId.
Bulk delete takes standalone and node items. A node item names its Section with sectionKey, and reaches only a node in that Section: a node elsewhere, another tenant's node, or an id whose type doesn't match the nodeTypeKey you sent comes back as not_found. An unknown sectionKey fails that item instead. Any other type rejects the whole request with 400 invalid_item_type, and nothing is deleted.
/bulk-deleteRequest Body
items (required) — Array of items to delete (max 100)type — Content type:standalone or nodeid — Firestore document ID (one of id or externalId required)externalId — External ID lookupsectionKey — node items only, and required on them: the node's Section (from GET /sections)nodeTypeKey — node items only. Required with externalId, which is unique per Section and node type; optional with id, and when sent the node must be of that typecascade (optional, default: false) — For a node item, also delete every descendant nodedryRun (optional, default: false) — Validate without deletingcurl -X POST "https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/bulk-delete" \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"items": [
{
"type": "standalone",
"externalId": "wp-post-1"
},
{
"type": "node",
"sectionKey": "knowledge-base",
"id": "abc123",
"cascade": true
}
],
"dryRun": false
}'{
"totalItems": 2,
"deleted": 2,
"notFound": 0,
"failed": 0,
"dryRun": false,
"results": [
{
"id": "s_91ad",
"externalId": "wp-post-1",
"type": "standalone",
"action": "deleted",
"cascadeDeleted": 0,
"error": null,
"sectionKey": null,
"nodeTypeKey": null
},
{
"id": "abc123",
"externalId": null,
"type": "node",
"action": "deleted",
"cascadeDeleted": 4,
"error": null,
"sectionKey": "knowledge-base",
"nodeTypeKey": null
}
]
}cascade: true on a node item also deletes its children, their children, and so on to the leaves, bottom-up. The cascadeDeleted field in the response shows the total number of descendant nodes removed. Without it, a node that still has children fails with node_has_children. Results echo the sectionKey and nodeTypeKey each node item was sent with.Safety: Use Dry-Run First
Set dryRun: true to preview exactly what would be deleted without removing any data. The response will show would_delete actions and cascade counts, letting you validate before committing.
Migration Guides
Import your existing content from AWS S3 buckets or GitHub repositories into Mentra. These guides provide complete, runnable Python scripts with dry-run support.
Import from AWS S3write:contentwrite:media
Overview
Migrate content stored as Markdown, HTML, or JSON files in an AWS S3 bucket into Mentra. The script maps your folder structure onto a Section's node types, root to leaf, and uploads media assets via presigned URLs.
Out of the box the script targets a training Section built from the built-in Programme template, so folders become programme and module nodes and content files become lesson nodes. Items go up as type: "node" with a sectionKey and a nodeTypeKey from that Section's template.
To import into any other shape, change SECTION_KEY and NODE_TYPE_CHAIN at the top of the script — a Knowledge Base, a flat list of Pages, or a template you authored yourself all import, at whatever depth the template permits. Both values come from GET /v1/sections.
The Section itself — its key, its template, and its node types — is authored in the Mentra app: the structure authoring endpoints need a signed-in user with structured_content:manage. The public API writes into a Section — bulk node items here, or the /sections/{key}/nodes routes for single items — but it cannot create the Section itself. Create the Section first, then import into it.
Folder → Hierarchy Mapping
my-content-bucket/
├── customer-onboarding/ → programme (Section root)
│ ├── meta.json → { "title": "Customer Onboarding", "summary": "..." }
│ ├── cover.jpg → Cover image (uploaded via Media API)
│ ├── setting-up-your-workspace/ → module
│ │ ├── meta.json
│ │ ├── 01-inviting-your-team.md → lesson, order = 1
│ │ ├── 02-connecting-your-tools.md → lesson, order = 2
│ │ └── images/
│ │ └── team-settings.png → Media asset
│ └── your-first-project/
│ ├── meta.json
│ └── 01-creating-a-project.md
└── advanced-workflows/
└── ....md, .html, or .json files become leaf nodes (lessons, by default)
Images uploaded via presigned URL → Media API → coverImageUrl
meta.json in each folder provides title, summary, keywords
Prerequisites
pip install boto3 requests
write:content and write:media scopes~/.aws/credentials, environment variables, or IAM role)Complete Import Script
DRY_RUN = True first. The script will validate your content and show what would be created without writing anything."""
S3 → Mentra Import Script
=========================
Migrates content from an S3 bucket into any Section, whatever its structure
template. Folder depth maps onto the target's node-type chain, root → leaf;
content files inside the deepest folder level become the leaf nodes.
Programme (default): bucket/a/b/01-lesson.md → programme > module > lesson
Knowledge Base: bucket/a/01-article.md → category > article
Usage:
1. Set the configuration variables below
2. Run with DRY_RUN = True to preview
3. Review the output
4. Set DRY_RUN = False to execute
"""
import boto3
import requests
import json
import os
import re
from pathlib import PurePosixPath
# ─── Configuration ───────────────────────────────────────────────
MENTRA_API_KEY = os.environ.get("MENTRA_API_KEY", "your-api-key-here")
MENTRA_BASE_URL = "https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1"
S3_BUCKET = "my-content-bucket"
S3_PREFIX = "" # Optional: only import from a subfolder
AWS_REGION = "eu-west-1"
LOCALE = "en-US"
DRY_RUN = True # ← Start with True!
# ─── Structure target ────────────────────────────────────────────
# The Section to import into (it must already exist — create it in the
# Mentra app) and its node types root → leaf. Both come from GET /v1/sections
# (`key`, and `template.vocabulary`'s keys). The default is a "training"
# Section built from the built-in Programme template; a Knowledge Base, a
# flat list of Pages, or your own template work the same way, at any depth:
# SECTION_KEY = "knowledge-base"; NODE_TYPE_CHAIN = ["category", "article"]
SECTION_KEY = "training"
NODE_TYPE_CHAIN = ["programme", "module", "lesson"]
SKIP_FOLDERS = {"images", "media", "assets", "_assets"}
# ─── Helpers ─────────────────────────────────────────────────────
HEADERS = {
"Authorization": f"Bearer {MENTRA_API_KEY}",
"Content-Type": "application/json",
}
def structure_chain() -> list[str]:
"""The root → leaf chain folder depth maps onto."""
if not SECTION_KEY or not NODE_TYPE_CHAIN:
raise SystemExit("Set SECTION_KEY and NODE_TYPE_CHAIN (see GET /v1/sections).")
return NODE_TYPE_CHAIN
def address(depth: int) -> dict:
"""The item fields that name a target at this depth in the chain."""
return {
"type": "node",
"sectionKey": SECTION_KEY,
"nodeTypeKey": NODE_TYPE_CHAIN[depth],
}
def slugify(text: str) -> str:
"""Convert text to a URL-safe slug."""
text = text.lower().strip()
text = re.sub(r"[^\w\s-]", "", text)
return re.sub(r"[\s_]+", "-", text).strip("-")
def read_s3_text(s3_client, bucket: str, key: str) -> str:
"""Read a text file from S3."""
obj = s3_client.get_object(Bucket=bucket, Key=key)
return obj["Body"].read().decode("utf-8")
def read_s3_json(s3_client, bucket: str, key: str) -> dict:
"""Read and parse a JSON file from S3."""
return json.loads(read_s3_text(s3_client, bucket, key))
def detect_content_format(filename: str) -> str:
"""Detect content format from file extension."""
ext = PurePosixPath(filename).suffix.lower()
return {".md": "markdown", ".html": "html", ".json": "json"}.get(ext, "markdown")
def extract_order(filename: str) -> int | None:
"""Extract numeric order from filename like '01-basics.md' → 1."""
match = re.match(r"^(\d+)", PurePosixPath(filename).stem)
return int(match.group(1)) if match else None
def extract_title(filename: str) -> str:
"""Convert filename to title: '01-basics.md' → 'Basics'."""
stem = PurePosixPath(filename).stem
# Remove leading numbers and separator
stem = re.sub(r"^\d+[-_]", "", stem)
return stem.replace("-", " ").replace("_", " ").title()
def generate_presigned_url(s3_client, bucket: str, key: str, expiry: int = 3600) -> str:
"""Generate a presigned URL for an S3 object (for media upload)."""
return s3_client.generate_presigned_url(
"get_object",
Params={"Bucket": bucket, "Key": key},
ExpiresIn=expiry,
)
def upload_media_url(source_url: str, filename: str) -> str | None:
"""Upload a media file to Mentra via presigned URL and return the CDN URL."""
resp = requests.post(
f"{MENTRA_BASE_URL}/media/upload-url",
headers=HEADERS,
json={"sourceUrl": source_url, "filename": filename},
)
if resp.status_code == 200:
return resp.json().get("cdnUrl") or resp.json().get("originalUrl")
print(f" ⚠ Media upload failed for {filename}: {resp.status_code} {resp.text}")
return None
# ─── S3 Tree Scanner ─────────────────────────────────────────────
def scan_s3_tree(s3_client, bucket: str, prefix: str) -> dict:
"""
Scan S3 and build a nested dict representing the folder structure.
Returns: { 'folders': { name: subtree }, 'files': [key1, key2, ...] }
"""
paginator = s3_client.get_paginator("list_objects_v2")
tree: dict = {"folders": {}, "files": []}
for page in paginator.paginate(Bucket=bucket, Prefix=prefix, Delimiter="/"):
# Files at this level
for obj in page.get("Contents", []):
rel = obj["Key"][len(prefix):]
if rel: # Skip the prefix itself
tree["files"].append(obj["Key"])
# Subfolders
for cp in page.get("CommonPrefixes", []):
folder_prefix = cp["Prefix"]
folder_name = folder_prefix[len(prefix):].rstrip("/")
tree["folders"][folder_name] = scan_s3_tree(s3_client, bucket, folder_prefix)
return tree
# ─── Import Logic ────────────────────────────────────────────────
def path_external_id(prefix: str, rel_path: str) -> str:
"""A collision-free externalId derived from the full source path.
Deriving it from the basename alone is not enough: two folders can each
hold an `01-introduction.md`, and since both nodes also share a Section and
a node type, the import treats them as ONE node — the request is rejected
as a duplicate address, or across separate batches the second silently
overwrites the first. Slashes are legal in an externalId, so keep the path
shape and slugify each segment.
Note: changing how ids are derived changes which nodes a re-run targets. If
you already imported with an older version of this script, the next run
creates new nodes rather than updating the old ones.
"""
stem = re.sub(r"\.[^./]+$", "", rel_path)
parts = [slugify(part) for part in stem.split("/")]
return f"{prefix}-{'/'.join(p for p in parts if p)}"
def build_leaf_items(s3_client, bucket: str, tree: dict, leaf_depth: int,
parent_external_id: str | None, trail: str = "") -> list[dict]:
"""Content files in this folder → leaf items of the chain.
``trail`` is this folder's path relative to S3_PREFIX, so leaf ids carry
their ancestors and stay unique across identically-named files.
"""
content_files = [
f for f in tree["files"]
if PurePosixPath(f).suffix.lower() in (".md", ".html", ".json")
and PurePosixPath(f).name != "meta.json"
]
items = []
for cf in sorted(content_files):
fname = PurePosixPath(cf).name
item = {
**address(leaf_depth),
"externalId": path_external_id("s3", f"{trail}{fname}"),
"title": extract_title(fname),
"content": read_s3_text(s3_client, bucket, cf),
"contentFormat": detect_content_format(fname),
"order": extract_order(fname),
"locale": LOCALE,
}
if parent_external_id:
item["parentExternalId"] = parent_external_id
items.append(item)
return items
def build_import_items(s3_client, bucket: str, tree: dict, depth: int = 0,
parent_external_id: str | None = None,
trail: str = "") -> list[dict]:
"""
Recursively walk the S3 tree and build BulkImportItemDto objects.
Folder depth maps onto structure_chain(): depth 0 → chain[0], depth 1 →
chain[1], and so on. Content files inside the deepest folder level become
chain[-1] items. With the default Programme chain: folders →
programme/module, files → lesson.
"""
items = []
chain = structure_chain()
leaf_depth = len(chain) - 1
if leaf_depth == 0:
# A single-type Section (e.g. the Flat 'Pages' template): every
# content file is a root node, wherever it sits in the bucket.
items.extend(build_leaf_items(s3_client, bucket, tree, 0, None, trail))
for folder_name, subtree in sorted(tree["folders"].items()):
if folder_name.lower() not in SKIP_FOLDERS:
items.extend(build_import_items(
s3_client, bucket, subtree, trail=f"{trail}{folder_name}/"
))
return items
for folder_name, subtree in sorted(tree["folders"].items()):
# Skip 'images' folders — those are media, not content
if folder_name.lower() in SKIP_FOLDERS:
continue
if depth > leaf_depth - 1:
print(f" ⚠ Skipping deep folder: {folder_name} "
f"(structure has {len(chain)} levels)")
continue
# Read optional meta.json for this folder
meta = {}
meta_files = [f for f in subtree["files"]
if PurePosixPath(f).name == "meta.json"]
if meta_files:
try:
meta = read_s3_json(s3_client, bucket, meta_files[0])
except Exception as e:
print(f" ⚠ Could not read {meta_files[0]}: {e}")
folder_path = f"{trail}{folder_name}"
external_id = path_external_id("s3", folder_path)
title = meta.get("title", folder_name.replace("-", " ").replace("_", " ").title())
# Handle cover image
cover_url = None
cover_files = [f for f in subtree["files"]
if PurePosixPath(f).name.lower().startswith("cover")]
if cover_files and not DRY_RUN:
presigned = generate_presigned_url(s3_client, bucket, cover_files[0])
cover_url = upload_media_url(presigned, PurePosixPath(cover_files[0]).name)
folder_item = {
**address(depth),
"externalId": external_id,
"title": title,
"summary": meta.get("summary"),
"keywords": meta.get("keywords", []),
"coverImageUrl": cover_url,
"locale": LOCALE,
}
if parent_external_id:
folder_item["parentExternalId"] = parent_external_id
items.append(folder_item)
if depth < leaf_depth - 1:
items.extend(build_import_items(
s3_client, bucket, subtree, depth + 1, external_id,
trail=f"{folder_path}/",
))
else:
items.extend(build_leaf_items(
s3_client, bucket, subtree, leaf_depth, external_id,
trail=f"{folder_path}/",
))
return items
def chunk_items(items: list[dict], size: int = 100) -> list[list[dict]]:
"""Split into API-sized batches without separating a parent from its subtree.
The walk emits depth-first, so a root item is followed by its whole subtree.
Packing whole subtrees keeps `parentExternalId` resolvable *within* each
request, which is what DRY_RUN needs: a preview writes nothing, so a child
in a later batch cannot see a parent the earlier batch only pretended to
create, and would be reported as failed even though the live run succeeds.
A single subtree larger than `size` still has to be split — the API caps a
request at 100 items — so only that subtree carries the caveat, and the
script says so rather than letting it look like a real failure.
"""
groups: list[list[dict]] = []
for item in items:
if not item.get("parentExternalId") or not groups:
groups.append([item])
else:
groups[-1].append(item)
batches: list[list[dict]] = []
batch: list[dict] = []
for group in groups:
if len(group) > size:
if batch:
batches.append(batch)
batch = []
for i in range(0, len(group), size):
batches.append(group[i:i + size])
if DRY_RUN:
root = group[0].get("externalId")
print(f" ⚠ Subtree '{root}' exceeds {size} items and had to be "
f"split. In DRY_RUN its children will report an unresolved "
f"parentExternalId; a real run resolves them.")
continue
if batch and len(batch) + len(group) > size:
batches.append(batch)
batch = []
batch.extend(group)
if batch:
batches.append(batch)
return batches
def send_import_batch(items: list[dict], dry_run: bool = True) -> dict:
"""Send a batch of items to the Mentra Bulk Import API."""
payload = {
"items": items, # already capped by chunk_items()
"onConflict": "update", # Upsert — safe for re-runs
"dryRun": dry_run,
}
resp = requests.post(
f"{MENTRA_BASE_URL}/import",
headers=HEADERS,
json=payload,
)
resp.raise_for_status()
return resp.json()
# ─── Main ────────────────────────────────────────────────────────
def main():
s3 = boto3.client("s3", region_name=AWS_REGION)
print(f"Scanning s3://{S3_BUCKET}/{S3_PREFIX} ...")
tree = scan_s3_tree(s3, S3_BUCKET, S3_PREFIX)
print("Building import items...")
items = build_import_items(s3, S3_BUCKET, tree)
print(f"Found {len(items)} items to import.\n")
# Batch in groups of 100
for batch_num, batch in enumerate(chunk_items(items), start=1):
mode_label = "DRY RUN" if DRY_RUN else "IMPORTING"
print(f"[{mode_label}] Batch {batch_num} ({len(batch)} items)...")
result = send_import_batch(batch, dry_run=DRY_RUN)
for r in result.get("results", []):
action = r.get("action", "unknown")
label = f"{r.get('nodeTypeKey') or r.get('type', '')} {r.get('externalId', '?')}"
symbol = {"would_create": "➕", "would_update": "✏️",
"would_skip": "⏭️", "created": "✅",
"updated": "✏️", "skipped": "⏭️",
"failed": "❌"}.get(action, "?")
print(f" {symbol} {action}: {label}" + (f" — {r['error']}" if r.get("error") else ""))
print(f" → created {result['created']}, updated {result['updated']}, "
f"skipped {result['skipped']}, failed {result['failed']}\n")
if DRY_RUN:
print("✅ Dry run complete. Review the output above.")
print(" Set DRY_RUN = False and run again to import for real.")
else:
print("✅ Import complete!")
if __name__ == "__main__":
main()meta.json Format
Optional metadata file in each folder. All fields are optional.
{
"title": "Setting up your workspace",
"summary": "Everything a new team needs before its first project.",
"keywords": ["onboarding", "workspace", "team", "setup"]
}Media Handling
The script generates presigned URLs for S3 images and uploads them to Mentra via the POST /media/upload-url endpoint. The returned CDN URL is used as coverImageUrl.
# 1. Generate a temporary URL for the S3 object
presigned = s3.generate_presigned_url(
"get_object",
Params={"Bucket": "my-bucket", "Key": "images/cover.jpg"},
ExpiresIn=3600 # Valid for 1 hour
)
# 2. Pass it to Mentra — Mentra downloads and stores the file
resp = requests.post(
f"{MENTRA_BASE_URL}/media/upload-url",
headers=headers,
json={"sourceUrl": presigned, "filename": "cover.jpg"}
)
cdn_url = resp.json()["cdnUrl"] # Use this in coverImageUrlImport from GitHubwrite:content
Overview
Migrate documentation or learning content from a GitHub repository into Mentra. The script clones your repo (or uses the GitHub API) and maps the folder structure onto a Section's node types, root to leaf — by default a training Section built from the built-in Programme template (programme → module → lesson). Change SECTION_KEY and NODE_TYPE_CHAIN to target any other Section; both come from GET /v1/sections.
Repo → Hierarchy Mapping
my-docs-repo/ ├── content/ → Root folder (configurable) │ ├── customer-onboarding/ → programme (Section root) │ │ ├── _index.md → programme metadata (title, summary) │ │ ├── setting-up-your-workspace/ → module │ │ │ ├── _index.md │ │ │ ├── 01-inviting-your-team.md → lesson, order = 1 │ │ │ └── 02-connecting-your-tools.md → lesson, order = 2 │ │ └── your-first-project/ │ │ └── ... │ └── advanced-workflows/ │ └── ... ├── static/ → Media assets (optional) │ └── images/ │ └── team-settings.png └── README.md → Ignored
_index.md in each folder provides title/summary for that level. Other .md files become leaf nodes (lessons, by default).
Images can reference GitHub raw URLs or be uploaded via the Media API using /media/upload-url
Prerequisites
pip install requests
write:content scope (add write:media if uploading images)Complete Import Script
DRY_RUN = True first. Review the output before importing."""
GitHub → Mentra Import Script
=============================
Clones a GitHub repo and maps its folder structure onto a Section, whatever
its structure template.
Conventions:
- _index.md in a folder = metadata for that level (title, summary)
- Numbered files (01-topic.md) = leaf nodes, sorted by number
- Subfolders = next level of the target's node-type chain
- static/ or images/ folders = media assets
Usage:
1. Set the configuration variables below
2. Run with DRY_RUN = True to preview
3. Review the output
4. Set DRY_RUN = False to execute
"""
import os
import re
import json
import shutil
import subprocess
import requests
from pathlib import Path
# ─── Configuration ───────────────────────────────────────────────
MENTRA_API_KEY = os.environ.get("MENTRA_API_KEY", "your-api-key-here")
MENTRA_BASE_URL = "https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1"
# GitHub settings
GITHUB_REPO = "https://github.com/your-org/your-docs-repo.git"
GITHUB_BRANCH = "main"
GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "") # For private repos
CONTENT_ROOT = "content" # Subfolder in repo that contains content
LOCALE = "en-US"
DRY_RUN = True # ← Start with True!
CLONE_DIR = "/tmp/mentra-github-import" # Temporary clone location
# ─── Structure target ────────────────────────────────────────────
# The Section to import into (it must already exist — create it in the
# Mentra app) and its node types root → leaf; both come from GET /v1/sections.
# The default is a "training" Section built from the built-in Programme
# template; any other shape works the same way, at any depth:
# SECTION_KEY = "knowledge-base"; NODE_TYPE_CHAIN = ["category", "article"]
#
# Note: only node types flagged content_bearing accept a body, so a folder
# whose _index.md carries content must map to a content_bearing type — the
# import reports 'not_content_bearing' per row rather than dropping it.
SECTION_KEY = "training"
NODE_TYPE_CHAIN = ["programme", "module", "lesson"]
# ─── Helpers ─────────────────────────────────────────────────────
HEADERS = {
"Authorization": f"Bearer {MENTRA_API_KEY}",
"Content-Type": "application/json",
}
def structure_chain() -> list[str]:
"""The root → leaf chain folder depth maps onto."""
if not SECTION_KEY or not NODE_TYPE_CHAIN:
raise SystemExit("Set SECTION_KEY and NODE_TYPE_CHAIN (see GET /v1/sections).")
return NODE_TYPE_CHAIN
def address(depth: int) -> dict:
"""The item fields that name a target at this depth in the chain."""
return {
"type": "node",
"sectionKey": SECTION_KEY,
"nodeTypeKey": NODE_TYPE_CHAIN[depth],
}
def slugify(text: str) -> str:
"""Convert text to a URL-safe slug."""
text = text.lower().strip()
text = re.sub(r"[^\w\s-]", "", text)
return re.sub(r"[\s_]+", "-", text).strip("-")
def detect_content_format(filepath: Path) -> str:
"""Detect content format from file extension."""
return {".md": "markdown", ".html": "html", ".json": "json"}.get(
filepath.suffix.lower(), "markdown"
)
def extract_order(filepath: Path) -> int | None:
"""Extract numeric order from filename like '01-basics.md' → 1."""
match = re.match(r"^(\d+)", filepath.stem)
return int(match.group(1)) if match else None
def extract_title_from_filename(filepath: Path) -> str:
"""Convert filename to title: '01-basics.md' → 'Basics'."""
stem = re.sub(r"^\d+[-_]", "", filepath.stem)
return stem.replace("-", " ").replace("_", " ").title()
def parse_frontmatter(content: str) -> tuple[dict, str]:
"""
Parse YAML frontmatter from Markdown.
Returns (metadata_dict, content_without_frontmatter).
"""
if not content.startswith("---"):
return {}, content
parts = content.split("---", 2)
if len(parts) < 3:
return {}, content
# Simple key: value parsing (no PyYAML dependency)
meta = {}
for line in parts[1].strip().split("\n"):
if ":" in line:
key, val = line.split(":", 1)
val = val.strip().strip('"').strip("'")
meta[key.strip()] = val
return meta, parts[2].strip()
def clone_repo():
"""Clone or update the GitHub repository."""
if Path(CLONE_DIR).exists():
print(f"Removing existing clone at {CLONE_DIR}...")
shutil.rmtree(CLONE_DIR)
clone_url = GITHUB_REPO
if GITHUB_TOKEN and "github.com" in clone_url:
# Inject token for private repos
clone_url = clone_url.replace(
"https://github.com",
f"https://{GITHUB_TOKEN}@github.com"
)
print(f"Cloning {GITHUB_REPO} (branch: {GITHUB_BRANCH})...")
subprocess.run(
["git", "clone", "--depth", "1", "--branch", GITHUB_BRANCH, clone_url, CLONE_DIR],
check=True,
capture_output=True,
)
print("Clone complete.\n")
def github_raw_url(repo: str, branch: str, filepath: str) -> str:
"""Build a raw.githubusercontent.com URL for an image."""
# Extract owner/repo from clone URL
match = re.search(r"github\.com[/:]([^/]+/[^/.]+)", repo)
if match:
return f"https://raw.githubusercontent.com/{match.group(1)}/{branch}/{filepath}"
return ""
# ─── Import Logic ────────────────────────────────────────────────
SKIP_DIRS = {"images", "media", "assets", "_assets", "static", ".git", "node_modules"}
def path_external_id(prefix: str, rel_path: str) -> str:
"""A collision-free externalId derived from the full source path.
Deriving it from the basename alone is not enough: two chapters can each
hold an `01-introduction.md`, and since both nodes also share a Section and
a node type, the import treats them as ONE node — the request is rejected
as a duplicate address, or across separate batches the second silently
overwrites the first. Slashes are legal in an externalId, so keep the path
shape and slugify each segment.
Note: changing how ids are derived changes which nodes a re-run targets. If
you already imported with an older version of this script, the next run
creates new nodes rather than updating the old ones.
"""
stem = re.sub(r"\.[^./]+$", "", rel_path)
parts = [slugify(part) for part in stem.split("/")]
return f"{prefix}-{'/'.join(p for p in parts if p)}"
def collect_leaf_files(folder: Path, leaf_depth: int,
parent_external_id: str | None, base: Path) -> list[dict]:
"""Content files in this folder → leaf items of the chain."""
content_files = sorted([
f for f in folder.iterdir()
if f.is_file()
and f.suffix.lower() in (".md", ".html", ".json")
and f.name != "_index.md"
])
items = []
for cf in content_files:
fm, content = parse_frontmatter(cf.read_text(encoding="utf-8"))
item = {
**address(leaf_depth),
"externalId": path_external_id("gh", str(cf.relative_to(base))),
"title": fm.get("title", extract_title_from_filename(cf)),
"content": content,
"contentFormat": detect_content_format(cf),
"order": extract_order(cf),
"locale": LOCALE,
}
if parent_external_id:
item["parentExternalId"] = parent_external_id
items.append(item)
return items
def scan_directory(root: Path, depth: int = 0,
parent_external_id: str | None = None,
base: Path | None = None) -> list[dict]:
"""
Recursively walk directory and build BulkImportItemDto objects.
Folder depth maps onto structure_chain(): depth 0 → chain[0], depth 1 →
chain[1], and so on. Files inside the deepest folder level become
chain[-1] items. With the default Programme chain: folders →
programme/module, files → lesson.
"""
items = []
chain = structure_chain()
leaf_depth = len(chain) - 1
# externalIds are derived from paths relative to the content root, so the
# top-level call fixes the base for the whole walk.
base = base if base is not None else root
if not root.is_dir():
return items
# Sorted for deterministic ordering
subdirs = sorted([d for d in root.iterdir() if d.is_dir() and d.name not in SKIP_DIRS])
if leaf_depth == 0:
# A single-type Section (e.g. the Flat 'Pages' template): every
# content file is a root node, wherever it sits in the repo.
items.extend(collect_leaf_files(root, 0, None, base))
for folder in subdirs:
items.extend(scan_directory(folder, base=base))
return items
for folder in subdirs:
if depth > leaf_depth - 1:
print(f" ⚠ Skipping deep folder: {folder.name} "
f"(structure has {len(chain)} levels)")
continue
external_id = path_external_id("gh", str(folder.relative_to(base)))
# Read _index.md for metadata
meta = {}
body = None
index_file = folder / "_index.md"
if index_file.exists():
raw = index_file.read_text(encoding="utf-8")
meta, body = parse_frontmatter(raw)
title = meta.get("title", folder.name.replace("-", " ").replace("_", " ").title())
folder_item = {
**address(depth),
"externalId": external_id,
"title": title,
"summary": meta.get("summary") or meta.get("description"),
"keywords": [k.strip() for k in meta.get("keywords", "").split(",")] if meta.get("keywords") else [],
"locale": LOCALE,
}
if body:
folder_item["content"] = body
folder_item["contentFormat"] = "markdown"
if parent_external_id:
folder_item["parentExternalId"] = parent_external_id
items.append(folder_item)
# Recurse or collect files
if depth < leaf_depth - 1:
items.extend(scan_directory(folder, depth + 1, external_id, base))
else:
items.extend(collect_leaf_files(folder, leaf_depth, external_id, base))
return items
def chunk_items(items: list[dict], size: int = 100) -> list[list[dict]]:
"""Split into API-sized batches without separating a parent from its subtree.
The walk emits depth-first, so a root item is followed by its whole subtree.
Packing whole subtrees keeps `parentExternalId` resolvable *within* each
request, which is what DRY_RUN needs: a preview writes nothing, so a child
in a later batch cannot see a parent the earlier batch only pretended to
create, and would be reported as failed even though the live run succeeds.
A single subtree larger than `size` still has to be split — the API caps a
request at 100 items — so only that subtree carries the caveat, and the
script says so rather than letting it look like a real failure.
"""
groups: list[list[dict]] = []
for item in items:
if not item.get("parentExternalId") or not groups:
groups.append([item])
else:
groups[-1].append(item)
batches: list[list[dict]] = []
batch: list[dict] = []
for group in groups:
if len(group) > size:
if batch:
batches.append(batch)
batch = []
for i in range(0, len(group), size):
batches.append(group[i:i + size])
if DRY_RUN:
root = group[0].get("externalId")
print(f" ⚠ Subtree '{root}' exceeds {size} items and had to be "
f"split. In DRY_RUN its children will report an unresolved "
f"parentExternalId; a real run resolves them.")
continue
if batch and len(batch) + len(group) > size:
batches.append(batch)
batch = []
batch.extend(group)
if batch:
batches.append(batch)
return batches
def send_import_batch(items: list[dict], dry_run: bool = True) -> dict:
"""Send a batch of items to the Mentra Bulk Import API."""
payload = {
"items": items,
"onConflict": "update",
"dryRun": dry_run,
}
resp = requests.post(
f"{MENTRA_BASE_URL}/import",
headers=HEADERS,
json=payload,
)
resp.raise_for_status()
return resp.json()
# ─── Main ────────────────────────────────────────────────────────
def main():
clone_repo()
content_root = Path(CLONE_DIR) / CONTENT_ROOT
if not content_root.exists():
print(f"ERROR: Content root '{CONTENT_ROOT}' not found in repo.")
print(f"Available top-level folders: {[d.name for d in Path(CLONE_DIR).iterdir() if d.is_dir()]}")
return
print(f"Scanning {content_root}...")
items = scan_directory(content_root)
print(f"Found {len(items)} items to import.\n")
if not items:
print("No content found. Check your CONTENT_ROOT setting and folder structure.")
return
# Batch in groups of 100
for batch_num, batch in enumerate(chunk_items(items), start=1):
mode_label = "DRY RUN" if DRY_RUN else "IMPORTING"
print(f"[{mode_label}] Batch {batch_num} ({len(batch)} items)...")
result = send_import_batch(batch, dry_run=DRY_RUN)
for r in result.get("results", []):
action = r.get("action", "unknown")
label = f"{r.get('nodeTypeKey') or r.get('type', '')} {r.get('externalId', '?')}"
symbol = {"would_create": "➕", "would_update": "✏️",
"would_skip": "⏭️", "created": "✅",
"updated": "✏️", "skipped": "⏭️",
"failed": "❌"}.get(action, "?")
print(f" {symbol} {action}: {label}" + (f" — {r['error']}" if r.get("error") else ""))
print(f" → created {result['created']}, updated {result['updated']}, "
f"skipped {result['skipped']}, failed {result['failed']}\n")
# Cleanup
print(f"Cleaning up {CLONE_DIR}...")
shutil.rmtree(CLONE_DIR, ignore_errors=True)
if DRY_RUN:
print("✅ Dry run complete. Review the output above.")
print(" Set DRY_RUN = False and run again to import for real.")
else:
print("✅ Import complete!")
if __name__ == "__main__":
main()_index.md Frontmatter Format
Each folder can contain an _index.md with YAML frontmatter for metadata. All fields are optional.
--- title: "Setting up your workspace" summary: "Everything a new team needs before its first project." keywords: "onboarding, workspace, setup" --- Optional body content for this folder goes here. This will be stored as the content of the module node.
Media Handling for GitHub
For public repos, you can reference images directly using raw.githubusercontent.com URLs as coverImageUrl. For private repos, upload images via the Media API.
# Raw GitHub URL pattern:
https://raw.githubusercontent.com/your-org/your-repo/main/static/images/cover.png
# Use directly as coverImageUrl in the import item:
{
"type": "node",
"sectionKey": "training",
"nodeTypeKey": "programme",
"externalId": "gh-customer-onboarding",
"title": "Customer Onboarding",
"coverImageUrl": "https://raw.githubusercontent.com/your-org/your-repo/main/static/images/onboarding-cover.png"
}# Read the file from the cloned repo
with open("static/images/cover.png", "rb") as f:
resp = requests.post(
f"{MENTRA_BASE_URL}/media/upload",
headers={"Authorization": f"Bearer {MENTRA_API_KEY}"},
files={"file": ("cover.png", f, "image/png")},
)
cdn_url = resp.json()["cdnUrl"]Recommended Workflow
- Run with
DRY_RUN = Trueto validate structure and preview actions - Review the output — check that titles and hierarchy look correct
- Fix any folder naming or
_index.mdfrontmatter issues in your repo - Set
DRY_RUN = Falseand run again to import - Verify in the Mentra CMS that your content hierarchy is correct
Response Structure
API responses follow a consistent structure across all endpoints.
1. List Endpoint Response (Metadata Only)
Returned by the standalone content list endpoints (GET /standalone, GET /standalone/summary). Contains paginated metadata to help you discover content. Other list endpoints use a different shape: GET /sections and GET /surfaces return only { items: [...] } (no pagination — a Section or Surface is fetched whole), and GET /media returns { items, total, limit, offset }.
{
"items": [
{
"id": "L1_123",
"title": "Effective Communication",
"summary": "Master the art of clear communication...",
"keywords": ["communication", "listening", "speaking"],
"slug": "effective-communication",
"author": {
"id": "auth_123",
"name": "Dr. Sarah Smith",
"title": "Cognitive Psychologist",
"bio": "Expert in memory and learning...",
"avatarUrl": "https://..."
},
"createdAt": "2024-01-15T10:00:00Z",
"updatedAt": "2024-11-20T14:30:00Z",
"publishedAt": "2024-01-20T09:00:00Z"
}
],
"page": 1,
"pageSize": 20,
"total": 42,
"hasNext": true,
"nextPage": 2
}2. Detail Endpoint Response (Full Content)
Returned by all GET /resource/{id} endpoints. Contains the complete content, including HTML, media assets, and AI-generated extensions.
{
"id": "L4_123",
"title": "Active Listening Techniques",
"summary": "Learn proven techniques for better listening...",
"keywords": ["listening", "empathy", "attention"],
"slug": "active-listening-techniques",
"author": {
"id": "auth_123",
"name": "Dr. Sarah Smith",
"title": "Cognitive Psychologist",
"bio": "Expert in memory and learning...",
"avatarUrl": "https://..."
},
// Layout (if include=layout)
"layout": [
{
"id": "block_123",
"type": "hero",
"props": {
"heading": "Active Listening Techniques", // Auto-resolved from step.title
"subheading": "Master the art...",
"imageUrl": "https://..."
}
},
{
"type": "richText",
"props": {
"content": "<p>...</p>" // Auto-resolved from step.contentHtml
}
}
],
// Full Content
"contentHtml": "<h1>Active Listening</h1><p>...</p>",
"contentJson": {...}, // Structured content blocks
"contentPlainText": "Active Listening...",
// Media Assets
"media": {
"heroImage": "https://...",
"images": ["https://..."],
"videos": []
},
// SEO Metadata
"seoMetadata": {
"metaTitle": "Active Listening Techniques | Mentra",
"metaDescription": "Learn proven techniques for better listening...",
"focusKeywords": ["listening", "communication"],
"canonicalUrl": "https://...",
"readingTime": 5
},
// AI-Generated Extensions (See "AI Extension Schemas" below for details)
"mnemonicSteps": [...],
"quizData": [...],
"scenarios": [...],
// Timestamps
"createdAt": "2024-01-15T10:00:00Z",
"updatedAt": "2024-11-20T14:30:00Z",
"publishedAt": "2024-01-20T09:00:00Z"
}📝 Content Format Details (v1.4.0)
contentHtml— Purely semantic HTML5 markup (headings, paragraphs, figures, lists, etc.). No CSS classes or inline styles are included. You are responsible for applying your own styling. Images use CDN URLs with semantic <figure>/<figcaption> tags and alignment/size hint classes (see Rich Text section below).contentJson— TipTap/ProseMirror JSON document spec. Use this if you need fine-grained control over rendering (e.g., custom block renderers). See the TipTap JSON documentation for the node type reference.contentPlainText— Pre-computed plain text extraction. Ideal for search indexing, SEO meta descriptions, and AI processing. Whitespace is normalized and image alt text is preserved.3. Rich Text & Media Handling
The contentHtml field contains purely semantic HTML5 markup with no embedded styles or CSS framework classes. Images with captions are rendered using semantic <figure> and <figcaption> tags.
Image HTML Structure
Images include semantic data-* attributes for alignment and sizing metadata. These are not CSS classes — you should read them and apply your own styles accordingly.
<!-- Image with caption --> <figure> <img src="https://ik.imagekit.io/..." alt="Diagram of active listening" data-size="large" data-align="center"> <figcaption>Figure 1: The Active Listening Cycle</figcaption> </figure> <!-- Image without caption --> <img src="https://ik.imagekit.io/..." alt="Quick reference chart" data-size="medium" data-align="left">
data-align Values
left— Float leftcenter— Centered blockright— Float right
data-size Values
small— 25% widthmedium— 50% widthlarge— 75% widthxl— 100% width
💡 Styling tip: Use CSS attribute selectors to target these values: img[data-size="large"] or img[data-align="center"]. Images without captions render as standalone <img> tags; images with captions are wrapped in <figure>.
4. AI Extension Schemas
Detailed structure of the AI-generated enrichment fields.
Author Schema (`author`)
Information about the content creator.
{
"id": "auth_123",
"name": "Dr. Sarah Smith",
"title": "Cognitive Psychologist",
"bio": "Expert in memory and learning retention strategies.",
"avatarUrl": "https://storage.googleapis.com/.../avatar.jpg"
}Mnemonic Steps (`mnemonicSteps`)
An array of steps to help memorize the content, often including generated images or videos.
[
{
"keyword": "LISTEN",
"description": "Imagine a giant ear walking down the street...",
"imageUrl": "https://storage.googleapis.com/.../image.png", // Optional
"videoUrl": "https://storage.googleapis.com/.../video.mp4" // Optional
}
]Quiz Data (`quizData`)
Multiple choice questions for testing understanding.
[
{
"questionId": "q1",
"question": "What is active listening?",
"options": ["Hearing sounds", "Listening to understand", "Ignoring speaker"],
"correctOptionIndex": 1,
"explanation": "Active listening is about engagement..."
}
]5. Layout Structure (Headless Page Builder)
When requesting include=layout, the API returns a resolved layout array. This array represents the visual structure defined in the Page Builder, with all dynamic data already filled in.
This allows you to render the page exactly as designed without handling data mapping on the client side.
Block Structure
{
"id": "unique_block_id",
"type": "hero | richText | features | faq | ...",
"props": {
// Properties specific to the block type.
// Dynamic placeholders (e.g. {{step.title}}) are already replaced with actual content.
"heading": "Actual Title",
"content": "<p>Resolved HTML content...</p>",
"style": "primary"
}
}Custom Rendering with contentJson
Every content item includes a contentJson field — the structured TipTap/ProseMirror JSON document that is the source of truth for all content. Use it when you need fine-grained control over rendering instead of consuming the pre-rendered contentHtml.
Use contentHtml when…
- You want quick integration with minimal code
- You’re rendering in a non-React environment (server-side, email, etc.)
- You’re fine applying CSS to semantic HTML
Use contentJson when…
- You want to map each node to your own React/Vue/Svelte components
- You need to transform content (e.g. lazy-load images, add analytics)
- You’re building a native mobile app
Document Structure
The root is always a doc node containing an array of block nodes. Each node has a type, optional attrs, and optional content (child nodes). Text nodes additionally carry a marks array for inline formatting.
{
"type": "doc",
"content": [
{
"type": "heading",
"attrs": { "level": 2 },
"content": [
{
"type": "text",
"text": "Getting Started",
"marks": []
}
]
},
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "Welcome to the course. This section covers "
},
{
"type": "text",
"text": "key concepts",
"marks": [{ "type": "bold" }]
},
{
"type": "text",
"text": " you need to know."
}
]
}
]
}Node Type Reference
The complete set of node types you may encounter in contentJson. This is the full surface area — there are no other node types.
| Node Type | HTML Equivalent | Attributes | Has Children |
|---|---|---|---|
doc | (root container) | — | ✅ |
paragraph | <p> | — | ✅ |
heading | <h1>–<h6> | level (1–6) | ✅ |
bulletList | <ul> | — | ✅ |
orderedList | <ol> | — | ✅ |
listItem | <li> | — | ✅ |
blockquote | <blockquote> | — | ✅ |
codeBlock | <pre><code> | language (string) | ✅ |
hardBreak | <br> | — | — |
horizontalRule | <hr> | — | — |
image | <img> / <figure> | src alt caption size alignment | — |
table | <table> | — | ✅ |
tableRow | <tr> | — | ✅ |
tableHeader / tableCell | <th> / <td> | — | ✅ |
Node Examples
Heading with level
{
"type": "heading",
"attrs": { "level": 2 },
"content": [
{ "type": "text", "text": "Chapter Title" }
]
}Code block with language
{
"type": "codeBlock",
"attrs": { "language": "python" },
"content": [
{ "type": "text", "text": "def hello():\n print('Hello, world!')" }
]
}Nested list
{
"type": "bulletList",
"content": [
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "First item" }
]
}
]
},
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Second item" }
]
}
]
}
]
}Table
{
"type": "table",
"content": [
{
"type": "tableRow",
"content": [
{
"type": "tableHeader",
"content": [
{ "type": "paragraph", "content": [{ "type": "text", "text": "Term" }] }
]
},
{
"type": "tableHeader",
"content": [
{ "type": "paragraph", "content": [{ "type": "text", "text": "Definition" }] }
]
}
]
},
{
"type": "tableRow",
"content": [
{
"type": "tableCell",
"content": [
{ "type": "paragraph", "content": [{ "type": "text", "text": "API" }] }
]
},
{
"type": "tableCell",
"content": [
{ "type": "paragraph", "content": [{ "type": "text", "text": "Application Programming Interface" }] }
]
}
]
}
]
}Mark Type Reference
Marks are inline formatting applied to text nodes via the marks array. Multiple marks can be stacked on a single text node.
| Mark Type | HTML Equivalent | Attributes |
|---|---|---|
bold | <strong> | — |
italic | <em> | — |
underline | <u> | — |
strike | <s> | — |
code | <code> | — |
link | <a> | href target |
Text node with multiple marks
Marks are composable. A single text node can have bold, italic, and a link simultaneously:
{
"type": "text",
"text": "click here",
"marks": [
{ "type": "bold" },
{ "type": "italic" },
{
"type": "link",
"attrs": {
"href": "https://example.com",
"target": "_blank"
}
}
]
}Renders as: <a href="https://example.com" target="_blank"><em><strong>click here</strong></em></a>
Image Node Deep Dive
The image node is the most attribute-rich node type. All image URLs are pre-resolved to ImageKit CDN URLs at publish time — you do not need to perform any URL transformation.
{
"type": "image",
"attrs": {
"src": "https://ik.imagekit.io/mentra/content/tr:w-800,q-85,f-webp/image.jpg",
"alt": "Diagram showing the feedback loop",
"caption": "Figure 1: The Feedback Loop",
"size": "large",
"alignment": "center"
}
}| Attribute | Type | Required | Description |
|---|---|---|---|
src | string | ✅ | CDN image URL (already optimized) |
alt | string | ✅ | Alt text for accessibility |
caption | string | null | No | If present, render as <figure> + <figcaption> |
size | string | null | No | Layout hint: small (25%), medium (50%), large (75%), xl (100%) |
alignment | string | null | No | Layout hint: left, center, right |
💡 Note: The contentHtml rendering maps size → data-size and alignment → data-align as data attributes on the <img> tag. When rendering from contentJson, you read these directly from attrs.
🎨 Ready to render? See the Libraries & SDKs section for the official @mentra-intelligence/react component that renders contentJson out of the box.
Libraries & SDKs
Official client libraries and rendering tools for integrating Mentra content into your application.
@mentra-intelligence/react
React components that render Mentra content from the API. MentraContent renders an article's contentJson: all standard TipTap/ProseMirror nodes, inline marks, and image sizing and alignment attributes. MentraPage renders a whole page from GET /pages/{slug}, block by block, with a default for every block type that you can replace with your own component (version 0.2.0 or later).
Installation
The package is published to the public npm registry, so it installs with no token or .npmrc setup.
npm install @mentra-intelligence/react isomorphic-dompurify
Earlier versions were published to GitHub Packages. If your .npmrc still has an @mentra-intelligence:registry=https://npm.pkg.github.com line, and the //npm.pkg.github.com/:_authToken line that goes with it, remove both, or npm keeps installing from the old registry.
Rendering a page
Fetch a published page and pass the response to MentraPage. No per-block code is needed.
import { MentraPage } from '@mentra-intelligence/react';
import DOMPurify from 'isomorphic-dompurify';
import { notFound } from 'next/navigation';
export default async function Page({ params }) {
const { slug } = await params; // a Promise in Next.js 15 and later
const res = await fetch(`https://api.mentra.systems/api/v1/pages/${encodeURIComponent(slug)}`, {
headers: { 'X-Api-Key': process.env.MENTRA_API_KEY },
});
if (res.status === 404) notFound(); // unknown or unpublished slug
if (!res.ok) throw new Error(`GET /v1/pages/${slug} failed: ${res.status}`);
const page = await res.json();
return <MentraPage page={page} sanitizeHtml={(html) => DOMPurify.sanitize(html)} />;
}This is a server component, so the sanitiser runs in Node, where plain dompurify has no DOM and no sanitize method. isomorphic-dompurify works on both server and client; any sanitiser with the same (html) => string shape does too.
- Every block type has a default: unstyled semantic HTML with a
mentra-block-<type>class. A block whose required fields are empty renders nothing. - Unknown block types are skipped without an error, so a page using a block type newer than your package version still renders.
- JSON-LD renders as
application/ld+jsonscript tags. PassjsonLd={false}to render it yourself. rich_textHTML is injected only through your sanitiser (sanitizeHtml). Without one it renders as plain text.- Unsafe URLs are dropped, and internal links render as text until you map them with
resolveLink. The API returns links and image URLs with an unsafe scheme asnulltoo; your own image components can still use the exportedsafeImageSrcas a second check. videoandembedplay onlyresolved: a provider player is an iframe withsrc,sandbox,allowandreferrerPolicycopied from it unchanged, never built fromcontent.url; a video file plays in<video>. If your site sends a Content-Security-Policy, itsframe-srcmust allowhttps://www.youtube-nocookie.com https://player.vimeo.com https://www.loom.com https://open.spotify.com https://www.google.com.- Context-bound blocks are skipped unless you pass
renderContextBound. A context-boundcolumnsblock still renders, and each child is judged on its own.
Overriding blocks
Pass blocks, keyed by block type. Each component gets block, content and context (render options, theme, the full page), and can use hooks. null hides a type; a key the package doesn't know adds one. A resolveLink you pass replaces the default, including its scheme check, which is exported as safeHref.
import { MentraPage, safeHref } from '@mentra-intelligence/react';
import Link from 'next/link';
const API = 'https://api.mentra.systems/api/v1';
const headers = { 'X-Api-Key': process.env.MENTRA_API_KEY };
const routes = { course: '/courses/', journey: '/journeys/', step: '/steps/' };
// Page id -> slug, from GET /v1/pages (each summary carries both).
// Next.js 15+ doesn't cache fetch by default, so revalidate the list every
// 5 minutes rather than re-reading every page of it on each render.
async function fetchPageSlugs() {
const slugs = new Map();
for (let n = 1; n; ) {
const res = await fetch(`${API}/pages?pageSize=100&page=${n}`, { headers, next: { revalidate: 300 } });
if (!res.ok) throw new Error(`GET /v1/pages failed: ${res.status}`);
const list = await res.json();
for (const p of list.items) slugs.set(p.id, p.slug);
n = list.nextPage;
}
return slugs;
}
// An internal link carries the target's id. Pages are fetched by slug, so a
// page id goes through the slug map; the other routes here take the id.
// Return undefined (no link) for anything incomplete, never a half-built URL.
function makeResolveLink(pageSlugs) {
return (link) => {
if (link?.type !== 'internal') return safeHref(link?.url);
if (!link.targetId) return undefined;
if (link.targetType === 'page') {
const slug = pageSlugs.get(link.targetId);
return slug ? `/pages/${encodeURIComponent(slug)}` : undefined;
}
const base = Object.hasOwn(routes, link.targetType) ? routes[link.targetType] : undefined;
return base ? `${base}${encodeURIComponent(link.targetId)}` : undefined;
};
}
// dynamic_content lists standalone articles, or Section nodes when its source
// is 'section'. Section nodes carry a nodeTypeKey; route each key you serve.
const sectionRoutes = { case_study: '/case-studies/' };
function itemHref(item, block) {
if (!item.slug) return undefined;
const slug = encodeURIComponent(item.slug);
if (block.content?.source !== 'section') return `/articles/${slug}`;
const base = Object.hasOwn(sectionRoutes, item.nodeTypeKey) ? sectionRoutes[item.nodeTypeKey] : undefined;
return base ? `${base}${slug}` : undefined;
}
function Hero({ content, context }) {
const href = context.resolveLink(content.ctaLink);
return (
<header className="py-24 text-center">
<h1 className="text-5xl font-bold">{content.heading}</h1>
{href && content.ctaText && <Link href={href} className="btn mt-8">{content.ctaText}</Link>}
</header>
);
}
export async function SitePage({ page }) {
const resolveLink = makeResolveLink(await fetchPageSlugs());
return (
<MentraPage
page={page}
blocks={{ hero: Hero, testimonials: null }}
resolveLink={resolveLink}
resolveItemHref={itemHref}
/>
);
}Theme
theme takes colours by role, heading and body fonts, and shape (radius, spacing, shadow, button style). The default blocks don't apply them; they are set as CSS custom properties on the page wrapper (--mentra-color-primary, --mentra-font-heading, …) for your stylesheet.
<MentraPage page={page} theme={{ colors: { primary: '#0f766e' }, fonts: { heading: 'Fraunces' } }} />.mentra-block-cta { background: var(--mentra-color-primary); }
.mentra-page h1, .mentra-page h2 { font-family: var(--mentra-font-heading); }Rendering an article
Import MentraContent and pass the contentJson field from any content detail response. Optionally import the companion CSS for default structural styling.
import { MentraContent } from '@mentra-intelligence/react';
import '@mentra-intelligence/react/styles'; // Optional: basic structural styles
export default function MyPage({ contentJson }) {
return (
<div className="prose">
<MentraContent doc={contentJson} />
</div>
);
}Custom Overrides
Customize how any node or mark is rendered by passing overrides. Useful for integrating your design system (e.g., custom links, Next.js routing, optimized images).
import { MentraContent } from '@mentra-intelligence/react';
import Link from 'next/link';
export default function MyPage({ contentJson }) {
return (
<MentraContent
doc={contentJson}
nodeOverrides={{
heading: ({ node, children }) => {
const Tag = `h${node.attrs?.level ?? 1}` as keyof JSX.IntrinsicElements;
const classes = node.attrs?.level === 1
? 'text-4xl font-bold'
: 'text-2xl font-semibold';
return <Tag className={classes}>{children}</Tag>;
},
image: ({ node }) => (
<img
src={node.attrs?.src}
alt={node.attrs?.alt}
loading="lazy"
className="rounded-xl shadow-lg my-4"
/>
),
}}
markOverrides={{
link: ({ mark, children }) => (
<Link href={mark.attrs?.href || '#'} className="text-blue-600 hover:underline">
{children}
</Link>
),
}}
/>
);
}Using contentHtml without React?
If you consume contentHtml in a non-React environment, the same structural CSS handles image alignment and sizing:
import '@mentra-intelligence/react/styles';
Analytics & Telemetry
Mentra includes a built-in analytics engine to track user engagement and learning progress. Tracking works in two ways:
Interactions with the Mentra API are tracked automatically. You don't need to do anything extra.
- Quiz Submissions
- AI Content Generation
- Mnemonic Creation
To track page views on your frontend, you must manually trigger the tracking endpoint when a user visits a page.
- Page Views
- Content Opens
- Custom UI Events
How to Track Views
Call the /telemetry/public/track endpoint whenever a user views a content item. This allows Mentra to aggregate view counts and engagement metrics. It takes no API key. Instead it requires your workspace's telemetry write key, sent as write_key in the body. Find it on the API Keys page (tenant admins only). The key decides which workspace an event is recorded against, so you don't send a tenant id. If you do send tenant_id, it must match the key's workspace. A missing, unknown or rotated-out key is rejected with 401 invalid_write_key, and a mismatched tenant with 403 write_key_tenant_mismatch. A content_id that belongs to another workspace is rejected with 403 foreign_content. Nothing is recorded in any of these cases. Each workspace can record up to 1,200 anonymous events per minute; past that, events are rejected with 429 telemetry_rate_limited and a Retry-After header, and nothing is recorded. The limit is per workspace, so another workspace's traffic never counts against yours. The key is public, not a secret: it only lets a caller record telemetry events for your workspace. Rotating it revokes the old key immediately. Call it server-side, same as every other tenant content/data operation in this guide: production CORS defaults to Mentra's own origins plus a wildcard for *.vercel.app and *.run.app deploys, and it does not allow an arbitrary custom domain.
/telemetry/public/trackcurl -X POST https://api.mentra.systems/api/v1/telemetry/public/track \
-H "Content-Type: application/json" \
-d '{
"write_key": "mn_pub_...",
"event_type": "view",
"content_id": "content-123",
"content_type": "article",
"metadata": {
"source": "web_client",
"path": "/kb/getting-started"
}
}'Real-World Scenarios
A content item can carry AI-generated Real-World Scenarios (request them with include=scenarios). These are practical situations where the learner can apply the knowledge they just acquired.
Scenarios help bridge the gap between theory and practice by providing:
- Role - The persona the learner should adopt.
- Setting - The context where the scenario takes place.
- Challenge - The problem to solve.
- Solution - The optimal way to apply the knowledge.
Data Structure
Scenarios are returned as part of the scenarios array in the Level 4 content response.
{
"scenarios": [
{
"title": "Handling a Difficult Client",
"role": "Project Manager",
"setting": "Weekly Status Meeting",
"challenge": "The client is demanding features that were out of scope...",
"solution": "Acknowledge their request, refer back to the SOW, and offer to create a change request...",
"key_takeaways": ["Boundaries", "Documentation", "Professionalism"]
}
]
}Adaptive Learning & LMS
⚠️ Not part of the public v1 API. These endpoints live on lms_adaptive and require a signed-in Firebase user — not a mn_live_... API key — so they aren't reachable with the auth scheme the rest of this guide describes. Documented here for completeness; not callable by a public-API integrator today.
Overview
Mentra provides intelligent LMS features to create adaptive learning experiences. You can submit quiz answers to get instant grading and identify knowledge gaps, then request personalized remediation content generated by AI.
/api/v1/adaptive/quiz/submitSubmit answers for a quiz attached to a Section node whose node type declares the assessable capability (any other node returns 400), or to a Standalone Content item. Returns grading results and identifies content blocks that need review. content_type is node or standalone_content; step is still accepted as a deprecated alias for node.
{
"content_item_id": "node_123",
"content_type": "node", // or "standalone_content"
"locale": "en-US",
"answers": [
{
"question_id": "q1",
"selected_option_index": 1
},
{
"question_id": "q2",
"selected_option_index": 0
}
]
}{
"total_questions": 2,
"correct_answers": 1,
"score_percentage": 50.0,
"passed": false,
"failed_block_ids": ["block_abc", "block_xyz"], // IDs of content blocks related to wrong answers
"feedback": [
{
"question_id": "q1",
"is_correct": true,
"correct_option_index": 1,
"explanation": "Correct! Active listening builds trust."
},
{
"question_id": "q2",
"is_correct": false,
"correct_option_index": 2,
"explanation": "Incorrect. Interruption breaks the flow.",
"related_content_block_ids": ["block_abc"]
}
]
}/api/v1/adaptive/remediateGenerate personalized remedial content for failed concepts. Uses AI to rewrite or explain specific content blocks using different strategies (simplification, analogy, etc.).
Strategies
simplify- Rewrites content in simpler terms.analogy- Explains concepts using real-world analogies.socratic- Asks guiding questions to help the learner.auto- AI chooses the best strategy (default).
{
"content_item_id": "node_123",
"content_type": "node",
"failed_block_ids": ["block_abc"],
"locale": "en-US",
"strategy": "analogy"
}{
"original_text_snippet": "Active listening requires...",
"remediated_content": "Think of active listening like being a goalie in soccer...",
"strategy_used": "analogy"
}Webhooks
Real-time Notifications
Webhooks allow your system to receive real-time notifications when content is published or unpublished in Mentra, or when a Placements surface changes. Instead of polling the API, you can listen for events and update your frontend or database immediately.
Supported Events
Triggered when any content item (a Section node such as a lesson or article, or standalone content) is published.
Triggered when content is unpublished.
Triggered when a Placements surface changes: settings updated, surface deleted, or a placement added, removed, or reordered. The payload carries the surfaceKey — re-fetch the surface and revalidate the page that renders it. Not a complete invalidation signal on its own: publishing/unpublishing a placed item emits the content events instead, and scheduled-window transitions, visibility-tags-only edits, and surface creation emit no webhook — see the Surfaces caching guidance.
There is no per-event subscription to manage: every active endpoint receives every event type above, including event types added after your endpoint was created. Adding an endpoint is the whole opt-in. Switch on X-Mentra-Event (or the payload's type) and ignore event types you don't recognise, rather than assuming the set is fixed or that every delivery carries the same payload shape.
Delivery & Headers
Each webhook request includes several custom headers to help you identify and process the event:
| Header | Description |
|---|---|
| X-Mentra-Signature | HMAC-SHA256 signature of the request body (hex digest), signed with your webhook secret. Omitted if the webhook has no secret configured. |
| X-Mentra-Event | The event type (e.g., content.published). |
| X-Mentra-Delivery | The event's stable identifier (matches the payload's id). The same value is sent for every endpoint receiving this event and for every retry — it does not change per delivery attempt. |
| User-Agent | Mentra-Webhook/1.0 |
Reliability & Retries
Mentra attempts to deliver webhooks with high reliability. If your endpoint is unreachable, or responds with a 5xx or 429 status, we retry the delivery using an exponential backoff strategy. A response in the 4xx range other than 429 is treated as a permanent rejection and is not retried.
- Attempts: Up to 5 total (the initial request plus up to 4 retries).
- Strategy: Exponential backoff (1s, 2s, 4s, 8s between attempts).
- Timeout: Each request has a 10-second timeout.
You can view the full history of delivery attempts, including request/response details, in the Developers section of your tenant dashboard.
Payload Structure
For the content events (content.published / content.unpublished), Mentra sends a JSON payload with the following structure. The data.content field contains the full content object.data.contentType names the kind of item — for a Section node published from the CMS it is the node's nodeTypeKey (for example lesson or article), and for standalone content standalone_content. Treat it as an open string.
{
"id": "evt_507f1f77bcf86cd799439011",
"type": "content.published",
"created": 1731671234,
"tenantId": "your-tenant-id",
"data": {
"contentType": "lesson",
"contentId": "node_123456",
"locale": "en-US",
"timestamp": "2024-11-15T12:00:00Z",
"content": {
"id": "node_123456",
"title": "Inviting your team",
"slug": "inviting-your-team",
"coverImageUrl": "https://...",
"seoMetadata": {
"metaTitle": "Inviting your team",
"metaDescription": "Add colleagues to your workspace..."
}
// ... full content fields
}
}
}surface.updated payload
The surface.updated event carries a minimal payload — it identifies which surface changed, not what its new contents are. On receipt, re-fetch the surface via GET /surfaces/{key} (see Surfaces) and revalidate the page that renders it. A surface.updated for a surface that no longer exists means the surface was deleted.
{
"id": "evt_9b2f4a11c3d84e0f8a6b1c2d3e4f5a6b",
"type": "surface.updated",
"created": 1731671234,
"tenantId": "your-tenant-id",
"data": {
"surfaceKey": "front-page-hero",
"surfaceId": "your-tenant-id:front-page-hero",
"changedAt": "2024-11-15T12:00:00Z"
}
}Security & Verification
Requests include the X-Mentra-Signature header — an HMAC-SHA256 hex digest of the raw request body, signed with your webhook secret — only when the webhook has a secret configured. A webhook created with an empty secret receives deliveries with no X-Mentra-Signature header at all; treat a missing header as expected in that case rather than as a verification failure. Configure a secret if you need every delivery to your endpoint signed.
Python Verification Example
import hmac
import hashlib
def verify_signature(payload_body: bytes, secret: str, signature_header: str) -> bool:
"""
Verifies the X-Mentra-Signature header.
payload_body: The raw bytes of the request body.
secret: Your webhook secret.
signature_header: The value of X-Mentra-Signature.
"""
expected_signature = hmac.new(
key=secret.encode("utf-8"),
msg=payload_body,
digestmod=hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected_signature, signature_header)Error Codes
This API returns two different error shapes, and which one you get depends on where the request failed. Check for error first, then fall back to detail — that covers both.
Application errors — error.code
Anything the API itself rejects (a bad slug, a refused delete, an unknown node type) carries a stable code you can branch on. Codes are part of the contract; the message beside them is not, and may be reworded. Every code in the reference below arrives in this shape when it is the request's outcome — the exception is a bulk row, below.
{
"error": {
"code": "reparent_unsupported",
"message": "Re-parenting is not supported. …",
"request_id": "a1b2c3d4"
}
}request_id identifies the single request in our logs — quote it when reporting a problem. Some errors add an error.details object with structured context; treat it as optional.
Framework errors — detail
Failures caught before your request reaches our code come from FastAPI, in its own shape, and carry no code: a missing or unknown API key (401), a key without the scope for that endpoint (403), a rate limit (429), and a body or query that fails the endpoint's schema (422, where detail is an array of field errors).
The two 403 cases need different fixes, and the status alone does not tell them apart. A sandbox key is refused on every write even when it is valid and correctly scoped, so widening its scopes changes nothing — the remedy is a production key. Sandbox refusals carry an X-Mentra-Sandbox: true response header; branch on that rather than on the message.
{ "detail": "Invalid or missing API key" }So a client that only reads error.code finds nothing on exactly the errors it meets first — a wrong key, a missing scope. The one authorisation code that does use the error shape is tenant_mismatch, because our own code raises it after authentication has already succeeded.
Bulk endpoints: failures come back as rows
A bulk request that reaches the per-item loop returns 200 and reports failures per row in results[].error — the human-readable message only, with no code. This is where the envelope promise above stops: the loop keeps str(the error), so a code raised inside it never reaches you as a code. Branch on the row's action, whose values differ by endpoint and by whether it was a dry run:
| Endpoint | Live run | dryRun: true |
|---|---|---|
| POST /import | created, updated, skipped, failed | would_create, would_update, would_skip, failed |
| POST /bulk-delete | deleted, not_found, failed | would_delete, not_found, failed |
Failures before the loop — a malformed request, or onConflict: "fail" finding an existing item — are ordinary application errors in the error shape above.
HTTP Statuses
| Status | Meaning | Notes |
|---|---|---|
200 | OK | Success, including updates and upserts. Bulk endpoints also use it for partial failure — check each row. |
201 | Created | A new item was created. A POST that upserted an existing one returns 200 instead. |
304 | Not Modified | Your cached copy is current. |
400 | Bad Request | Something in the request is invalid. The code says what. |
401 | Unauthorized | Missing or invalid API key — usually a truncated key, a key from the wrong environment, or the wrong header name. |
403 | Forbidden | Either the key lacks the scope for this endpoint (e.g. writing with a read:content-only key), or it is a sandbox key and the endpoint writes. |
404 | Not Found | No such item, or it isn't published or visible to this key. |
409 | Conflict | The write collides with existing data — a taken slug or externalId, or a delete refused because children exist. |
413 | Payload Too Large | The request cannot be stored — a size or quota ceiling, not a malformed body. storage_quota_exceeded is the one that reaches an API key: free space or raise the plan's allowance and re-send the same request unchanged. |
422 | Unprocessable Entity | The body did not match the endpoint's schema (FastAPI's own validation, ahead of ours). |
429 | Too Many Requests | Back off and retry with exponential delay. |
5xx | Server Error | Our side failed — but read error.code before retrying. source_url_import_unavailable (503) cannot succeed on retry until an operator changes something; storage_unavailable (502) can. A storage-quota overrun is not here — it answers 413 storage_quota_exceeded, where it used to arrive as a 502 image_processing_failed. Quote the request_id if it persists. |
Error Code Reference
Grouped by the surface that raises them. A code raised on more than one surface — node_not_found arrives from a read, a write and a bulk row alike — is listed under each. The same code means the same thing wherever it appears.
All endpoints
| Code | HTTP | Means | What to do |
|---|---|---|---|
| internal_error | 500 | Something failed on our side that we did not anticipate. | Not your request. Retry, and if it persists quote the request_id — it identifies the failure in our logs. Emitted by the catch-all handler, so it can arrive from any endpoint. |
Read
| Code | HTTP | Means | What to do |
|---|---|---|---|
| tenant_mismatch | 403 | A standalone item with that id exists, but belongs to a different tenant than your API key. | Check the id — ids are not shared between tenants. Only GET /standalone/{id} can return this; every other id-taking route filters by tenant first and answers with its own not-found code. |
| standalone_not_found | 404 | No standalone item with that id exists for your tenant. | Check the id, or list /standalone to find the current one. |
| standalone_not_published | 404 | The item is not published in the locale you asked for. It may be published in others. | This is per-locale, not per-item: the filter reads statusByLocale for the requested locale, with BCP-47 prefix matching (nb matches nb-NO). So an item live in en-US still answers this to ?locale=nb. Check that locale's status, or publish it — or pass publishedOnly=false with an API key holding the read:preview scope. Omitting `locale` does not mean "any published locale": the route substitutes the item's originalLocale (falling back to en-US), so an item whose original locale is a draft answers this even when another translation is live. Ask for the published locale explicitly. |
| standalone_not_visible | 404 | The item's visibility tags exclude this request. | Independent of publication: the visibility filter runs after the publish check and on its own, so a draft read with publishedOnly=false and a read:preview key can still answer this. Check the item's visibility settings in the CMS. |
| section_not_found | 404 | No Section with that key exists for your tenant. | Call GET /sections for the keys your tenant actually has. A tenant with no Sections gets an empty list rather than an error. |
| node_not_found | 404 | The node is not readable at that address — which covers more than a wrong id. | GET /sections/{key}/nodes/{id} collapses five conditions into this one 404, deliberately: no such node, a node belonging to a different Section, the node unpublished or hidden in the requested locale, and either of those two applied to any ancestor. So a valid id in the right Section still answers this when it or something above it is unpublished. Check publication and visibility state for the locale, and up the ancestor chain, before re-checking the id. Distinguishing them would leak which ids exist. |
| surface_not_found | 404 | No Surface with that key exists for your tenant. | Call GET /surfaces for the available keys. |
| page_not_found | 404 | No page is readable at that slug — which covers more than a typo. | GET /pages/{slug} answers this for a slug no page uses and, deliberately, for a page that exists but is not published (a draft, or one whose published version was taken down), so a draft's slug is not disclosed. Publish the page in the CMS, or read its working copy with publishedOnly=false and a read:preview key. The slug matched is the published one: a page renamed since its last publish is still served at its old slug until it is published again. |
| preview_token_invalid | 403 | The X-Preview-Token header carried a page preview token that is not valid for this request. | GET /pages/{slug} answers this for a token that is unknown, has expired (tokens live 10 minutes), or was minted for another page slug or another tenant than your API key's — deliberately the same answer for each, so it does not say which. It is returned even when your key holds read:preview. Ask the editor to press Preview again for a fresh token, and send it only with the slug it came with. |
| slug_not_found | 404 | Nothing readable resolves at that slug — which covers more than a typo. | As well as no published slug matching, a structured hit is discarded when its Section has no public key, or when any ancestor is missing, hidden or unpublished; all of it collapses to this one 404 so the response cannot confirm a slug exists. Check the locale, then the Section's public key and the ancestor chain's publish and visibility state, before assuming the slug is wrong. |
| invalid_content_type | 400 | The contentType filter on slug lookup is not one this API recognises. | The parameter is named contentType — use one of the documented values, or omit it to search every type. |
| invalid_type_filter | 400 | The type filter on export is not one this API recognises. | The parameter is named type on the wire. The handler variable is content_type, but it is declared with alias="type", so type is what a client sends — an earlier revision of this entry said content_type and was wrong. Use one of the documented values, or omit it to export everything. |
| structured_parent_ambiguous | 409 | A structure node has more than one attached content item, so the operation cannot identify its body. | Detach the extra content item so the node has exactly one body, then retry. Exports and identity-based updates refuse an arbitrary body. |
| unknown_template | 400 | The Section you addressed points at a structure template this deployment no longer ships. | Not something a request can fix — the Section's stored templateId is stale. Read GET /sections to see the Sections you can write to, and report the affected sectionKey so the Section can be repaired. |
Write
| Code | HTTP | Means | What to do |
|---|---|---|---|
| standalone_not_found | 404 | No standalone item with that id exists for your tenant. | Check the id, or list /standalone to find the current one. |
| section_not_found | 404 | No Section with that key exists for your tenant. | Call GET /sections for the keys your tenant actually has. A tenant with no Sections gets an empty list rather than an error. |
| node_not_found | 404 | The node is not readable at that address — which covers more than a wrong id. | GET /sections/{key}/nodes/{id} collapses five conditions into this one 404, deliberately: no such node, a node belonging to a different Section, the node unpublished or hidden in the requested locale, and either of those two applied to any ancestor. So a valid id in the right Section still answers this when it or something above it is unpublished. Check publication and visibility state for the locale, and up the ancestor chain, before re-checking the id. Distinguishing them would leak which ids exist. |
| structured_parent_ambiguous | 409 | A structure node has more than one attached content item, so the operation cannot identify its body. | Detach the extra content item so the node has exactly one body, then retry. Exports and identity-based updates refuse an arbitrary body. |
| reparent_unsupported | 400 | You asked to change an item's parent. No endpoint moves an item. | Re-create the item in its new position and delete the original. For a subtree, build the replacement top-down with fresh externalId and slug values, then delete the old one leaf-first. On the Section node routes a sent parentId is compared with the node's current one, so an explicit null is refused only where it would actually move something — updating a node that currently has a parent. On a node already at the Section root it changes nothing and the write succeeds. On a create it means what omitting it means, at the Section root, so a non-root type is refused either way — with invalid_root_type, not this code. Omit the field to leave an existing parent alone. |
| node_has_children | 409 | You tried to delete an item that still has children. Nothing was deleted — the API refuses rather than orphaning them. | Delete the children first, leaf-first, or send a node item to POST /bulk-delete with cascade: true, which walks the subtree for you, bottom-up. |
| slug_conflict | 409 | A slug you supplied explicitly is already taken for that locale. | Slugs are unique per tenant and locale, across every Section. Change the effective slug or drop it: seoMetadata.slug wins over the top-level slug, so omitting only the latter can hit the same conflict again. With no slug at all, one derived from the title auto-suffixes (-2, -3) instead of conflicting. |
| external_id_conflict | 409 | Another node already holds that externalId in the same namespace. | Only node writes reserve externalId. You receive it as a code on POST/PUT /sections/{key}/nodes only, where the reservation is per (Section, nodeTypeKey). Bulk import reserves the same way — per (Section, node type) — but a bulk row carries no code: the collision comes back as action: "failed" with this message in results[].error, so branch on action, not on a code that will not be there. POST /standalone does not — it treats an existing tenant-scoped externalId as an idempotent upsert and never raises this. Pick another id, or send the write as the update it is. |
| not_content_bearing | 400 | You sent a content body to a node type whose template says it cannot hold one. | Remove content, contentFormat, keywords and seoMetadata. title, summary and coverImageUrl are always accepted, so container nodes stay writable. |
| unknown_node_type | 400 | The nodeTypeKey is not a node type of that Section's template. | Read the template's vocabulary from GET /sections rather than assuming one — node types are tenant-authored and not a fixed set. |
| unknown_template | 400 | The Section you addressed points at a structure template this deployment no longer ships. | Not something a request can fix — the Section's stored templateId is stale. Read GET /sections to see the Sections you can write to, and report the affected sectionKey so the Section can be repaired. |
| node_title_required | 400 | title was blank or whitespace only. | Send a non-empty title. It is required on create and cannot be cleared on update. |
| invalid_external_id | 400 | The externalId is empty, too long, or contains a character the validator rejects. | The rule, in full, so you need not parse the message: at most 256 characters after surrounding whitespace is stripped, and matching ^[a-zA-Z0-9._\-:/]+$ — letters, digits, . _ - : / and nothing else. Whitespace around the value is trimmed rather than rejected, so the stored id can differ from what you sent — " id " is stored as id. That holds on every write surface, POST /import included, and for parentExternalId as well as externalId, so an id addresses the same item whichever surface wrote it. Common CMS id shapes (UUIDs, Contentful sys.id, Strapi ids) already fit. |
| invalid_locale | 400 | The locale cannot be used as a slug key because it contains . or a backtick. | Those two characters are unsafe in a Firestore field path, so a slug cannot be stored against such a locale. Raised only when you supply a slug alongside the locale — omit the slug and the write succeeds. Ordinary codes like en-US and nb-NO are unaffected. |
| invalid_input | 400 | A field failed validation. The message names it. | Fix the named field and retry. |
| firestore_document_too_large | 413 | The document this write would leave behind — a standalone item, a Section node, or its content translation — would exceed Firestore's 1 MiB limit once merged with what is stored. | Shorten the largest field — the message names it — and retry. POST and PUT /standalone and the Section node POST and PUT routes return this code. POST /import fails the row with the same message and does not return the code. |
Bulk
| Code | HTTP | Means | What to do |
|---|---|---|---|
| section_not_found | 404 | No Section with that key exists for your tenant. | Call GET /sections for the keys your tenant actually has. A tenant with no Sections gets an empty list rather than an error. |
| node_not_found | 404 | The node is not readable at that address — which covers more than a wrong id. | GET /sections/{key}/nodes/{id} collapses five conditions into this one 404, deliberately: no such node, a node belonging to a different Section, the node unpublished or hidden in the requested locale, and either of those two applied to any ancestor. So a valid id in the right Section still answers this when it or something above it is unpublished. Check publication and visibility state for the locale, and up the ancestor chain, before re-checking the id. Distinguishing them would leak which ids exist. |
| structured_parent_ambiguous | 409 | A structure node has more than one attached content item, so the operation cannot identify its body. | Detach the extra content item so the node has exactly one body, then retry. Exports and identity-based updates refuse an arbitrary body. |
| reparent_unsupported | 400 | You asked to change an item's parent. No endpoint moves an item. | Re-create the item in its new position and delete the original. For a subtree, build the replacement top-down with fresh externalId and slug values, then delete the old one leaf-first. On the Section node routes a sent parentId is compared with the node's current one, so an explicit null is refused only where it would actually move something — updating a node that currently has a parent. On a node already at the Section root it changes nothing and the write succeeds. On a create it means what omitting it means, at the Section root, so a non-root type is refused either way — with invalid_root_type, not this code. Omit the field to leave an existing parent alone. |
| node_has_children | 409 | You tried to delete an item that still has children. Nothing was deleted — the API refuses rather than orphaning them. | Delete the children first, leaf-first, or send a node item to POST /bulk-delete with cascade: true, which walks the subtree for you, bottom-up. |
| slug_conflict | 409 | A slug you supplied explicitly is already taken for that locale. | Slugs are unique per tenant and locale, across every Section. Change the effective slug or drop it: seoMetadata.slug wins over the top-level slug, so omitting only the latter can hit the same conflict again. With no slug at all, one derived from the title auto-suffixes (-2, -3) instead of conflicting. |
| external_id_conflict | 409 | Another node already holds that externalId in the same namespace. | Only node writes reserve externalId. You receive it as a code on POST/PUT /sections/{key}/nodes only, where the reservation is per (Section, nodeTypeKey). Bulk import reserves the same way — per (Section, node type) — but a bulk row carries no code: the collision comes back as action: "failed" with this message in results[].error, so branch on action, not on a code that will not be there. POST /standalone does not — it treats an existing tenant-scoped externalId as an idempotent upsert and never raises this. Pick another id, or send the write as the update it is. |
| not_content_bearing | 400 | You sent a content body to a node type whose template says it cannot hold one. | Remove content, contentFormat, keywords and seoMetadata. title, summary and coverImageUrl are always accepted, so container nodes stay writable. |
| unknown_node_type | 400 | The nodeTypeKey is not a node type of that Section's template. | Read the template's vocabulary from GET /sections rather than assuming one — node types are tenant-authored and not a fixed set. |
| parent_wrong_section | 400 | The parent exists but belongs to a different Section. | A node's parent must live in the same Section as the node. |
| invalid_child_type | 400 | The template does not allow this node type as a child of that parent. | Check the template's allowed nesting in GET /sections. |
| invalid_root_type | 400 | The node type cannot sit at a Section's root. | Omit parentId only for a type the template marks as a root type. |
| node_title_required | 400 | title was blank or whitespace only. | Send a non-empty title. It is required on create and cannot be cleared on update. |
| invalid_external_id | 400 | The externalId is empty, too long, or contains a character the validator rejects. | The rule, in full, so you need not parse the message: at most 256 characters after surrounding whitespace is stripped, and matching ^[a-zA-Z0-9._\-:/]+$ — letters, digits, . _ - : / and nothing else. Whitespace around the value is trimmed rather than rejected, so the stored id can differ from what you sent — " id " is stored as id. That holds on every write surface, POST /import included, and for parentExternalId as well as externalId, so an id addresses the same item whichever surface wrote it. Common CMS id shapes (UUIDs, Contentful sys.id, Strapi ids) already fit. |
| node_address_required | 400 | A bulk item of type node did not carry the Section address it needs. | An import item needs both sectionKey and nodeTypeKey. A delete item needs sectionKey, plus nodeTypeKey when it names the node by externalId rather than id. Read both from GET /sections. |
| node_address_not_allowed | 400 | sectionKey or nodeTypeKey was sent on an item that is not of type node. | Those fields (and, on import, parentNodeTypeKey) belong only to node items. Sending them on a standalone item is rejected rather than quietly ignored, since a client that names a Section means it. |
| duplicate_item_address | 400 | Two rows in the same request address the same item. | Nothing is written — one of the two would otherwise be silently discarded. De-duplicate the batch. Re-sending the same externalId in a later request is the intended idempotent path and is fine. |
| parent_node_type_without_external_id | 400 | parentNodeTypeKey was supplied without a parentExternalId for it to disambiguate. | parentNodeTypeKey only narrows a parentExternalId lookup. Drop it, or supply the parentExternalId it qualifies. |
| items_empty | 400 | The request carried no items. | Send at least one item. |
| items_limit_exceeded | 400 | More items than a single request accepts. | Batch into requests of 100 items or fewer. |
| invalid_item_type | 400 | An item's type is not one this endpoint accepts. | Bulk import and bulk delete both take standalone and node. Structured content of every Section is addressed as node. |
| invalid_on_conflict | 400 | onConflict is not one of skip, update or fail. | Use one of the three. |
| import_conflict | 409 | onConflict: "fail" and at least one item already exists. Nothing was written. | Switch to update to upsert, or skip to leave existing items alone. This aborts the whole request before any row is processed. |
| item_identifier_required | 400 | A bulk-delete item named neither id nor externalId. | Give each item one of the two. |
Media
| Code | HTTP | Means | What to do |
|---|---|---|---|
| media_not_found | 404 | No media item with that id exists for your tenant. | Check the id, or list the media library. |
| unsupported_image_type | 400 | The uploaded file is not an image type this API accepts. | Convert to a supported format before uploading. |
| empty_file | 400 | The uploaded file had no content. | Check the upload actually carried a body. |
| payload_too_large | 400 | The upload exceeds the size limit. | Resize or compress before uploading. |
| file_read_failed | 400 | Reading the uploaded bytes off the request failed. | This is a transfer failure, not a verdict on your image: the only raising site is an exception from file.file.read(), so it means the multipart upload was truncated or the server could not buffer it. Corrupt contents read back fine and fail later as unsupported_image_type or image_processing_failed. Retry the upload; replacing the file will not help. |
| source_url_not_allowed | 400 | We will not fetch that target. | Deliberately vague, and deliberately one code: the URL may be unresolvable, resolve to a private address, or redirect somewhere we refuse. Distinguishing those in the response would answer questions about our network. Use a URL on the public internet. |
| invalid_source_url | 400 | The sourceUrl you sent is empty or is not a well-formed URL. | This is about your string, not about where it points — a target we refuse to fetch returns source_url_not_allowed instead. Note that omitting sourceUrl does not reach this check at all: the field is required, so the request fails schema validation first and comes back as a framework 422 with detail and no code. |
| source_url_timeout | 400 | The source host did not respond in time. | Retry, or host the image somewhere faster. Upload the bytes directly if the source is unreliable. |
| source_url_fetch_failed | 400 | The download failed at the HTTP or network level — the host never answered, the connection dropped, or it returned an error status. | Check the URL is reachable from the public internet and answers 200 without authentication. A page that answers normally but is not an image gets past this and fails later as unsupported_image_type. |
| source_url_import_busy | 503 | The URL could not be resolved right now — the resolver is saturated or hit a temporary DNS failure. | Nothing is wrong with your request: retry shortly. This is not a per-tenant limit, so it is not caused by your own other imports. |
| source_url_import_unavailable | 503 | This deployment is not configured to fetch URLs safely, so it refuses to fetch at all. | Retrying will not help — the condition is operational, not transient. Upload the bytes directly, or ask whoever runs the deployment. |
| storage_quota_exceeded | 413 | The upload would take the tenant past its storage quota. Nothing was stored. | Free space or raise the plan's storage allowance, then re-send the same request — the image is not the problem, so re-encoding or shrinking it only helps insofar as it fits under the remaining allowance. Until #2232 this arrived as image_processing_failed, which sent people to debug the file. |
| storage_unavailable | 502 | The storage backend could not complete an upload or delete operation. | Ours, not yours. Retry with backoff; if it persists quote the request_id. An upload that fails this way writes no media record or quota. A failed delete keeps the media record and quota unchanged, and a retry skips objects already gone. Once every named object is absent, quota release and metadata deletion commit atomically, so an ambiguous storage response cannot double-release or strand quota. This path does not orphan blobs. A failed upload can still leave an object: rollback is best effort and uses the same storage that just failed. Such an object belongs to no asset, counts against no quota, and is invisible to the API, so report repeated failures rather than only retrying. |
| image_processing_failed | 502 | Compressing or storing the image failed after the bytes arrived. | This now means what its name says: a quota overrun answers storage_quota_exceeded and an unreachable store answers storage_unavailable, both of which this code used to swallow (#2232). Retry, and report a repeated failure on one file. |
Telemetry
| Code | HTTP | Means | What to do |
|---|---|---|---|
| invalid_write_key | 401 | The write_key is missing, malformed, unknown or rotated out, or its workspace no longer exists. Nothing was recorded. | Send your workspace's current telemetry write key, from the API Keys page. If you rotated it, update your integration to the new one; the old key is refused immediately. |
| write_key_tenant_mismatch | 403 | The body's tenant_id names a different workspace from the one the write_key belongs to. Nothing was recorded. | Drop tenant_id — the key decides the workspace — or send the key that belongs to the workspace you meant. |
| foreign_content | 403 | The content_id names content that belongs to a different workspace from the one the write_key belongs to. Nothing was recorded. An id that matches no content in any workspace is accepted. | Send the ids of your own workspace's content. If you serve content from more than one workspace, send each event with that workspace's key. |
| telemetry_rate_limited | 429 | This workspace has reached its per-minute limit of anonymous telemetry events (1,200 a minute), or sent a large burst at once. Nothing was recorded. The limit is per workspace, so other workspaces are unaffected. | Wait the number of seconds in the Retry-After header, then resend. If your real traffic reaches the limit, sample view events. |
Rate Limits & Pricing
Mentra API enforces rate limits per API key to ensure service quality for all customers.
| Limit | Value | Applies To |
|---|---|---|
| Requests per minute | 60 | All endpoints, per API key |
| Bulk import items per request | 100 | POST /import endpoint |
| Max file size (media upload) | 20 MB | POST /media/upload and /media/upload-url |
| Storage quota per tenant | Plan-based | Total media storage, enforced per tenant |
| Page size (list endpoints) | 100 | All GET list endpoints |
When Rate Limited
If you exceed the rate limit, the API returns 429 Too Many Requests with a Retry-After: 60 header.
- Implement exponential backoff when retrying failed requests
- Respect the
Retry-Afterheader value - Use bulk import to reduce request count for large imports
- Cache read responses locally using ETag headers
For higher limits or enterprise plans, please contact our sales team.
Changelog
GET /v1/pages/{slug} accepts an optional X-Preview-Token header, so the page builder's Preview button can open a page's unpublished working copy on your own site. A tenant admin sets a preview URL template (for example https://example.com/api/preview?slug={slug}&token={token}) in Organization settings; pressing Preview then opens that URL with a token minted for that one page. Your site sends the token with its usual API key, which needs read:content but not read:preview. A token reads that one page's working copy at that one slug, for that tenant only, for 10 minutes; data-backed blocks on it stay published-only, and publishedOnly is ignored. Any other use (expired, unknown, another slug or another tenant's key) is a 403 preview_token_invalid, even for a key that holds read:preview. No other route reads the header. Responses now also list X-Preview-Token in Vary. Requests without the header behave exactly as before.
New: GET /v1/pages lists your published pages, for building routes or a sitemap on your own site. It needs the read:content scope. Each item is a summary without blocks: id, slug (the slug GET /v1/pages/{slug} serves it at), title, status, canonicalUrl (null when no public URL is set), publishedAt and updatedAt, taken from the page's published copy, so unpublished edits stay unseen. Drafts and taken-down pages are not listed. Items are sorted by slug and paginated with page and pageSize (default 20, at most 100), returning total, hasNext and nextPage like the other /v1 lists. publishedOnly=false lists every page's working copy at its working slug and requires read:preview, as on the other /v1 reads. Responses carry an ETag; send it back as If-None-Match for a 304.
GET /v1/menus adds an optional locale query parameter and two response fields. With locale (for example nb, or nb-NO, which matches nb by prefix), level items resolve to their slugs and urls in that locale, and an item whose target, or any of its ancestors, is not published in that locale comes back unresolved (slug and url null); page and Section items are unaffected. Without it, resolution is unchanged. courseSlug is set on a resolved journey item to its course's slug, the one its url already uses, so it no longer has to be parsed out of url. containingSectionKey is set on a resolved level item (lifeskill, course, journey or step) to the key of the Section it sits in; sectionKey still marks only an item that targets a Section. Both are null on every other item. The change is additive: existing fields are unchanged.
New: GET /v1/pages/{slug} returns one of your published pages by its slug, for rendering on your own site. It needs the read:content scope. The response carries the page's blocks in the canonical block shape, its JSON-LD (jsonLd), the SEO fields metaTitle, metaDescription and ogImage, and canonicalUrl, which is your tenant's public URL plus the slug (null when no public URL is set). Data-backed blocks come back resolved: a dynamic_content block gains resolved.items, the published items it lists in the requested locale, and a content_body block naming a specific standalone item gains resolved.item. Blocks that need an item to render against (meta_info, context_navigation, content_body in context mode, or any block with dynamicMappings) are returned unresolved and flagged contextBound: true. A draft page returns 404 page_not_found, the same as a slug that does not exist. publishedOnly=false returns the page's working copy and requires read:preview, as on the other /v1 reads. Responses carry an ETag; send it back as If-None-Match for a 304.
Breaking: GET /v1/menus now requires an API key with the read:content scope, sent the same way as for the other /v1 content reads (X-Api-Key, or Authorization: Bearer). The menus returned are always the key's tenant's. The tenantId and tenantName query parameters and the X-Tenant-ID header are removed: they no longer select a tenant and are ignored if sent. A request with no key, or an unknown one, returns 401 with a detail message; a key without read:content returns 403. Each call is logged against the key, as the other /v1 content reads are. A sandbox key reads the sandbox tenant's menus. The tenant_identifier_required and tenant_not_found error codes are no longer returned by this endpoint and are removed from the error-code reference. The response shape is unchanged.
The level1..4 item types are removed from the public API. Every Section's structured content, the legacy four-level Section included, is now addressed as a node item, with sectionKey plus nodeTypeKey; read both keys from GET /v1/sections. POST /v1/import and POST /v1/bulk-delete reject an item typed level1, level2, level3 or level4 with 400 invalid_item_type, and nothing in the request is written or deleted. Import no longer creates the legacy four-level Section for you: a node item addresses an existing Section by key. POST /v1/bulk-delete gains the node item type: send sectionKey with the node's id, or sectionKey, nodeTypeKey and externalId together, since an externalId is unique per Section and node type. With an id, nodeTypeKey is optional, and when you send it the node must be of that type. cascade: true deletes a node's descendants bottom-up. Without it, a node that still has children fails with node_has_children. Delete results now echo the sectionKey and nodeTypeKey a node item was sent with, and are null on standalone items. GET /v1/export emits the legacy four-level Section as node items, parents before children like every other Section, instead of as level1..4 items. Each of those items carries an externalId, which falls back to the node's id when none is stored. parentId is null and parentExternalId plus parentNodeTypeKey name the parent, exactly as on other node items. The type filter accepts standalone and node, and level1..4 now returns 400 invalid_type_filter. An export file taken before this release that contains level1..4 items will not import verbatim: take a fresh export, or rewrite those items as node items with the Section's keys. GET /v1/content/by-slug/{slug} accepts contentType standalone or node, and level1..4 now returns 400 invalid_content_type. A structured hit reports contentType node rather than level1..4, still with the sectionKey to read it from. Slug lookup now searches the nodes of every Section, not only the legacy four-level one, so a node slug in any Section resolves here. Standalone content is unchanged everywhere.
Documentation corrections; no API behaviour changed. The Sections, import, export and migration guides now use the built-in Programme and Knowledge Base templates as their examples, and the level1..4 item types are documented as a legacy option that keeps working unchanged. Three published examples did not match what the API returns and are corrected: bulk-import result rows carry type and action (with externalId, id, error, sectionKey and nodeTypeKey), not index and status; GET /v1/export has no locale parameter, and pageSize defaults to 100, not 50; and GET /v1/content/by-slug/{slug} searches only standalone content and nodes in the legacy four-level Section, so resolve a node's slug in any other Section from that Section's tree. If you built against the old examples, check those three points.
The twelve level-addressed write routes are removed: POST, PUT and DELETE on /v1/level1, /v1/level2, /v1/level3 and /v1/level4 no longer exist and now return 404. Write structured content through POST /v1/sections/{sectionKey}/nodes and PUT/DELETE /v1/sections/{sectionKey}/nodes/{nodeId} instead. Only POST names a node type, as nodeTypeKey (read the keys from GET /v1/sections); PUT and DELETE take just the Section key and node id, and PUT rejects a nodeTypeKey with 422. Unlike the level routes, which refused an id from the wrong level with 404, these routes check only that the node belongs to the named Section, not its type: a DELETE with the wrong id removes that node, so verify the node's nodeTypeKey first if your client could mismatch them. The read routes were retired earlier. Bulk import and bulk delete still accept level1..4 as an item type, unchanged.
A structure node's own title, description and cover image are now published rather than live, on every publishedOnly=true read. This closes the one case the earlier release today deliberately left open. Where a node carries content, its title, summary and coverImageUrl already came from the published translation. Where it does not — a Knowledge Base category or a group root, any node created but not yet given a body — those three fields were read from the node document itself, and that document is rewritten the instant an editor saves, with no publish step in between. So renaming a category made the new name public immediately, while renaming an article did not, and nothing in the response told the two apart. The same field behaved differently depending on whether a body happened to be attached. Publishing a node now also freezes its own title, description and cover image, and a published read serves that frozen copy: GET /v1/sections/{key}/tree, GET /v1/sections/{key}/nodes/{id} (including its breadcrumbs and children), the node teasers in GET /v1/surfaces/{key}, GET /v1/content/by-slug/{slug} and GET /v1/export all resolve it the same way, so no two of them can disagree about a node. Renaming a node after publishing it therefore keeps serving the previously published name until you publish again — which is what publishing already meant for every other kind of edit on this API. Three things do not change, and are worth stating because each is the question integrators ask first. Nothing goes blank: a node that has never been published serves its current values, so navigation labels cannot disappear, and existing content was seeded with the values it was already serving, making this release a no-op for every node until its next edit. Preview reads are untouched — publishedOnly=false with a key holding read:preview still shows the working values, which is what that scope is for. And structural edits are still immediate: reordering, creating and deleting nodes take effect without a publish, because navigation housekeeping is not published copy. No request or response field was added, removed or renamed, and no field's type changed. If you cache tree or node responses keyed on title, expect one invalidation as the values settle to their published form; the tree's ETag already covers its resolved titles.
GET /v1/export now serves published bodies on a publishedOnly=true read, not working ones. Earlier today's release gated the export at item level: an item appears only when it is published, publicly visible and reachable. The body inside each item was left alone, so every item still carried its working translation — a published article whose editor had rewritten it and not republished exported the unreleased content, contentJson, contentHtml, summary, keywords and seoMetadata to a key holding only read:content, while GET /v1/standalone/{id} returned the published text to that same key at the same moment. Two reads of one item at one instant disagreed, which is the boundary the read:preview scope exists to draw and the one already closed for by-slug and surface teasers (title, summary, coverImageUrl) and for standalone SEO. All three builders are now scoped — standalone, level1..4 on either content platform, and Section-native node items — and none of them falls back: a published read reads the published revision or nothing. That covers title as well as the body. title, summary and coverImageUrl were additionally resolved from the item's own document when the translation had no value, and that document is mutable — renaming a node or a standalone item writes it immediately, with no publish step — so the same read could disclose an unpublished rename, description or cover. For any item that can hold a body those three now come from the published translation only — standalone, level1..4 items on either content platform, and Section-native node items alike. The one exception is a structural container that holds no body of its own: a category or group root legitimately stores its title and coverImageUrl on the node itself, there is no translation that could ever carry them, and those still return as before. If you call GET /v1/export to mirror what your site shows, this is what you wanted and there is nothing to do. Two consequences to plan for. First, an item whose ORIGINAL locale has no published revision now exports empty editorial fields — content, contentHtml, summary, keywords, seoMetadata and coverImageUrl come back null, and title comes back an empty string — instead of the draft's values. Note the asymmetry, because it is easy to miss: an item is admitted when any of its locales is published, but its body and metadata are read from the original locale only. So an article authored in en and published solely in nb is listed, correctly, and still comes back with those fields empty. That is a normal multilingual situation rather than a defect, and it has the same shape as an interrupted publish or as content marked live through the publishedLocales index with nothing under the published revision. The item is still listed and still addressable — id, slug, visibilityTags, isPublished and the timestamps are unchanged — so do not treat an empty body as a missing item. Identify items by id, which every item carries: externalId is optional and is commonly null on standalone and level1..4 records, which are the ones most likely to hit this, and only node items are guaranteed one because the export synthesises it. One consequence worth checking against your restore tooling: an item in that state will not re-import from a default export, because import requires a non-blank title. Take it with publishedOnly=false, or publish the item's original locale. Second, an export taken at the default no longer round-trips in-progress edits — re-importing it reconstructs the published text and drops the unreleased revision. That default already omitted unpublished items wholesale, so it was not a faithful backup before this change; the bodies now agree with the items. For a backup that restores exactly what the CMS holds, use publishedOnly=false with a key holding read:preview: that path still returns the working translation for every item, drafts included. One backup payload did change, and it changed by gaining content: level1..4 items belonging to tenants still on the pre-Sections content platform, whose bodies were authored in the CMS, previously exported with an empty body on both settings, because the export looked for the body beneath the level record while that content is stored alongside it instead. It is now found, so those items carry content, contentHtml, summary and the rest where they used to be blank. Nothing was removed or renamed, but a backup taken after this release can be materially larger than one taken before it, and a diff against an older backup will show those fields appearing — worth checking if your restore tooling treats an item gaining a body as a conflict. Every other item type is untouched on that setting. Holding read:preview does not change the default — the parameter does, so a preview-scoped key still reads like production, bodies included, unless it asks for drafts. One field's contents narrowed, on every setting: seoMetadata on standalone and level1..4 items is now limited to the keys the write API accepts (metaTitle, metaDescription, focusKeyword, focusKeywords, slug, canonicalUrl, readingTime), matching what node items have done since the Section export shipped. CMS-authored content also stores generated derivatives — suggestedSlug, structuredData, openGraph, twitterCard — and emitting those verbatim made POST /v1/import reject the entire request, so an export containing one such item could not be restored at all. If you read seoMetadata off an export for something other than re-import, those four keys are no longer present on those item types; the delivery reads still expose them. No request or response field was added, removed or renamed, and the item shape, pagination and type filter are unchanged.
GET /v1/export now accepts publishedOnly and defaults it to true, so an export returns published, publicly-visible content only — the same contract every other read on this API already has. Until now the export was the one read with no publish filter, no visibility filter and no preview gate: a key holding read:content alone received every draft in the tenant in full — bodies, summaries, keywords and SEO metadata — including items whose visibilityTags is empty and are therefore hidden from everyone, and items never published in any locale, while that same key got 403 Missing scope read:preview for preview access from GET /v1/standalone?publishedOnly=false. isPublished was computed per item and reported, but it filtered nothing. This is a behaviour change for anyone already calling /v1/export. If you export to mirror what your site shows, the new default is what you wanted and there is nothing to do. If you export to take a backup or migrate a tenant, add publishedOnly=false and make sure the key holds read:preview — otherwise the backup silently loses every unpublished item. publishedOnly=false without read:preview is a 403 carrying the same message the standalone and Section reads return; the split is per key, so a tenant can hand its public website a read:content key that cannot see unreleased work and its backup job a read:content + read:preview key that can. Holding read:preview does not change the default: the parameter does, so a preview-scoped key still reads like production unless it asks for drafts. All three item shapes obey the flag — standalone, level1..4 on either content platform, and the Section-native node items. Under publishedOnly=true an item is included only when at least one of its locales is published and it passes the visibility filter — its visibilityTags contains public, or the field is absent entirely, which legacy content predating visibility tagging relies on and which every read on this API treats as public. Only an explicit list without public — including an empty one — is excluded, so do not treat a returned item as invalid because it carries no visibilityTags. And, for level1..4 and node items, it is included only only when every ancestor passes that same test, because a published article under a draft category is not publicly reachable and the Section tree does not serve it either. A consequence worth planning for: pruning an ancestor prunes its whole subtree, so a published item can disappear from the export because something above it was unpublished. totalItems plus the pagination counts describe what you receive rather than what was filtered out. Pruning never leaves a parentExternalId pointing at nothing: across a complete export — every page, no type filter, imported in page order — every parentExternalId resolves to an item the export contains, so a pruned backup still restores cleanly. That guarantee is about pruning only, and it is not page-local. A type-filtered export names parents it does not contain, because you asked for a slice: type=level2 has always referenced the level1 items it omits. Pagination can likewise separate a parent from its child: the export does emit parents before children — standalone, then level1 through level4, then node items in parent-first order — but a page boundary can still fall between the two, so a single page taken on its own may carry a child whose parent arrived on an earlier one. Import the pages in order and that resolves. Both limits were true before this change and are unchanged by it. One operational consequence of guaranteeing that: if a datastore read fails part-way through enumerating your content, the request now returns an error instead of a 200 carrying whatever had loaded. That applies on both publishedOnly settings. A partial export is indistinguishable from a tenant that genuinely holds less, and restoring from one loses content silently, so a backup job that previously recorded a success it should not have will now see a failure it can retry. Under publishedOnly=false nothing is filtered — drafts and hidden items are both included, exactly as the export behaved before — so a preview-scoped key takes the same full backup it always did, and that payload still POSTs back to /v1/import verbatim. One thing the flag did not change, at this release: the exported body was the item's working translation, so a published item edited since it was last published exported the edited text. That was superseded the same day — see the entry above, which scopes bodies to the published revision on publishedOnly=true and describes what changes for backup consumers. The type, page and pageSize parameters and the item shape are unchanged.
GET /v1/content/by-slug/{slug} and the node teasers in GET /v1/surfaces/{key} now serve published metadata on a publishedOnly read. Both read title, summary and coverImageUrl straight off the mutable content document, which every write updates regardless of publish state — so an item renamed but not republished returned its unpublished title (and summary/cover) while GET /v1/sections/{key}/tree and GET /v1/sections/{key}/nodes/{id} returned the published one for the same item at the same moment. All three fields now come from the same publish-scoped translation those Section routes already use, and they move together: a published translation that carries no summary or cover now returns null rather than falling back to the draft value, and an item whose content has no published translation at all (an interrupted publish, or legacy data) returns null rather than the draft. Preview reads (publishedOnly=false with the read:preview scope) are unchanged and still show drafts. Two related changes: updatedAt on both routes now reports the newer of the item's own timestamp and its content's, so a title-only republish — which touches neither the item document nor the surface — is visible instead of looking unmodified; and for the same reason the surface ETag now covers the resolved teaser metadata, so cached surface ETags are invalidated once by this release and a revalidating client no longer 304s past a republished title. Node teasers also now carry summary, populated from the published translation; the field was already part of the surface item shape and was previously always null for node entries. No request or response field was added, removed or renamed.
Standalone content SEO is now scoped to publish state and tenant. GET /v1/standalone/{id} (and its /posts and /standalone-content aliases) previously fell back to the draft translation's stored seoMetadata when the requested locale had no published translation — so a publishedOnly=true request made with a key holding only read:content could receive unpublished metaDescription, focusKeywords, suggestedSlug and structuredData. A published read now reads only published translations. The integrator-visible effect: for content published in a locale that has no published translation document (a legacy shape), seoMetadata on a publishedOnly=true response now carries generated values derived from the item's title and summary instead of the draft's authored values; the field is still present and its shape is unchanged. To keep receiving authored draft SEO, request publishedOnly=false with a key holding read:preview, or publish the locale's translation. Preview reads are unchanged.
Structured content is served from the section/node model for every tenant; the legacy four-level storage behind it is retired. Nothing about the API changes with this: request and response shapes, the level1..4 write routes, bulk import/export, slug lookup, menus and Sections all behave exactly as before, because the last tenants moved to the new model at the 2026-07-17 cutover and this removes the code path none of them was taking. Two clarifications that were previously ambiguous. Re-parenting an item through an update is refused on every write surface, and there is no endpoint that moves an item to a different parent; what differs is how the refusal reaches you. PUT /level2..4/{id} returns 400 reparent_unsupported for a non-null parentId that differs from the item's current one, while omitting parentId — or sending it as null — means leave the parent alone and returns 200. PUT /sections/{key}/nodes/{id} returns the same 400 for any parentId it is actually sent that differs, an explicit null included, since on a child that reads as a move to the Section root. Bulk import reports it differently: under onConflict update the row comes back inside a 200 response as a failed action, so branch on the row rather than the status code, and note that the row's error is the human-readable message and carries no machine-readable code to match on (a node already at the Section root is unaffected, its current parent being null already); under skip an existing row is skipped before the parent is looked at; and under fail the whole request aborts with 409 before any row is processed. An earlier note here pointed at "the dedicated node routes" for moves, which were never built. To place an item under a different parent: a leaf can be re-created in the new position and the original deleted, but an item with children must have those deleted first, leaf-first, because the delete is refused while they exist and no surface can move them aside. So moving a subtree takes two passes in opposite directions: build the new one top-down, since every create needs a parent that already exists, then delete the old one leaf-first. Give the replacements fresh externalId and slug values while you do it — the originals still exist during the build, so reusing an externalId resolves to the original and turns the create into an update that refuses the new parent, and reusing an explicit slug returns slug_conflict; reassign the real values once the originals are gone. And a non-cascade delete of an item that still has children returns 409 rather than orphaning them, which is now the behaviour everywhere rather than only on the new platform.
GET /v1/export now returns a content body that POST /v1/import can actually read back, for standalone and level1..4 items. Those items paired contentFormat "json" with the plain-text rendering of the body, so re-importing a content-bearing item failed conversion on that row with "Invalid JSON content" — the documented round trip worked only for items with no body, and for the node items added on 2026-07-26. The content field now carries the serialized document that contentFormat describes, so a restore reconstructs it byte-for-byte. If you read the export rather than restore it, nothing you were using has moved: contentJson and contentHtml still carry the structured and rendered forms exactly as before, and every other field is unchanged. If you parsed content expecting prose, read contentHtml (rendered) or keep using the plain text you were reading there before by rendering contentJson — content is the field the import consumes, and it is now labelled honestly. Publication state is still not restored by an import: everything arrives as a draft, so plan a republish step into any migration.
PUT /v1/level1..4 now enforces externalId uniqueness on update: assigning an externalId already held by another item at the same level returns 409 external_id_conflict and writes nothing, matching the upsert-key contract those routes already advertise. Previously only the create path checked this — an update could silently assign a duplicate externalId, after which the next create-by-externalId upsert picked between the two items arbitrarily. Re-sending an item's own unchanged externalId is unaffected and still succeeds. This mirrors the same 409 the Section node routes (POST/PUT /v1/sections/{key}/nodes) already return for the identical case.
Export any Section shape — GET /v1/export now emits Section-native content as items typed "node", carrying sectionKey plus nodeTypeKey, alongside the existing standalone and level1..4 items. Until now the export enumerated only the four-level level1..4 ladder, so a category, an article, or any tenant-authored node type had no slot in the schema and was omitted silently with a 200: a tenant using a branching, flat, deeper-than-four, or tenant-authored Section had no backup path, and the import generalization shipped earlier today was one-way. The type filter gains node; omitting type returns everything, and the standalone and level1..4 items are unchanged. Node items are emitted parents-before-children, because parentExternalId resolves against nodes created earlier in the same import request, and each node appears exactly once — an export naming one node twice would be refused wholesale with 400 duplicate_item_address. parentId is deliberately null on a node item: it out-ranks parentExternalId on import and the exported value is a source-tenant id that names nothing in the target, so parentExternalId is the portable linkage. Two long-standing defects that made the advertised round trip fail for every item type are fixed with it: an export item can now be POSTed to /v1/import verbatim (the response-only fields id, contentJson, contentHtml, isPublished, createdAt and updatedAt are ignored on input instead of rejecting the request), and a node item's content is the serialized document matching its contentFormat, so re-import reconstructs the body rather than failing to parse it. Unknown fields are still rejected, so a misspelled field name is still an error. A node item also carries parentNodeTypeKey, naming the parent's node type: when a template lets two node types hold the same child, one externalId can name a node of each — both legal, since identity is per node type — and a bare parentExternalId would be ambiguous, so the child would fail to import. You may send it on your own node items; omitting it leaves resolution exactly as before. Every node item carries an externalId: it is optional when you create a node and CMS-authored content has none, but import requires one, so a node without one falls back to its Mentra document id, used consistently for its own externalId and its children's parentExternalId — restoring into an empty or different tenant works as intended and repeats idempotently, though re-importing into the tenant you exported from creates a second node rather than updating the original, which never carried that id. Exported seoMetadata is limited to the fields the write API accepts (metaTitle, metaDescription, focusKeyword, focusKeywords, slug, canonicalUrl, readingTime); SEO derivatives generated in the CMS (suggestedSlug, structuredData, openGraph, twitterCard) are omitted because the write API rejects them and including them would fail the whole import request. If two nodes in one Section and node type share a stored externalId, which older data allows because that uniqueness is not enforced by the datastore, both are still exported and the second is given a distinct -2 address so the backup stays importable. Three limitations to plan around: a node item carries one locale, so a multi-locale node exports its original-locale body only; restoring into a new tenant needs the Section to exist first, since a node item addresses a Section by key rather than creating one; and publication state is not restored — the export reports isPublished, but the write API has no publish capability, so everything imports as a draft (true of standalone and level1..4 imports too, and always has been). Because Section delivery defaults to publishedOnly, restored content stays off the public tree until it is republished in the CMS, so plan a republish step into any migration. Note also that the verbatim round trip currently applies to node items: standalone and level1..4 exports still pair contentFormat json with the plain-text rendering, so a content-bearing item of those types fails conversion on re-import until that pairing is corrected.
Bulk-import into any Section shape. POST /v1/import items may now be typed "node" and carry a sectionKey plus a nodeTypeKey from that Section's template, alongside the existing standalone and level1..4 types. Previously every structured item resolved onto the legacy four-level ladder, so a Section built on a branching, flat, deeper-than-four, or tenant-authored template could be read and written one item at a time but had no batch path in. Node items nest to whatever depth the template permits; parentExternalId resolves within the item's own Section, searching only the node types that template says may parent the child, so a placement the template forbids is reported as a per-item error before anything is written (dryRun reports the same verdicts a live run does). externalId remains the upsert key, scoped per Section and node type — the same externalId may name a node in two Sections without collision, and a result now echoes sectionKey and nodeTypeKey so it correlates back to its request item. Because that scope is the node's identity, two "node" items in one request sharing sectionKey, nodeTypeKey and externalId are rejected up front with 400 duplicate_item_address and nothing is written: under any onConflict strategy one of the two rows would otherwise be silently discarded, so a bad id mapping is reported rather than half-applied. Re-sending the same externalId in a later request is the intended idempotent path and is unaffected. sectionKey/nodeTypeKey are accepted only on a "node" item: sending them on a level1..4 item returns 400 rather than silently importing into the default legacy four-level Section. The standalone and level1..4 request shapes are unchanged and keep working. (At the time this shipped GET /v1/export still mirrored only the level1..4 + standalone item shape, so a node from a Section other than the legacy four-level one could not be round-tripped; export gained node items later the same day — see the entry above.)
Write structured content into any Section shape: POST /v1/sections/{key}/nodes plus PUT and DELETE on /v1/sections/{key}/nodes/{id} create, update, and remove a node by naming its Section key and its node type from that Section's template. Previously the only structured write path was POST /v1/level1..4, which maps onto the legacy four-level ladder and nothing else — so a Section built on a branching template (Knowledge Base), a flat one (Pages), or a tenant-authored template had no write path even though it could already be read via GET /v1/sections/{key}/tree. The new routes place nodes at whatever depth the template permits (no four-level cap), reject placements the template forbids before writing, and treat externalId as an upsert key per Section and node type (an update that would duplicate another node's key returns 409). Only node types flagged content_bearing accept a body: sending content, keywords, or seoMetadata to a container type returns 400 rather than silently dropping it, while title, summary, and coverImageUrl are always accepted so container nodes stay writable. The level1..4 routes are unchanged and keep working. (At the time this shipped GET /v1/export could not represent a node from a Section other than the legacy four-level one and omitted those records, so there was no backup path for such a Section. That gap is now closed — GET /v1/export emits node items; see the 2026-07-26 export entry above.)
Read a structured node's full content body: GET /v1/sections/{key}/nodes/{id} returns the rendered content (HTML/JSON/plain text), SEO + schema.org metadata, ancestor breadcrumbs, and the same include= extras as the standalone detail (quiz, scenarios, mnemonics, video, sources) plus include=children for container nodes. Start from a sectionKey + node id or from a by-slug hit's sectionKey. Relatedly, by-slug on a tenant without structured content now resolves only readable (standalone) content instead of returning legacy level hits that had no read path.
Structured content now reads through a single surface. The per-level read endpoints (GET /v1/level1..4 and their aliases) have been retired; fetch a tenant's structured content as a group via GET /v1/sections and GET /v1/sections/{key}/tree instead. Standalone-content endpoints and the hierarchy write endpoints are unaffected.
Placements can now teaser a structured entry node — such as a Knowledge Base Category — not only standalone articles. A resolved surface item (GET /v1/surfaces/{key}) carries a targetKind of "standalone" or "node"; a node teaser additionally carries a sectionKey so your site can deep-link into the Section the entry node belongs to. Existing standalone placements are unaffected (targetKind defaults to "standalone"); the item shape is otherwise unchanged.
Sections — fetch a named structured-content group as a group: GET /v1/sections lists a tenant's Sections by stable key with their template vocabulary (two same-template Sections are now distinguishable), and GET /v1/sections/{key}/tree returns the whole ordered node tree in one call, each node carrying its per-locale slug, filtered to the requested locale's published, public content. Both payloads echo the Section's advisory basePath "lives at" hint for display — never resolution truth; the key and node slugs stay authoritative.
Structured content now serves from the generalized section/node model for tenants on the new content platform. Request/response shapes are unchanged across the read, write, bulk-import, bulk-delete, slug-lookup, menus, and export endpoints — content is simply sourced from the new model, and read/slug/menus/export behavior is identical. Writes on the new platform add three stricter guards: re-parenting an item via update is refused — 400 on the direct write routes, and a failed row inside a 200 on bulk import; see the 2026-08-10 entry for the per-surface detail — a non-cascade delete of an item that still has children returns 409 rather than orphaning them, and slugs are unique per-tenant across all levels (a cross-level clash returns 409). Clients not on the new platform are unaffected.
Placements — render a curated content slot by key: GET /v1/surfaces/{key} returns the surface's currently-active placements as ordered content summaries (scheduling windows evaluated server-side), and GET /v1/surfaces lists the tenant's surface keys for discovery.
Curate by tag — standalone content now returns its editorial tags, and the list/summary endpoints accept a tag= filter (e.g. tag=page:forside) for surface curation.
Show your sources — the standalone detail endpoint can now return the research citations (title + URL) behind a piece via include=sources.
SEO-friendly content discovery — look up any content item by its human-readable slug instead of internal IDs.
Safer imports and full data portability — preview import results before committing, export your entire content library, and batch-delete content in bulk.
Cleaner content output — HTML responses are now pure semantic markup, giving you full control over styling in your own frontend.
Full read-write API and media management — create, update, and delete content programmatically. Upload and manage images with automatic CDN delivery and on-the-fly optimization.
Flexible hierarchy naming — access content using generic level-based paths that match your tenant's custom labels.
Smarter content queries — filter and selectively include fields to fetch exactly the data you need.
Ready to Get Started?
Create your free account and get your API key in under 2 minutes. Start building amazing learning experiences today.