Public API v1

Mentra API Documentation

Programmatic access to structured learning content via our headless CMS. Build your own learning platforms, mobile apps, or content integrations.

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:content scoped 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 externalId and 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:

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

  1. Sign up or log in to Mentra
  2. Navigate to the API Keys page
  3. Create a new API key with the appropriate scopes
  4. Copy the key immediately! It is only shown once.
    ⚠️ Important: The key format is mn_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.

read:content
read:preview
read:analytics
write:content
write:media

Scopes Reference

The table below lists every scope, what it grants, and which endpoint groups require it.

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

Current environment
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

ParameterTypeDefaultDescription
pageinteger11-based page index. Must be ≥ 1.
pageSizeinteger20Items 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.

nb-NO
Norwegian Bokmål
en
English
en-US
English (US)
en-GB
English (UK)
en-AU
English (Australia)
en-CA
English (Canada)
nn-NO
Norwegian Nynorsk
sv-SE
Swedish (Sweden)
da-DK
Danish (Denmark)
fi-FI
Finnish (Finland)
is-IS
Icelandic (Iceland)
de-DE
German (Germany)
fr-FR
French (France)
es-ES
Spanish (Spain)
it-IT
Italian (Italy)
pt-PT
Portuguese (Portugal)
nl-NL
Dutch (Netherlands)
de-AT
German (Austria)
de-CH
German (Switzerland)
fr-BE
French (Belgium)
fr-CH
French (Switzerland)
fr-CA
French (Canada)
es-MX
Spanish (Mexico)
pt-BR
Portuguese (Brazil)
pl-PL
Polish (Poland)
ja-JP
Japanese (Japan)
zh-CN
Chinese Simplified (China)
ko-KR
Korean (South Korea)
ar-SA
Arabic (Saudi Arabia)
hi-IN
Hindi (India)

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.

GET
/standalone

Returns 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
GET
/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=layout

Structured Content — Sections

Fetch a named structured-content group by its stable key. This replaces the retired per-level read routes.

GET
/sections

Discovery — 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"] }
        ]
      }
    }
  ]
}
GET
/sections/{key}/tree

Returns 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
GET
/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,sources

Pages

List your published page-builder pages, or fetch one by its slug, ready to render on your site.

GET
/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 422
publishedOnly (optional) - Default true; false returns the working copy and requires read:preview; its data-backed blocks then show unpublished articles and edits too
https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/pages/about-us?locale=en-US
GET
/pages

Lists 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 nextPage
publishedOnly (optional) - Default true; false lists working copies at their working slugs, drafts included, and requires read:preview
https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/pages?page=1&pageSize=20

Write Endpoints
write:content

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.

POST
/standalone

Create 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 title
locale (optional, default: en) - Content language
content (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 strings
keywords (optional) - Array of keyword strings
summary (optional) - Short description
coverImageUrl (optional) - Cover image URL
slug (optional) - URL slug. Generated from the title when omitted.
visibilityTags (optional) - Array of audience tags
seoMetadata (optional) - SEO overrides
POST /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 title
parentId (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 language
content, contentFormat, summary, coverImageUrl, keywords, externalId, slug, visibilityTags, seoMetadata - as above
order (optional) - Sort position within the parent
No tags. 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.
PUT
/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}.

DELETE
/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.

POST
/sections/{sectionKey}/nodes

Creates 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 template
title (required)
parentId (optional) - Omit for a Section-root node
externalId (optional) - Upsert key, unique per Section + node type
content, 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"
}'
PUT
/sections/{sectionKey}/nodes/{nodeId}

Partial update. A node's type is immutable and moves are not supported: a changed parentId returns 400 reparent_unsupported.

DELETE
/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.

POST
/import

Conflict 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 409
curl -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 created
would_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

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

Transform Parameters

w-{N} — Width in pixels
h-{N} — Height in pixels
c-at_max — Crop mode: fit within bounds
fo-auto — Smart focus (auto-detect subject)
f-webp / f-auto — Force WebP or auto-negotiate best format
q-{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.

POST
/media/upload-url

Request Body

sourceUrl (required) - Public HTTP/HTTPS URL of the image to download
filename (optional) - Override the filename (auto-detected from URL if omitted)
alt (optional) - Alt text for accessibility
tags (optional) - Array of tag strings for categorization
curl -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.

POST
/media/upload

Form Fields

file (required) - Binary image file
alt (optional) - Alt text for accessibility
tags (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.

GET
/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 optimization

List Media Assets

List all media assets for your tenant with pagination and optional tag filtering. Each item includes a cdnUrl for optimized delivery.

GET
/media

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

DELETE
/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

read:content

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 content

Paths 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/activeUntil is served only while activeFrom <= 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. manual serves the editor's hand-curated order; newest serves pinned placements first, then the rest by content recency.
  • maxItems is 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, so If-Modified-Since is ignored by the endpoint.
  • Subscribe to the surface.updated webhook for push-based invalidation: it fires on every surface mutation and its payload carries the surfaceKey.
  • surface.updated alone is not sufficient invalidation. Publishing or unpublishing a placed item changes the surface response but emits content.published / content.unpublished instead — 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-style revalidate) as the safety net for those.
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.surfaceKey

Content 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

read:content
GET
/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

slugByLocale.{requested locale}slug (legacy field)404
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, …
}

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.

GET
/export

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

  1. Export from source tenant with GET /export
  2. Validate with POST /import using dryRun: true
  3. 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.

POST
/bulk-delete

Request Body

items (required) — Array of items to delete (max 100)
type — Content type:standalone or node
id — Firestore document ID (one of id or externalId required)
externalId — External ID lookup
sectionKey — 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 type
cascade (optional, default: false) — For a node item, also delete every descendant node
dryRun (optional, default: false) — Validate without deleting
curl -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
    }
  ]
}

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 S3
write:content
write: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/
    └── ...
Content Files

.md, .html, or .json files become leaf nodes (lessons, by default)

Media Assets

Images uploaded via presigned URL → Media API → coverImageUrl

Metadata

meta.json in each folder provides title, summary, keywords

Prerequisites

pip install boto3 requests
• Mentra API Key with write:content and write:media scopes
• AWS credentials configured (via ~/.aws/credentials, environment variables, or IAM role)
• S3 bucket with read access to your content files

Complete Import Script

"""
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 coverImageUrl

Import from GitHub
write: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
Content Convention

_index.md in each folder provides title/summary for that level. Other .md files become leaf nodes (lessons, by default).

Media References

Images can reference GitHub raw URLs or be uploaded via the Media API using /media/upload-url

Prerequisites

pip install requests
• Mentra API Key with write:content scope (add write:media if uploading images)
• GitHub Personal Access Token (for private repos) or public repo URL
• Git installed (the script clones the repo locally)

Complete Import Script

"""
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

  1. Run with DRY_RUN = True to validate structure and preview actions
  2. Review the output — check that titles and hierarchy look correct
  3. Fix any folder naming or _index.md frontmatter issues in your repo
  4. Set DRY_RUN = False and run again to import
  5. 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 left
  • center — Centered block
  • right — Float right
data-size Values
  • small — 25% width
  • medium — 50% width
  • large — 75% width
  • xl — 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 TypeHTML EquivalentAttributesHas 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 TypeHTML EquivalentAttributes
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"
  }
}
AttributeTypeRequiredDescription
srcstring✅CDN image URL (already optimized)
altstring✅Alt text for accessibility
captionstring | nullNoIf present, render as <figure> + <figcaption>
sizestring | nullNoLayout hint: small (25%), medium (50%), large (75%), xl (100%)
alignmentstring | nullNoLayout 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
Official

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+json script tags. Pass jsonLd={false} to render it yourself.
  • rich_text HTML 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 as null too; your own image components can still use the exported safeImageSrc as a second check.
  • video and embed play only resolved: a provider player is an iframe with src, sandbox, allow and referrerPolicy copied from it unchanged, never built from content.url; a video file plays in <video>. If your site sends a Content-Security-Policy, its frame-src must allow https://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-bound columns block 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:

Automatic Tracking

Interactions with the Mentra API are tracked automatically. You don't need to do anything extra.

  • Quiz Submissions
  • AI Content Generation
  • Mnemonic Creation
Manual View Tracking

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.

POST
/telemetry/public/track
curl -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

AI Generated

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.

POST
/api/v1/adaptive/quiz/submit

Submit 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"]
  }
]
}
POST
/api/v1/adaptive/remediate

Generate 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

content.published

Triggered when any content item (a Section node such as a lesson or article, or standalone content) is published.

content.unpublished

Triggered when content is unpublished.

surface.updated

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:

HeaderDescription
X-Mentra-SignatureHMAC-SHA256 signature of the request body (hex digest), signed with your webhook secret. Omitted if the webhook has no secret configured.
X-Mentra-EventThe event type (e.g., content.published).
X-Mentra-DeliveryThe 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-AgentMentra-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:

EndpointLive rundryRun: true
POST /importcreated, updated, skipped, failedwould_create, would_update, would_skip, failed
POST /bulk-deletedeleted, not_found, failedwould_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

StatusMeaningNotes
200
OKSuccess, including updates and upserts. Bulk endpoints also use it for partial failure — check each row.
201
CreatedA new item was created. A POST that upserted an existing one returns 200 instead.
304
Not ModifiedYour cached copy is current.
400
Bad RequestSomething in the request is invalid. The code says what.
401
UnauthorizedMissing or invalid API key — usually a truncated key, a key from the wrong environment, or the wrong header name.
403
ForbiddenEither 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 FoundNo such item, or it isn't published or visible to this key.
409
ConflictThe write collides with existing data — a taken slug or externalId, or a delete refused because children exist.
413
Payload Too LargeThe 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 EntityThe body did not match the endpoint's schema (FastAPI's own validation, ahead of ours).
429
Too Many RequestsBack off and retry with exponential delay.
5xx
Server ErrorOur 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

CodeHTTPMeansWhat 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

CodeHTTPMeansWhat 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

CodeHTTPMeansWhat 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

CodeHTTPMeansWhat 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

CodeHTTPMeansWhat 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

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

LimitValueApplies 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-After header 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

2026-10-21
Feature

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.

2026-10-20
Feature

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.

2026-10-20
Feature

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.

2026-10-20
Feature

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.

2026-10-19
Change

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.

2026-10-06
removal

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.

2026-10-06
fix

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.

2026-08-25
removal

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.

2026-08-10
Change

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.

2026-08-10
Change

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.

2026-08-10
Change

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.

2026-08-10
fix

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.

2026-08-10
fix

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.

2026-08-10
Change

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.

2026-07-30
fix

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.

2026-07-27
Change

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.

2026-07-26
Feature

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.

2026-07-26
Feature

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

2026-07-25
Feature

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

2026-07-21
Feature

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.

2026-07-21
Change

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.

2026-07-19
Feature

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.

2026-07-18
Feature

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.

2026-07-17
Change

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.

2026-07-17
Feature

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.

2026-07-16
Feature

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.

2026-07-15
Feature

Show your sources — the standalone detail endpoint can now return the research citations (title + URL) behind a piece via include=sources.

2026-03-16
Feature

SEO-friendly content discovery — look up any content item by its human-readable slug instead of internal IDs.

2025-02-27
Feature

Safer imports and full data portability — preview import results before committing, export your entire content library, and batch-delete content in bulk.

2025-02-26
Change

Cleaner content output — HTML responses are now pure semantic markup, giving you full control over styling in your own frontend.

2025-02-24
Feature

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.

2024-05-20
Feature

Flexible hierarchy naming — access content using generic level-based paths that match your tenant's custom labels.

2024-05-15
Feature

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.

We'd like to use analytics cookies to understand how Mentra is used, so we can improve it. They're optional — declining changes nothing about how the product works. Read our privacy policy.