# Mentra Public API v1 — Full Documentation > Plain-text dump of the full public API docs, intended for LLMs and AI coding assistants. The human-facing HTML version lives at https://www.mentra.systems/public-api-docs. - API base URL: `https://api.mentra.systems/api/v1` - OpenAPI spec: https://api.mentra.systems/api/v1/openapi.json - Changelog: https://api.mentra.systems/api/v1/changelog - llms.txt: https://www.mentra.systems/llms.txt --- # Introduction The **Mentra Public API v1** is a RESTful API providing read and write access to learning content. Content is organized in a hierarchical structure (Level 1 → Level 2 → Level 3 → Level 4), plus standalone content items. Tenants can configure custom labels for each hierarchy level. ## Key Features - **Multi-language support** — content available in multiple locales (en-US, nb-NO) - **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 your content in bulk-import-compatible format for backups, tenant duplication, and round-trip migrations (published content by default; add `publishedOnly=false` with a `read:preview` key to include drafts) - **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) - **Surfaces (Placements)** — editor-curated content slots resolved server-side: `GET /surfaces/{key}` returns the currently-active, ordered items for a named slot on your site. See [Surfaces (Placements)](#surfaces-placements). - **Webhooks** — real-time notifications for content updates and surface changes - **GEO discoverability** — Mentra emits JSON-LD schemas (Article + on the way: FAQPage / HowTo / DefinedTerm) and a tenant-level `llms.txt` so answer engines like ChatGPT and Perplexity can cite your content. See the [GEO Integration Guide](#geo-integration-guide). - **OpenAPI Specification** — full machine-readable spec at `https://api.mentra.systems/api/v1/openapi.json` --- # Authentication All content and data endpoints require authentication via **API Keys**. Provide the key using either the standard `Authorization` header or the custom `X-Api-Key` header. Two kinds of endpoints in this guide are keyless, for different reasons: - **Intentionally public discovery/health/content endpoints** — `GET /health`, `GET /openapi.json`, and `GET /changelog` (see [Changelog](#changelog)) take no key by design, the same way most REST APIs leave health checks and spec discovery open. `GET /api/public/{tenant_id}/llms.txt` and `GET /api/public/{tenant_id}/llms-full.txt` (see the [GEO Integration Guide](#geo-integration-guide)'s "Tenant-level discoverability" section) are also deliberately anonymous — they're built for LLM/answer-engine crawlers, which don't carry API keys. - **A tracked gap, not a stable contract** — `POST /telemetry/public/track` (see [Telemetry](#how-to-track-views)) currently accepts calls with no key at all, but this is scheduled to be closed. Don't build an integration that depends on it staying keyless. ## How to Get an API Key 1. Sign up or log in at 2. Navigate to the API Keys page in the CMS 3. Create a new API key with the appropriate scopes 4. Copy the key immediately — it is only shown once. API keys have the format `mn_live_PREFIX.SECRET`. Make sure to copy the entire string, including the dot and the long secret part. ## Authentication Header Format Two equivalent options: ``` Authorization: Bearer mn_live_... ``` ``` X-Api-Key: mn_live_... ``` ## Scopes Reference Each API key is granted one or more scopes that control what it can access. | Scope | Description | Endpoint Groups | |------------------|-------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------| | `read:content` | Read access to **published** structured and standalone content. | Sections (read), Standalone List/Get, Slug Lookup, Content Export | | `read:preview` | Access to **draft / unpublished** content. Only checked when `publishedOnly=false`. | Same as `read:content` — additive modifier | | `read:analytics` | Access to usage analytics and learner metrics (future). | Analytics endpoints | | `write:content` | Create, update, and delete content (standalone + hierarchy). Also Bulk Import/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 | | `auth:headless` | Mint JWT tokens for end-users in headless authentication flows. | Headless Token Mint | **Tip:** Grant only the scopes your integration needs. A read-only frontend should only use `read:content`. Add `read:preview` only for staging or preview environments. --- # Base URL All API requests are made to the following base URL: ``` https://api.mentra.systems/api/v1 ``` Every endpoint path in this document is relative to that base URL. --- # Quick Start Get started in 60 seconds with these ready-to-use examples. Replace `YOUR_API_KEY` with your own `mn_live_...` key. ## cURL (Terminal) Health check: ```bash curl -H "Authorization: Bearer YOUR_API_KEY" \ "https://api.mentra.systems/api/v1/health" ``` Discover your structured content — list Sections by key: ```bash curl -H "Authorization: Bearer YOUR_API_KEY" \ "https://api.mentra.systems/api/v1/sections" ``` Fetch a Section's full node tree (Norwegian): ```bash curl -H "Authorization: Bearer YOUR_API_KEY" \ "https://api.mentra.systems/api/v1/sections/YOUR_SECTION_KEY/tree?locale=nb-NO" ``` ## JavaScript / TypeScript (fetch) ```javascript const BASE_URL = "https://api.mentra.systems/api/v1"; const API_KEY = "YOUR_API_KEY"; // Fetch a Section's ordered node tree for a locale const response = await fetch( BASE_URL + "/sections/YOUR_SECTION_KEY/tree?locale=nb-NO", { headers: { "Authorization": "Bearer " + API_KEY } } ); const data = await response.json(); ``` ## Python (requests) ```python import requests BASE_URL = "https://api.mentra.systems/api/v1" API_KEY = "YOUR_API_KEY" # Get full content for a specific standalone article response = requests.get( f"{BASE_URL}/standalone/CONTENT_ID", params={"locale": "en-US"}, headers={"Authorization": f"Bearer {API_KEY}"} ) if response.status_code == 200: content = response.json() print(content["title"]) print(content["contentHtml"]) # Full HTML content print(content["seoMetadata"]) # SEO / GEO metadata ``` --- # Pagination All list endpoints return paginated results. Use the `page` and `pageSize` query parameters to navigate through results. ## Query Parameters | Parameter | Type | Default | Description | |------------|---------|---------|--------------------------------------------| | `page` | integer | `1` | 1-based page index. Must be ≥ 1. | | `pageSize` | integer | `20` | Items per page. Range: 1–100. | ## Response Envelope Every list response includes the following pagination metadata alongside the `items` array: ```json { "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 ```python import requests API_KEY = "mn_live_..." BASE = "https://api.mentra.systems/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 (`GET /export`) — up to 100 items per page — rather than paginating through individual list endpoints. Like the list endpoints it defaults to `publishedOnly=true`; add `publishedOnly=false` (with a `read:preview` key) when you are backing up rather than listing what is published. ## Cursor Pagination (newer public endpoints) The v1 endpoints above use offset pagination. Some newer public endpoints outside `/api/v1/*` (for example, the marketing-facing `GET /api/insights/posts`) use **cursor-based pagination** instead. ### Query Parameters | Parameter | Type | Default | Description | |-----------|---------|---------|---------------------------------------------------------| | `limit` | integer | `50` | Items per page. Max `200`. | | `cursor` | string | — | Opaque token from a previous response's `next_cursor`. | ### Response Envelope ```json { "items": [ ... ], "next_cursor": "eyJkIjoiYWJjMTIzIn0" } ``` `next_cursor` is `null` on the final page. The token is base64url-encoded and opaque — treat it as a black box; don't try to decode or construct your own. ### Example: Iterating All Pages ```python import requests BASE = "https://api.mentra.systems" cursor = None all_items = [] while True: params = {"limit": 50} if cursor: params["cursor"] = cursor resp = requests.get(f"{BASE}/api/insights/posts", params=params) data = resp.json() all_items.extend(data["items"]) cursor = data.get("next_cursor") if not cursor: break print(f"Fetched {len(all_items)} items") ``` **Tip:** A malformed cursor is treated as a fresh request rather than rejected — safe to replay a stale bookmark without special-casing. --- # Locale Parameter The `locale` parameter is **REQUIRED** for all content endpoints. It determines which language version of the content to return. ## Supported Locales - `en-US` — English (United States) - `nb-NO` — Norwegian Bokmål (Norway) ## Format Locales follow the **BCP-47** standard: `language-region`. ## Examples Norwegian content: ``` https://api.mentra.systems/api/v1/sections/YOUR_SECTION_KEY/tree?locale=nb-NO ``` English content: ``` https://api.mentra.systems/api/v1/standalone/CONTENT_ID?locale=en-US ``` --- # API Endpoints All endpoints below are relative to `https://api.mentra.systems/api/v1`. ## Read Endpoints (`read:content`) Read endpoints come in two variants per resource: - **List**: `GET /{resource}` — returns metadata only (title, summary, keywords, slug, timestamps) for multiple items, paginated. - **Detail**: `GET /{resource}/{id}` — returns full content including HTML, plain text, media, SEO metadata, and AI-generated extensions. ### Common query parameters (list) - `locale` (required) — content language (`en-US`, `nb-NO`). - `publishedOnly` (optional, default `true`) — filter to published content only. - `page` (optional, default `1`) — 1-based page number. - `pageSize` (optional, default `20`, max `100`) — items per page. - `parentId` / `level{N-1}Id` (optional, Level 2–4 only) — filter by parent. ### Common query parameters (detail) - `locale` (required) — content language. - `include` (optional, Level 4 & Standalone only) — comma-separated list of extra fields. Use `include=layout` to get resolved page builder blocks. Standalone only: `include=sources` returns `researchSources`, the research citations (title + URL) saved on the piece — see `10-response-structure.md`. ### Resources **Standalone Content** — `/standalone` Independent content items not tied to the hierarchical structure. Perfect for blog posts, articles, or standalone lessons. ``` GET /standalone?locale=nb-NO&publishedOnly=true&page=1&pageSize=20 GET /standalone/{contentId}?locale=nb-NO GET /standalone/{contentId}?locale=nb-NO&include=sources GET /standalone?locale=nb-NO&tag=page:forside ``` Every standalone item (list, summary, and detail) carries a `tags` array — the manually curated editorial tags set in the CMS. Unlike `keywords` (which are AI-generated and regenerated whenever the summary is regenerated), `tags` are stable and safe to build integrations against. - `tag` (optional, standalone list/summary only) — exact-match filter that returns only items whose `tags` array contains the given value. Composable with `locale`, `updatedSince`, `publishedOnly`, and pagination. **The `page:` operational-tag convention.** Use a namespaced `page:` tag to curate which articles appear on a given surface of your site. For example, tag the articles you want on your landing page with `page:forside`, then fetch them with `GET /standalone?tag=page:forside`. The `page:` prefix keeps these operational placement tags visually distinct from ordinary topical tags. **For new integrations, prefer the dedicated Placements feature** — see [Surfaces (Placements)](#surfaces-placements) for `GET /surfaces/{key}`, which adds server-side ordering, scheduling windows, and item caps on top of what tag filtering offers. **Structured Content — Sections** — `/sections` Fetch a named structured-content group by its stable `key`. This replaces the retired per-level read routes (`/level1..4` and their `/lifeskills`, `/courses`, `/journeys`, `/steps` aliases). ``` GET /sections GET /sections/{key}/tree?locale=nb-NO&publishedOnly=true ``` `GET /sections` lists each Section by `key` with its label, template vocabulary, and advisory `basePath` hint. `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. **Structured Content (Sections)** — `/sections` The `level1..4` routes above project one level at a time in the fixed curriculum vocabulary. To fetch a whole named hierarchy **as a group** — any template, not just the four-level curriculum — use the Sections API: ``` GET /sections — discover the tenant's Sections + their templates GET /sections/{key}/tree?locale=nb-NO — one Section's whole ordered node tree ``` See [Sections (Structured Content)](#sections-structured-content) for request/ response shapes, the authoring-vs-delivery split, and how import populates a Section. For new integrations reading structured content, prefer `/sections`. ## Write Endpoints (`write:content`) Write endpoints require an API key with the `write:content` scope. ### Create Content ``` POST /standalone POST /level1 POST /level2 (requires parentId) POST /level3 (requires parentId) POST /level4 (requires parentId) ``` Supports Markdown, HTML, or TipTap JSON input with automatic format conversion. Returns `201 Created` for new items, or `200 OK` when an `externalId` matches an existing item (upsert). Example: ```bash curl -X POST "https://api.mentra.systems/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: - `title` (required) — content title. - `locale` (optional, default `en-US`) — content language. - `content` (optional) — content body (string or TipTap JSON). - `contentFormat` (optional) — one of `markdown`, `html`, `json`. Auto-converts to the internal format. - `externalId` (optional) — your system's unique ID. Enables idempotent upsert. - `tags` (optional) — array of tag strings. - `summary` (optional) — short description. - `coverImageUrl` (optional) — cover image URL. - `parentId` (required for Level 2–4) — parent item's Firestore ID. **Idempotent Upsert via `externalId`:** If you include an `externalId` and a document with that ID already exists for your tenant, the API **updates** the existing document instead of creating a duplicate. Response returns `200 OK` instead of `201 Created`. Recommended for CMS migrations from Contentful, Strapi, Sanity, or WordPress. ### Update Content ``` PUT /standalone/{id} PUT /level1/{id} | /level2/{id} | /level3/{id} | /level4/{id} ``` Supports **partial updates** — only provided fields are modified. ```bash curl -X PUT "https://api.mentra.systems/api/v1/standalone/CONTENT_ID" \ -H "X-Api-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "title": "Updated Title", "content": "

New Content

Updated via API.

", "contentFormat": "html" }' ``` **`externalId` uniqueness is enforced on update too.** Since `externalId` is the upsert key (see Create Content above), a `PUT /level1..4/{id}` that would assign an `externalId` already held by another item at the same level returns 409 `external_id_conflict` and writes nothing. Re-sending the item's own unchanged `externalId` is unaffected. This is the same rule the Section node routes (`PUT /sections/{key}/nodes/{id}`) already enforce. ### Delete Content ``` DELETE /standalone/{id} DELETE /level1/{id} | /level2/{id} | /level3/{id} | /level4/{id} ``` Permanently deletes a content item and all its translations. Hierarchy deletes do **not** cascade to children (use `/bulk-delete` with `cascade: true` for cascading deletes). ```bash curl -X DELETE "https://api.mentra.systems/api/v1/standalone/CONTENT_ID" \ -H "X-Api-Key: YOUR_API_KEY" ``` ### Bulk Import ``` POST /import ``` Import up to **100 items** in a single request. Supports mixed content types (standalone + hierarchy) with per-item error reporting. Conflict resolution strategies (`onConflict`): - `skip` — existing items are left unchanged. - `update` — existing items are updated with new data (upsert). - `fail` — if ANY item exists, the entire import is aborted with `409`. ```bash curl -X POST "https://api.mentra.systems/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": "level1", "externalId": "cat-101", "title": "Programming", "locale": "en-US" }, { "type": "level2", "externalId": "course-201", "title": "Python Basics", "parentExternalId": "cat-101", "locale": "en-US" } ], "onConflict": "update" }' ``` Response: ```json { "totalItems": 3, "created": 2, "updated": 1, "skipped": 0, "failed": 0, "results": [ { "index": 0, "status": "created", "id": "abc123", "externalId": "wp-post-1" }, { "index": 1, "status": "created", "id": "def456", "externalId": "cat-101" }, { "index": 2, "status": "updated", "id": "ghi789", "externalId": "course-201" } ] } ``` **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. **Structured content:** An item may address structured content two ways. `level1..4` maps onto the four-level Curriculum ladder and writes into the tenant's Curriculum Section. `type: "node"` names a Section by `sectionKey` and a node type by `nodeTypeKey` from that Section's template, so a branching, flat, deeper-than-four, or tenant-authored shape imports too: ```json { "type": "node", "sectionKey": "library", "nodeTypeKey": "article", "externalId": "kb-getting-started", "parentExternalId": "kb-basics", "title": "Getting started", "content": "# Getting started", "contentFormat": "markdown" } ``` For `node` items, `parentExternalId` resolves within the item's own Section across the node types the template allows as parents, `externalId` is an upsert key scoped per Section and node type, and a placement the template forbids fails that row before anything is written. `sectionKey` / `nodeTypeKey` are rejected on a `level1..4` item (400 `node_address_not_allowed`), and two `node` items addressing the same node in one request are rejected as a whole (400 `duplicate_item_address`). Read the results back via `GET /sections/{key}/tree`. See [Sections (Structured Content)](#sections-structured-content). **Dry-run mode:** Set `"dryRun": true` in the request body to validate your import without writing any data. Response actions become `would_create`, `would_update`, or `would_skip`. ### Migration Guide For CMS migrations: 1. Map your source IDs to `externalId` values so imports are idempotent. 2. Choose `contentFormat`: Markdown or HTML — Mentra auto-converts. 3. Use bulk import with `onConflict: "fail"` for initial imports (to catch duplicates) and `onConflict: "update"` for incremental syncs. 4. Build hierarchy top-down: parents before children — Level 1 then Level 2 for `level*` items, or root node type first for `node` items. See `09-migration-guides.md` for S3 and GitHub migration recipes. ## Media Endpoints (`write:media`) See `12-libraries.md` for the Media API, CDN transforms, and upload endpoints. ## Export and Bulk Delete See `08-export-and-bulk-delete.md`. ## Slug Lookup See `07-slug-lookup.md`. --- # Surfaces (Placements) A **Surface** is a named content slot on your site — a front-page teaser row, a pricing-page highlight strip, a campaign section — whose contents are curated by editors in Mentra. Each entry inside a surface is a **Placement**: a reference to one standalone content item, optionally time-boxed with an active window. The surface's `key` (e.g. `front-page-teasers`) is the contract between Mentra and your site. You wire a key into a page section **once**; from then on, editors control what appears in that slot — which items, in what order, for how long — entirely from the CMS. Every future curated section becomes configuration, not code. All curation logic is evaluated **server-side at read time**: the API returns only the currently-active, published, ordered items. Your site renders what it receives — no client-side filtering, window math, or sorting required. This supersedes the `page:` tag-convention stopgap described in the endpoints page. ## Endpoints Both endpoints require an API key with the `read:content` scope (see the Authentication page). ``` GET /surfaces — list the tenant's surface keys + labels (discovery) GET /surfaces/{key} — resolve a surface: its currently-active content ``` Like every endpoint in this document, paths are relative to the base URL `https://api.mentra.systems/api/v1` (see the Base URL page). ### `GET /surfaces` — discovery Returns every surface defined for your tenant, sorted by key. Useful for verifying which keys exist while wiring up an integration. ```json { "items": [ { "key": "front-page-teasers", "label": "Front page teasers" }, { "key": "pricing-highlights", "label": "Pricing page highlights" } ] } ``` ### `GET /surfaces/{key}` — resolve a surface Query parameters: - `locale` (optional) — content language (e.g. `en-US`, `nb-NO`). When provided, only items published in that locale are served, and item fields (title, summary, `publishedAt`, …) come from that locale's translation. Locale matching is BCP 47 prefix-aware: `?locale=nb` matches content published as `nb-NO`. Example: ```bash curl "https://api.mentra.systems/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 a standard **content summary** object — the same reduced shape returned by `GET /standalone/summary`, not the full detail object from `GET /standalone` (no body content, `media`, or `seoMetadata`): ```json { "key": "front-page-teasers", "label": "Front page teasers", "maxItems": 4, "ordering": "manual", "items": [ { "id": "a1b2c3d4e5f6", "externalId": null, "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", "keywords": ["radardata", "beslutningsstøtte"], "tags": ["innsikt"], "visibilityTags": null, "createdAt": "2026-06-02T09:14:00Z", "updatedAt": "2026-07-10T08:30:00Z", "publishedAt": "2026-07-10T08:31:12Z", "author": { "id": "auth_123", "name": "Kari Nordmann", "title": "Fagredaktør", "bio": null, "avatarUrl": "https://…/kari.webp" } } ] } ``` To render richer content than the summary provides (full body HTML, media, SEO metadata), 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. For teaser/card sections the summary is usually all you need. ## Resolution rules The `items` array is computed fresh on every (non-cached) request, in this order: 1. **Active window.** A placement may carry `activeFrom` / `activeUntil` timestamps set by the editor. It is served only while `activeFrom <= now < activeUntil` — the start is inclusive, the end is exclusive, and a missing bound is unbounded. Expired and not-yet-started placements are excluded at read time; there is no publish/unpublish job to wait for, so a window opening or closing takes effect on the next request. 2. **Published in the requested locale.** A placement is served only if its referenced content is published (in the requested `locale`, when given — prefix-aware as described above). Unpublished, deleted, and visibility-restricted content is silently skipped, never an error. 3. **Ordering.** `ordering: "manual"` serves the editor's hand-curated order. `ordering: "newest"` serves pinned placements first (in manual order), then the rest by content recency, newest first. 4. **`maxItems`.** Applied last, capping the surviving ordered list. Status codes: - **Unknown key → `404`** with an error envelope carrying the machine-readable code `surface_not_found`: ```json { "error": { "code": "surface_not_found", "message": "Surface not found", "request_id": "a1b2c3d4-…" } } ``` A key that could never exist (wrong characters — keys are lowercase slug-like, e.g. `front-page-teasers`) is treated the same as an unknown one. - **Known key with zero active items → `200`** with `"items": []`. The distinction is deliberate: a `404` means the slot isn't configured (a wiring problem to surface loudly), while an empty `items` array means the slot is configured but currently has nothing to show (render nothing, or a fallback, and move on). ## Caching and revalidation Responses carry `ETag` and `Last-Modified` headers. Revalidate with `If-None-Match` **only** — a `304 Not Modified` tells you your cached copy is still current. Do not use `If-Modified-Since` here: the resolved item set is time-windowed, so its effective "last modified" moment can move backwards when a placement drops out of the window; the endpoint therefore ignores `If-Modified-Since` and relies on the ETag, which changes whenever the resolved item set changes. For push-based cache invalidation, subscribe to the **`surface.updated` webhook** (see [Webhooks](#webhooks)). It fires on every surface mutation — settings updated, surface deleted, placement added, removed, or reordered — and its payload carries the `surfaceKey`, so a generic handler can revalidate exactly the page(s) rendering that surface. One caveat: `surface.updated` alone is not sufficient invalidation — it fires on *surface mutations* only. Publishing or unpublishing a placed item changes the surface response but emits `content.published` / `content.unpublished` instead, so revalidate on those too. And some changes emit no webhook at all: a scheduled placement 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 (e.g. a statically generated site), keep time-based revalidation (a modest TTL, or ISR-style `revalidate`) as the safety net for those. ## Recommendation: build the component generically Resist the temptation to build a `` component. Build one `` component that takes a key, and reuse it for every slot — then adding a new curated section to your site is a CMS action plus one line of markup, not a development task. ``` component Surface(key, locale): response = GET {base_url}/surfaces/{key}?locale={locale} with header X-Api-Key with cached ETag via If-None-Match if response is 404: # slot not configured — a wiring problem log warning; render nothing if response.items is empty: # configured but currently empty render nothing (or a fallback) render section: heading = response.label for item in response.items: # already filtered, ordered, capped render card(item.title, item.summary, item.coverImageUrl, link to /articles/{item.slug}) # Usage — every curated slot is one line: page "/" renders: Surface("front-page-teasers", locale) page "/pricing" renders: Surface("pricing-highlights", locale) # Webhook handler — one generic revalidation path for all surfaces: on webhook "surface.updated" (payload): revalidate pages rendering payload.data.surfaceKey ``` Keep the key-to-page mapping in your site's configuration (or derive pages from the webhook payload's `surfaceKey`) so neither side hardcodes the other. --- # Sections (Structured Content) A **Section** is a named, ordered tree of content — a curriculum, a knowledge base, a documentation set. Where a [Surface](#surfaces-placements) is a curated slot of *standalone* items, a Section is a whole hierarchy you fetch **as a group**: its shape (what nests under what, how deep) comes from a **template**, and every node in it carries a per-locale `slug` so your site can build URLs. The Section's `key` (e.g. `academy`) is the contract between Mentra and your site — stable, human-readable, and the handle you fetch by. Two Sections built on the same template stay distinguishable by their distinct `key` and `label`, which the older per-level projection couldn't express. All filtering is evaluated **server-side at read time**: the tree comes back already scoped to the requested locale's published, publicly-visible nodes, in position order. Your site renders what it receives — no client-side publish checks, locale matching, or sorting required. > **Relationship to the `/level1..4` (`/courses`, `/journeys`, …) endpoints.** > Their **read** routes (`GET /level1..4`) are retired — `/sections` replaces > them. Their **write** routes (`POST` / `PUT` / `DELETE /level1..4`) still > work and write into the same underlying content; see > [Write Endpoints](#write-endpoints-writecontent). The > write routes project **one level at a time** in a Memolife-named shape; > the Sections API is the generalized, template-driven **read** surface that > returns a **whole Section in one call** and is not tied to a fixed > four-level curriculum vocabulary. For new integrations, prefer `/sections` > for reads. ## Authoring vs. delivery — where Sections come from Sections have two halves, and this API is only the second one: - **Authoring (in the CMS).** A Section, its template (Curriculum, Knowledge Base, or a **tenant-authored custom template**), its node types, and its `basePath` hint are **created and configured by editors in the Mentra CMS**. You do not create Sections or node types over the public API — the template is what makes a Section's shape well-formed, and that setup is an editorial decision. Because templates can be tenant-authored, a Section's node-type keys, labels, and depth are **not a fixed set**: always read them from the `template` in the discovery response (below) rather than assuming the four-level curriculum or the Knowledge-Base vocabulary. - **Delivery (this API).** Once a Section exists, the public read endpoints below serve it to your site, and the public write/import endpoints (below) populate its nodes with content. ## Endpoints These read endpoints require an API key with the `read:content` scope (see the Authentication page). Fetching drafts (`publishedOnly=false`) additionally requires the `read:preview` scope. ``` GET /sections — list the tenant's Sections + their templates (discovery) GET /sections/{key}/tree — resolve a Section: its whole ordered node tree (navigation) GET /sections/{key}/nodes/{id} — one node's full content body (HTML/SEO/media/AI extensions) ``` The tree gives you **navigation** — titles, slugs, nesting — cheaply. To render an actual page, fetch that node's **body** with the node-detail endpoint below. It's the structured-content peer of `GET /standalone/{id}`: the tree is your menu, node-detail serves the dish. Like every endpoint in this document, paths are relative to the base URL `https://api.mentra.systems/api/v1` (see the Base URL page). Sections are gated on a per-tenant flag. A tenant that hasn't been moved onto the structured-content model has **no** Sections: discovery returns an empty `items` array (not an error), and any tree request returns `404`. This lets a site probe deterministically. ### `GET /sections` — discovery Returns every Section defined for your tenant, sorted by `key`, each with its template so you can learn the vocabulary (node-type labels), depth, and per-node-type capabilities up front. ```bash curl "https://api.mentra.systems/api/v1/sections" \ -H "X-Api-Key: YOUR_API_KEY" ``` ```json { "items": [ { "key": "academy", "label": "Academy", "templateId": "curriculum", "basePath": "/academy", "template": { "vocabulary": { "lifeskill": { "singular": "Life Skill", "plural": "Life Skills" }, "course": { "singular": "Course", "plural": "Courses" }, "journey": { "singular": "Journey", "plural": "Journeys" }, "step": { "singular": "Step", "plural": "Steps" } }, "depth": 4, "nodeTypes": [ { "key": "lifeskill", "singular": "Life Skill", "plural": "Life Skills", "capabilities": ["content_bearing", "nav_root"] }, { "key": "course", "singular": "Course", "plural": "Courses", "capabilities": ["content_bearing", "course_root", "scorm_exportable"] }, { "key": "journey", "singular": "Journey", "plural": "Journeys", "capabilities": ["content_bearing"] }, { "key": "step", "singular": "Step", "plural": "Steps", "capabilities": ["assessable", "content_bearing"] } ] } }, { "key": "knowledge-base", "label": "Knowledge Base", "templateId": "knowledge_base", "basePath": "/kb", "template": { "vocabulary": { "category": { "singular": "Category", "plural": "Categories" }, "article": { "singular": "Article", "plural": "Articles" } }, "depth": 2, "nodeTypes": [ { "key": "category", "singular": "Category", "plural": "Categories", "capabilities": ["content_bearing", "nav_root"] }, { "key": "article", "singular": "Article", "plural": "Articles", "capabilities": ["content_bearing"] } ] } } ] } ``` Field notes: - **`key`** — the stable, URL-safe handle you fetch the tree by. Authoritative. - **`label`** — the editor-facing display name; treat as presentation only. - **`templateId`** — which template the Section is built on. Built-in presets (`curriculum`, `knowledge_base`, …) or a tenant-authored custom template id — treat it as an opaque string, not a fixed enum. Determines its shape. - **`basePath`** — an advisory *"lives at"* display hint (e.g. `/academy`). It's a convenience for rendering breadcrumbs or link prefixes; it is **not resolution truth**. Always resolve content by the Section `key` and each node's `slug`, never by parsing `basePath`. May be `null`. - **`template.vocabulary`** — per-node-type `singular` / `plural` labels, keyed by node-type key. Use these to label your UI instead of hardcoding "Course", "Article", etc. On a custom template both the **keys** and the labels are tenant-defined, so drive your rendering off whatever `nodeTypes` / `vocabulary` come back rather than a hardcoded key list. - **`template.depth`** — the longest root→leaf chain (`1` = flat). - **`template.nodeTypes[].capabilities`** — machine-readable traits of a node type. Treat the list as open: a tenant-authored template may carry any subset, and new flags can be added, so branch on the flags you care about rather than matching the whole set. | Flag | What it means for you | Shown in Mentra as | |---|---|---| | `content_bearing` | A content **body** may attach at this level. Absent, nodes of this type won't have body fields — but they still resolve on `GET /sections/{key}/nodes/{id}` and still carry `title`, `breadcrumbs`, `seoMetadata` and (on request) `children`, so they remain renderable **index pages**. Treat the flag as "expect a body here", not as "this node has no page". | "Can hold content" | | `nav_root` | This level is a legitimate **entry point**: somewhere a visitor can be sent directly rather than only reached by drilling down. Two things build on it — a Section whose *root* type has it can anchor a top-level menu entry, and nodes at **any** level with it can be featured as teasers. It never creates a link by itself; an author still places it. | "Can be a starting point" | | `assessable` | May carry `quizData` and counts toward learner progress. Usually the leaf. | "Can be quizzed and tracked" | | `course_root` | The enrolment/completion unit — the node a learner signs up for, and the context learner progress is reported against. | "Is the thing learners enrol in" | | `scorm_exportable` | This node's subtree is a valid SCORM package root, so it can be exported for an external LMS. | "Can be exported to other platforms" | The right-hand column is the wording a Mentra admin sees in the structure template designer. The flag names in the API are stable; the in-app labels are written for content editors and may be reworded, so key your integration off the flag names and use the labels only when talking to someone about what they see on screen. ### `GET /sections/{key}/tree` — resolve a Section Returns the Section's whole node tree in one call, already filtered and ordered. Query parameters: - `locale` (optional) — content language (e.g. `en-US`, `nb-NO`). When provided, only nodes published in that locale are served, and each node's `slug` comes from that locale's published slug. Locale matching is BCP 47 prefix-aware: `?locale=nb` matches content published as `nb-NO`. - `publishedOnly` (optional, default `true`) — when `true`, only published nodes are returned. Set `false` to include drafts; this requires the `read:preview` scope. ```bash curl "https://api.mentra.systems/api/v1/sections/academy/tree?locale=nb-NO" \ -H "X-Api-Key: YOUR_API_KEY" ``` Response — Section metadata, the template vocabulary, and the ordered `nodes[]`, each node recursively carrying its `children`: ```json { "section": { "key": "academy", "label": "Academy", "templateId": "curriculum", "basePath": "/academy" }, "vocabulary": { "lifeskill": { "singular": "Life Skill", "plural": "Life Skills" }, "course": { "singular": "Course", "plural": "Courses" }, "journey": { "singular": "Journey", "plural": "Journeys" }, "step": { "singular": "Step", "plural": "Steps" } }, "nodes": [ { "id": "n_a1b2c3", "nodeTypeKey": "lifeskill", "title": "Beslutningsstøtte", "slug": "beslutningsstotte", "position": 0, "children": [ { "id": "n_d4e5f6", "nodeTypeKey": "course", "title": "Radardata i praksis", "slug": "radardata-i-praksis", "position": 0, "children": [ { "id": "n_g7h8i9", "nodeTypeKey": "journey", "title": "Kom i gang", "slug": "kom-i-gang", "position": 0, "children": [] } ] } ] } ], "nodeCount": 3 } ``` Field notes: - **`section`** — echoes the discovery metadata for the resolved Section (`basePath` may be `null`). - **`vocabulary`** — the same per-node-type label map as discovery, repeated here so a tree render is self-contained. - **`nodes[]`** — the tree roots, in `position` order; each node's `children` are ordered the same way. Every node carries: - `id` — the node's stable Firestore id. - `nodeTypeKey` — which template node type it is (`lifeskill`, `article`, …); look it up in `vocabulary` for labels. - `title` — the node's title, **publish-scoped**. For a node that carries content it comes from that content's published translation; for a body-less container (a category, a group root) it comes from the copy frozen by the node's last publish, so renaming one keeps serving the previously published name until you publish again. A node that has never been published serves its current title, so navigation labels never go blank. **Not locale-resolved:** unlike `slug`, a container's title is single-valued on the node regardless of the requested `locale`, so a node authored in one language keeps that title even under a `?locale=` request for another. If you need fully localized navigation labels, fetch the node's localized content separately rather than relying on this field. - `slug` — the node's per-locale, published slug. Authoritative for URLs, and the one field here that *is* locale-resolved; may be `null` if the node has no published slug in that locale. - `position` — the node's order among its siblings. - `children` — the node's child nodes (empty array at a leaf). - **`nodeCount`** — the total number of nodes across the whole tree (all depths), after filtering. Filtering rules baked into the tree: 1. **Published in the requested locale.** With `publishedOnly=true` (the default), a node is included only if it's published in the requested `locale` (prefix-aware). Drafts, unpublished, and visibility-restricted nodes are silently skipped. 2. **Ancestors gate their subtree.** A node whose ancestor was filtered out becomes unreachable — hiding a parent prunes everything under it, so you never get an orphaned child with no path to it. 3. **Order.** Siblings are returned by `position`, then `title` as a tiebreak. Status codes: - **Unknown or malformed key → `404`** with the machine-readable code `section_not_found`: ```json { "error": { "code": "section_not_found", "message": "Section not found", "request_id": "a1b2c3d4-…" } } ``` A key that could never exist (wrong characters — keys are lowercase, slug-like) is treated the same as an unknown one, as is any Section request against a tenant not on the structured-content model. ### `GET /sections/{key}/nodes/{id}` — a node's full content body The tree gives you a node's `id` and `slug`; this endpoint turns that `id` into the **full page**: rendered content, SEO / schema.org metadata, ancestor breadcrumbs, and the AI-generated extensions. It's the structured-content equivalent of `GET /standalone/{id}`, and it accepts the same `include=` vocabulary. You reach it from either handle: - **From the tree** — walk to the node you want and use its `id`. - **From a slug** — `GET /content/by-slug/{slug}` returns both a `sectionKey` and the node `id`; pass them straight here. (See the Slug Lookup page.) Query parameters: - `locale` (optional) — content language (BCP 47 prefix-aware, as elsewhere). - `include` (optional) — comma-separated extras, off by default to keep the payload lean: `quiz`, `scenarios`, `mnemonics`, `video`, `sources`, and `children` (the node's direct child nodes, for rendering a container/index node). Unlisted extras stay `null`. - `publishedOnly` (optional, default `true`) — `false` includes drafts and requires the `read:preview` scope. ```bash curl "https://api.mentra.systems/api/v1/sections/academy/nodes/n_d4e5f6?locale=nb-NO&include=quiz,sources" \ -H "X-Api-Key: YOUR_API_KEY" ``` ```json { "id": "n_d4e5f6", "sectionKey": "academy", "nodeTypeKey": "course", "parentId": "n_a1b2c3", "slug": "radardata-i-praksis", "title": "Radardata i praksis", "summary": "En praktisk gjennomgang…", "locale": "nb-NO", "coverImageUrl": "https://ik.imagekit.io/…/cover.jpg", "position": 0, "depth": 2, "publishedAt": "2026-02-01T00:00:00Z", "updatedAt": "2026-02-03T09:15:00Z", "breadcrumbs": [ { "id": "n_a1b2c3", "title": "Beslutningsstøtte", "slug": "beslutningsstotte" } ], "seoMetadata": { "metaDescription": "…", "structuredData": [ { "@type": "Article", "headline": "Radardata i praksis", "citation": [ … ] }, { "@type": "BreadcrumbList", "itemListElement": [ … ] } ], "openGraph": { … }, "twitterCardMetadata": { … } }, "contentHtml": "

", "contentPlainText": "…", "quizData": { … }, "researchSources": [ { "title": "…", "url": "https://…", "type": "url" } ] } ``` Field notes: - **`title` / `summary`** — the page's title and description, taken from the content translation for the resolved locale (so they're localized and publish-scoped, not a draft edit sitting on the node document). A pure container node with no attached content falls back to the node's own title/description/cover — publish-scoped too: the copy frozen by its last publish, or its current values if it has never been published. - **`breadcrumbs[]`** — the ancestor trail, root→parent (this node excluded), each crumb carrying its `id`, `title`, and locale-resolved `slug` (both also taken from each ancestor's own published translation/locale). Breadcrumbs are never *shortened*: if any ancestor is unpublished or not public for the requested locale, the node itself returns `404 node_not_found` (it's unreachable in the tree too), rather than returning a trail with a gap. The same path is also emitted as a `BreadcrumbList` block inside `seoMetadata.structuredData`. - **`seoMetadata`** — the same schema.org / OpenGraph / Twitter-card structure the standalone detail returns, so structured pages are as citable by search and AI answer engines as standalone articles. `include=sources` adds the research citations both as `researchSources` and as `citation` on the Article JSON-LD. - **`contentHtml` / `contentJson` / `contentPlainText`** — the rendered body. **Absent (null) for a node with no attached content** — a pure container node (a Course grouping its Journeys, a KB Category grouping Articles) still resolves with its title, breadcrumbs, and SEO, so it renders as an index page. Pair it with `include=children` to list what sits under it. - **`children`** — present only with `include=children`: the node's *direct* children as tree-shaped nodes (`id`/`nodeTypeKey`/`title`/`slug`/`position`), same filtering as the tree. Deeper structure comes from the tree read. - **AI extensions** (`quizData`, `scenarios`, `mnemonicSteps`, video fields) — opt-in via the matching `include=` token, exactly as on the standalone detail. Status codes: - **`404` `section_not_found`** — the Section key is unknown/malformed, or the tenant isn't on the structured-content model. - **`404` `node_not_found`** — no such node in that Section for this tenant, or the node exists but this locale must not see it (unpublished / visibility-restricted / belongs to a different Section). A node that can't be read is indistinguishable from one that doesn't exist — no draft leaks. ## Caching and revalidation All three read endpoints carry `ETag` and `Last-Modified` headers. Revalidate with `If-None-Match` **only** — a `304 Not Modified` tells you your cached copy is still current. Do **not** use `If-Modified-Since`: the resolved node set is locale-filtered, so its effective "last modified" moment can move as nodes enter or leave the visible set; the endpoint therefore ignores `If-Modified-Since` and relies on the ETag, which changes whenever the resolved tree changes (including publish, reorder, and slug changes). ## Importing content into a Section Getting content **into** a Section is the delivery half of the split described above, and it goes through the existing public write and bulk-import endpoints — there is no separate "section write" API. ### The shape is authored; the content is imported - **Set up in the CMS:** the Section itself, its template, its node types, and its `basePath`. Editors do this once. - **Imported over the API:** the actual nodes and their content, via `POST /import` (bulk, up to 100 items) or the single-item routes. Both come in two addressing modes: **Section-addressed** (`sectionKey` + `nodeTypeKey`), which works with any template shape, and the legacy **level-addressed** one (`level1..4`), which only ever names the Curriculum ladder. For tenants on the structured-content model, both land in the Section's node tree — the same tree `GET /sections/{key}/tree` reads back — rather than the legacy per-level collections. The rest of this section covers the level-addressed mode; see [Writing into any Section shape](#writing-into-any-section-shape) and [Bulk import into any Section shape](#bulk-import-into-any-section-shape) for the Section-addressed one. Parent placement works exactly as it does for the legacy hierarchy import: - A `level1` item is a **root** node of the Section. - A `level2`/`level3`/`level4` item **must** reference its parent. How you reference it depends on the endpoint: - **Bulk `POST /import`** accepts either `parentId` (a node id) or `parentExternalId` — the latter resolved within the batch first, then against existing nodes, so you can create a parent and its children in one request without knowing the parent's id ahead of time. - **Single-item `POST /level2..4`** accepts **only `parentId`**; `parentExternalId` is not a field on those routes (they reject unknown fields with `422`). Create or look up the parent first, then pass its id. - The `type` maps to the template's node type at that depth — `level1` → the root type, `level2` → its child, and so on down the ladder. - Order items **parents-before-children** (Level 1 → 2 → 3 → 4). A child whose parent can't be resolved fails that row with a clear error. ```bash curl -X POST "https://api.mentra.systems/api/v1/import" \ -H "X-Api-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "items": [ { "type": "level1", "externalId": "ls-decisions", "title": "Beslutningsstøtte", "locale": "nb-NO" }, { "type": "level2", "externalId": "course-radar", "parentExternalId": "ls-decisions", "title": "Radardata i praksis", "locale": "nb-NO" } ], "onConflict": "update" }' ``` Use `externalId` on every item so re-runs are idempotent, and `dryRun: true` first to catch unresolved parents before writing. See [Bulk Import](#bulk-import) and the Migration Guides page for full request / response details and end-to-end recipes. ### Writing into any Section shape Single-item writes address a Section directly, so **any** template shape is writable — branching, flat, deeper than four levels, or authored in your own template designer: ``` POST {base_url}/sections/{sectionKey}/nodes PUT {base_url}/sections/{sectionKey}/nodes/{nodeId} DELETE {base_url}/sections/{sectionKey}/nodes/{nodeId} ``` Name the Section by its `key` and the node type by a `nodeTypeKey` from that Section's template — the same keys `GET /sections` and `GET /sections/{key}/tree` already return, so discovery and writing speak one vocabulary. 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. ```bash curl -X POST "{base_url}/sections/library/nodes" \ -H "X-Api-Key: $MENTRA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "nodeTypeKey": "article", "title": "Getting started", "parentId": "", "content": "# Getting started\n...", "contentFormat": "markdown", "externalId": "kb-getting-started" }' ``` Behavior worth knowing: - **Placement is validated against the template before anything is written.** A type the template doesn't define returns 400 `unknown_node_type`; a non-root type at the root returns 400 `invalid_root_type`; a parent whose type doesn't allow this child returns 400 `invalid_child_type`; a parent in a different Section returns 400 `parent_wrong_section`. - **`externalId` is an upsert key** per Section and node type — a re-run updates and returns 200 instead of 201, so imports are idempotent. Because the key has to stay unambiguous, an update that would hand one node an `externalId` already held by another node of the same type in that Section returns 409 `external_id_conflict`. - **Only `content_bearing` node types carry a body.** Check the capability in `GET /sections` before sending content. A container type (say a `nav_root` category that isn't `content_bearing`) is still fully writable and gets its own slug — it just takes no `content` / `keywords` / `seoMetadata`, and sending those returns 400 `not_content_bearing` rather than silently dropping them. `title`, `summary`, and `coverImageUrl` are always accepted: they live on the node itself, which is what makes such nodes renderable index pages. - **A node's type is immutable** and moves aren't supported here: changing `parentId` on update returns 400 `reparent_unsupported`. - **Delete does not cascade.** A node that still has children returns 409 `node_has_children`. - An unknown `sectionKey` returns 404 `section_not_found`, as does a node addressed under the wrong Section (404 `node_not_found`). > **Nodes outside a Curriculum Section are covered by `GET /export`.** > The export emits Section-native content as items typed `node`, carrying > `sectionKey` + `nodeTypeKey` — the same address the import item uses — so the > round trip goes both ways. See > [Exporting Section content](#exporting-section-content) > for the item shape and the two limitations worth planning around (one locale > per node; the Section must exist before you restore into it). > > This closes a gap that predated these write routes: until then no > Section-native content had ever appeared in an export, so a `category`, an > `article`, or any tenant-defined node type was omitted silently with a `200`, > and `GET /export` was a Curriculum + standalone export rather than a > whole-tenant one. > > `GET /sections/{key}/tree` and `GET /sections/{key}/nodes/{id}` are still > **delivery** endpoints, not a backup path: both default to > `publishedOnly=true`, the tree applies visibility filtering even in preview, > and node detail returns one locale per call rather than enumerating what's > stored. Use them to mirror what your site shows; use > `GET /export?publishedOnly=false` to back up or migrate — the export takes > the same `publishedOnly` parameter and the same default, so an export left at > the default is published-scoped rather than a backup, and the draft path there > needs `read:preview` too. That default now scopes bodies as well as items: a > published item edited since it was last published exports the released text, > not the edit. The reads above are still the better fit for delivery — they > serve one locale per call and never make you page a whole tenant — but the > export default no longer leaks unreleased wording. ### Bulk import into any Section shape `POST /import` addresses a Section the same way the single-item routes do. Type an item `node` and give it a `sectionKey` plus a `nodeTypeKey` from that Section's template, and up to 100 nodes land in one request — at whatever depth the template permits: ```bash curl -X POST "{base_url}/import" \ -H "X-Api-Key: $MENTRA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "items": [ { "type": "node", "sectionKey": "library", "nodeTypeKey": "category", "externalId": "kb-basics", "title": "Basics" }, { "type": "node", "sectionKey": "library", "nodeTypeKey": "article", "externalId": "kb-getting-started", "parentExternalId": "kb-basics", "title": "Getting started", "content": "# Getting started\n...", "contentFormat": "markdown" } ], "onConflict": "update" }' ``` Behavior worth knowing: - **`parentExternalId` resolves inside the item's own Section**, searching only the node types the template says may parent that child — items created earlier in the same batch first, then existing nodes. So a parent and its children import in one request without you knowing the parent's id. Order items parents-before-children. - If a template lets **two different types** parent the same child and both hold a node with that `externalId`, the row fails rather than guessing — pass `parentId` to name the parent exactly. - **Placement is validated before anything is written**, with the same errors the single-item route returns (`unknown_node_type`, `invalid_root_type`, `invalid_child_type`, `parent_wrong_section`, `not_content_bearing`). Bad rows fail individually; the rest of the batch still imports. - **`dryRun: true` reports the same per-item verdicts a live run would**, so you can catch an impossible nesting before writing anything. It simulates the batch in order — a slug an earlier row takes is unavailable to a later one, and a slug an earlier row *frees* by renaming becomes available. - **A dry-run's simulation stops at the request boundary.** It writes nothing, so a second preview request cannot see nodes the first would have created: a child whose `parentExternalId` names a parent from an *earlier* request is reported unresolved even though a real run would have written that parent first. When splitting more than 100 items across requests, keep a parent and its children in the same one. - **`externalId` is the upsert key, scoped per Section and node type.** The same `externalId` may name a node in two Sections — or two node types in one Section — without collision. Each result echoes `sectionKey` and `nodeTypeKey` so it correlates back to the request item. - **One request may not name the same node twice.** Two `node` items sharing all three of `sectionKey`, `nodeTypeKey` and `externalId` return 400 `duplicate_item_address` and nothing is written. Whatever `onConflict` says, one of the two rows would otherwise be silently discarded, so a bad id mapping is reported rather than half-applied. Re-importing the *same* `externalId` in a *later* request is the intended idempotent path and is unaffected. - Omit both `parentId` and `parentExternalId` to create at the Section root (valid only for a root type). On a **re-import of an existing node**, that omission means "leave the parent alone", not "move it to the root". Sending `"parentId": null` explicitly *is* a move request, and on an existing node it returns 400 `reparent_unsupported` — moves belong to the node routes. - A blank or whitespace-only `title` is rejected (400 `node_title_required`), as is a body that fails conversion (malformed JSON, unsupported `contentFormat`). Both are reported by `dryRun` too. - `sectionKey` / `nodeTypeKey` are accepted **only** on a `node` item. Sending them on a `level1..4` item returns 400 `node_address_not_allowed` rather than quietly importing into the Curriculum Section instead. **The `level1..4` item types keep working, unchanged.** They map onto the four-level Curriculum ladder (`lifeskill → course → journey → step`) only. A `level1` import resolves into the tenant's **existing (default) Curriculum Section** — whatever it's named; if the tenant has no Curriculum Section yet, one is auto-provisioned with the key `academy` (labeled *Academy*) — and `level2..4` resolve to the fixed Curriculum node types beneath it. After importing, discover the actual key via `GET /sections` rather than assuming `academy`. Existing integrations need no migration; use `node` items when you want to target a Section explicitly or a non-Curriculum shape. The legacy `POST/PUT/DELETE /v1/level1..4` single-item routes are likewise unchanged. (Read delivery is unaffected: `GET /sections` and `GET /sections/{key}/tree` serve **every** Section regardless of template, Curriculum or not.) ## Recommendation: render the tree generically As with Surfaces, resist building a component per Section. Build one `
` component that takes a `key`, fetches the tree, and walks `nodes[]` recursively — labeling each node from `vocabulary[nodeTypeKey]` and linking by `slug`. Adding a new Section to your site then becomes a CMS action plus wiring one key, not a development task. ``` component Section(key, locale): response = GET {base_url}/sections/{key}/tree?locale={locale} with header X-Api-Key with cached ETag via If-None-Match if response is 404: # Section not configured (or tenant not on the model) log warning; render nothing render tree(response.nodes, response.vocabulary) function tree(nodes, vocabulary): for node in nodes: # already filtered, ordered, locale-resolved label = vocabulary[node.nodeTypeKey].singular render link(node.title, to /{key-derived-path}/{node.slug}) if node.children: tree(node.children, vocabulary) ``` Keep the key-to-page mapping in your site's configuration so neither side hardcodes the other. --- # Content Discovery (Slug Lookup) Resolve content by human-readable slugs instead of Firestore IDs. Perfect for building SEO-friendly URL routing on your frontend. ## Endpoint ``` GET /content/by-slug/{slug} ``` Scope: `read:content`. Searches across all content types (standalone, level1–4) 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 structured hit (`level1`–`level4`) additionally carries the `sectionKey` of the Section it belongs to — fetch the node's full body via `GET /sections/{sectionKey}/nodes/{id}` (or its Section tree via `GET /sections/{sectionKey}/tree`). Only content with a public read path is returned. On a tenant that isn't on the structured-content model, the search is narrowed to standalone content: a slug never resolves to a legacy `level1`–`level4` document, because those have no public read endpoint there. (Once the tenant is on the model, level hits resolve to nodes readable via the Section endpoints above.) ## Query Parameters - `locale` (optional, default `en-US`) — locale code. If the slug is not found for the requested locale, falls back to the document's `originalLocale`. - `contentType` (optional) — limit search to a specific type: `standalone`, `level1`, `level2`, `level3`, `level4`. When omitted, all collections are searched in order. - `publishedOnly` (optional, default `true`) — filter to published content only. Set to `false` with a preview-scoped API key to include drafts. ## Locale Fallback Chain 1. `slugByLocale.{requested locale}` 2. `slug` (legacy field) 3. `404` ## Example Request: ``` GET https://api.mentra.systems/api/v1/content/by-slug/getting-started-with-ai?locale=en-US ``` Response: ```json { "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" } ``` ## Two-step Resolution Pattern (JavaScript) ```javascript // 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" // structured hits 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, … } ``` Slugs are generated automatically from the content title when creating or updating content. You can also edit slugs manually in the CMS editor. Each locale can have its own slug (e.g. `getting-started-with-ai` for en-US, `kom-i-gang-med-ki` for nb-NO). --- # Content Export and Bulk Delete ## Content Export (`read:content`, plus `read:preview` for unpublished items) ``` GET /export ``` Export your entire tenant's content library in a format that can be re-imported via the Bulk Import endpoint (`POST /import`). Enables round-trip migrations, backups, and tenant duplication. ### Query Parameters - `type` (optional, default `all`) — filter by content type: `standalone`, `level1`, `level2`, `level3`, `level4`, `node`, `all`. - `publishedOnly` (optional, **default `true`**) — export published, publicly visible content only. Set `false` for a full backup including drafts; that requires the `read:preview` scope. See below. - `locale` (optional, default `en-US`) — export a specific locale. - `page` (optional, default `1`) — page number. - `pageSize` (optional, default `50`, range 1–100) — items per page. ### Example ```bash curl "https://api.mentra.systems/api/v1/export?locale=en-US&pageSize=50" \ -H "X-Api-Key: YOUR_API_KEY" ``` ### What an export contains — `publishedOnly` > **Changed.** `GET /export` previously returned *everything* a tenant held, > drafts included, to any key with `read:content`. It now defaults to > `publishedOnly=true` and returns published, publicly visible content only. > **If you export to take a backup, add `publishedOnly=false` and make sure the > key holds `read:preview`** — otherwise your backup silently loses every > unpublished item. If you export to mirror what your site shows, the default > is closer to what you want — but it is still not a delivery read: see > **What the default does and does not guarantee** below before relying on it. `GET /export` now behaves like every other read on this API: - **`publishedOnly=true` (the default)** — an item is included only if at least one of its locales is published *and* it passes the visibility filter. That filter admits an item whose `visibilityTags` contains `public`, **and also an item with no `visibilityTags` field at all** — legacy content predating visibility tagging, which every read on this API treats as public. Only an explicit list without `public` is excluded, so `visibilityTags: []` is hidden from everyone and is not exported. Do not treat a returned item as invalid because it carries no `visibilityTags`. `read:content` is enough for this. The gate applies at **both** levels: it decides *which items* are exported **and** which *revision* of each one you receive — published translations, never the working copy. See below. - **`publishedOnly=false`** — everything, exactly as the export always returned: drafts, hidden items, and items never published in any locale. This needs the `read:preview` scope; without it the request is `403 Missing scope read:preview for preview access`, the same answer `GET /standalone?publishedOnly=false` already gives. The split is per key, so a tenant can hand its public website a `read:content` key that cannot see unpublished *items*, and its backup job or staging build 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. ### What the default guarantees **Which items you get.** With `publishedOnly=true` an item appears only if one of its locales is published and its `visibilityTags` contains `public`, and — for `level1`–`level4` and `node` items — only if every ancestor passes the same test. Nothing unpublished, hidden, or unreachable is listed. **And which revision of each item's editorial fields.** `content`, `contentJson`, `contentHtml`, `summary`, `keywords`, `seoMetadata` and `title` come from the item's **published** translation, so an item that is published and has been edited since exports the released text rather than the edit. A `read:content` key cannot see unreleased wording any more than it can see unreleased items. > **This changed.** Until this release the default served every item's *working* > translation, and fell back to the item's own mutable document for `title`, > `summary` and `coverImageUrl` — so a published item that had been rewritten or > renamed exported the unreleased version to a key holding only `read:content`. > If you export to mirror what your site shows, the new behaviour is what you > wanted and there is nothing to do. **Each item is exported in its original locale, and that specific locale must be published** for the editorial fields to be populated. This matters more than it sounds, because the two rules differ: an item is *admitted* when **any** of its locales is published, but its `content`, `contentJson`, `contentHtml`, `summary`, `keywords`, `seoMetadata`, `coverImageUrl` and `title` are read from the **original** locale only. An article authored in `en` and published solely in `nb` is therefore listed — correctly, it is live — but comes back with those fields empty, because there is no published `en` revision to serve. This is a normal multilingual situation, not a defect and not a legacy edge case, and it has the same shape as an interrupted publish or as content marked live through the older per-locale published index. Empty here means `null` for every field above and an empty string for `title`; the export never falls back to the draft. **Such an item is still listed and still addressable.** `id`, `slug`, `visibilityTags`, `isPublished` and the timestamps are unaffected. Identify items by **`id`**, which every item carries — `externalId` is optional and is commonly `null` on standalone and `level1`–`level4` records, which are exactly the ones most likely to hit this. (Only `node` items are guaranteed an `externalId`, because the export synthesises one when the node has none.) **An item in that state will not re-import from a default export**, since import requires a non-blank title. Take it with `publishedOnly=false`, or publish the item's original locale so the published revision exists. **Container items keep their own title and cover — the published one.** A structural node that holds no body of its own — a category, a group root — legitimately stores its title and `coverImageUrl` on the node itself, so those are still returned rather than suppressed. On a `publishedOnly=true` export they are the values frozen by the node's last publish, not whatever an editor saved since; a node that has never been published exports its current values. That matches what `GET /v1/sections/{key}/tree`, the node detail read and the surface teasers serve for the same node, which is the agreement this endpoint is held to. The suppression above applies only to items that have a content body attached. **For a faithful backup, use `publishedOnly=false`.** It returns the working translation for every item, drafts included, which is what `POST /import` needs to reproduce what the tenant actually holds. The default does **not** round-trip in-progress edits — restoring from it reconstructs the published text. It already omitted unpublished items entirely, so the default was not a backup before this change either; now the bodies match that. > **One backup payload did change, and it changed by gaining content.** > `level1`–`level4` 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 — the export looked for the body > beneath the level record, and 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 is removed and nothing is > 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. If your restore tooling treats an item gaining a body as a > conflict, that is the case to check. Every other item type, and every item on > `publishedOnly=false` that already had a body, is untouched. **If you want exactly what is live, the delivery endpoints remain the direct route** — `GET /standalone` and `GET /standalone/{id}` for standalone content, `GET /sections/{key}/tree` and `GET /sections/{key}/nodes/{id}` for structured content. They serve one locale per call and apply the same publish scoping the export default now does. Use `GET /export` to back up, migrate, or duplicate a tenant. `isPublished` is still reported on every item. Under the default it is now always `true`; under `publishedOnly=false` it tells you which items were live at export time. Response shape: ```json { "totalItems": 42, "items": [ { "type": "standalone", "id": "abc123", "externalId": "wp-post-1", "title": "Introduction to Learning", "content": "{\"type\":\"doc\",\"content\":[...]}", "contentJson": { "type": "doc", "content": [] }, "contentHtml": "

Introduction

...

", "contentFormat": "json", "summary": "A brief summary...", "coverImageUrl": "https://ik.imagekit.io/...", "tags": ["intro", "basics"], "locale": "en-US" }, { "type": "level1", "id": "def456", "externalId": "cat-101", "title": "Programming", "locale": "en-US" }, { "type": "level2", "id": "ghi789", "title": "Python Basics", "parentId": "def456", "order": 0, "locale": "en-US" } ], "page": 1, "pageSize": 50, "hasNext": false, "nextPage": null } ``` ### Exporting Section content Content that lives in a Section — a Knowledge Base `category`, a `Page`, or any node type from a tenant-authored template — is exported as an item typed `node`, carrying `sectionKey` and `nodeTypeKey` alongside the usual fields: ```json { "type": "node", "id": "n_7c2f", "sectionKey": "library", "nodeTypeKey": "article", "externalId": "kb-first", "title": "Getting started", "content": "{\"type\":\"doc\",\"content\":[...]}", "contentFormat": "json", "contentJson": { "type": "doc", "content": [] }, "contentHtml": "

...

", "parentId": null, "parentExternalId": "kb-basics", "parentNodeTypeKey": "category", "locale": "en" } ``` Four things about this shape are load-bearing for the round trip: - **A node's identity is `(sectionKey, nodeTypeKey, externalId)`** — not `externalId` alone. The same `externalId` may legitimately name a node in two Sections, or two node types in one Section. - **`parentId` is always `null`.** Import gives `parentId` precedence over `parentExternalId`, and the exported id belongs to the *source* tenant, so echoing it would make every child resolve against something that doesn't exist in the target. `parentExternalId` is the portable link. - **`parentNodeTypeKey` names the parent's type.** If your template lets two node types hold the same child (say a `note` under either a `binder` or a `chapter`), one `externalId` can name a node of *each* — both are legal, because identity is per node type. A bare `parentExternalId` would then be ambiguous and the child would fail to import; this field pins the exact parent. You can send it on your own `node` items too. Omit it and resolution is unchanged: every eligible parent type is searched, and a genuine tie is an error rather than a guess. - **Every node carries an `externalId`.** It's optional when you create a node, and content authored in the Mentra CMS has none — but import requires one, so a node without one falls back to its Mentra document `id`, used consistently for both its own `externalId` and its children's `parentExternalId`. Restoring into an empty or different tenant works as intended, and re-running the same restore is idempotent. One caveat: because the fallback is synthesized at export time, re-importing into the tenant you exported *from* creates a second node rather than updating the original, which never carried that id. Assign real `externalId`s if you need same-tenant upserts. - **Parents come before children, and each node appears exactly once.** `parentExternalId` resolves against nodes created earlier in the same request, so order matters — and an import that names one node twice is rejected outright with `400 duplicate_item_address`. Two limitations to plan around: - **One locale per node.** An import item carries a single `locale` / `title` / `content`, and a second item for the same node would be that `duplicate_item_address` rejection — so a multi-locale node exports its original-locale body only. - **The Section must exist before you restore into it.** A `node` item addresses a Section by key; it doesn't create one. Create the Section in the target tenant first, then import. - **`seoMetadata` carries the fields the write API accepts** — `metaTitle`, `metaDescription`, `focusKeyword`, `focusKeywords`, `slug`, `canonicalUrl`, `readingTime`. Content authored in the Mentra CMS also stores generated SEO derivatives (`suggestedSlug`, `structuredData`, `openGraph`, `twitterCard`); those are omitted, because the write API rejects them and including them would fail the whole import request. Nothing you sent through the public API is lost — those keys were never accepted on the way in either. **This now applies to every item type.** Until this release only `node` items were narrowed; `standalone` and `level1`–`level4` items emitted the stored map verbatim, so a CMS-authored one carried those four derivative keys and made `POST /import` reject the entire request. If you read `seoMetadata` off an export for something other than re-import, those keys will no longer be present on those item types — the delivery reads still expose the generated values. A subtree split across two 100-item pages won't re-import under `dryRun`: a preview writes nothing, so the second page can't see the first page's parents. Import the pages for real in order, or raise `pageSize` so a subtree stays whole. If two nodes in one Section and node type share a stored `externalId` — possible in older data, since that uniqueness isn't enforced by the datastore — both are still exported, and the second is given a distinct `-2` address so the backup stays importable. Worth cleaning up at the source: the single-item write routes resolve such an `externalId` to whichever node comes back first. ### Round-Trip Workflow 1. Export from source tenant with `GET /export?publishedOnly=false` — a migration or backup wants the drafts too, and the default omits them. The source key needs `read:preview`. 2. Validate with `POST /import` using `dryRun: true`. 3. Import for real with `dryRun: false`. Export items POST back to `/import` without stripping anything: the response-only fields (`id`, `contentJson`, `contentHtml`, `isPublished`, `createdAt`, `updatedAt`) are ignored on input rather than rejected. Every item type round-trips its body: `content` carries the serialized document and `contentFormat` says `json`, so the import reconstructs it exactly. The rendered forms travel alongside it as `contentJson` and `contentHtml` for consumers that read the export rather than restore it. One limit worth knowing before you rely on a restore: - **Publication state is not restored.** The export reports `isPublished`, but the write API has no publish capability at all, so *everything* imports as a draft — this is true of `standalone` and `level1`–`level4` imports too, and always has been. Since Section delivery defaults to `publishedOnly=true`, restored content won't appear on your public tree until it's republished in the CMS. Plan a republish step into any migration. ```python import requests SOURCE_URL = "https://api.mentra.systems/api/v1" TARGET_URL = "https://api.mentra.systems/api/v1" # or different tenant SOURCE_KEY = "SOURCE_TENANT_API_KEY" # needs read:content + read:preview TARGET_KEY = "TARGET_TENANT_API_KEY" # 1. Export all content. publishedOnly=false is what makes this a backup: # drop it and every unpublished item is left behind, and every item that # survives comes back as its published revision rather than what the CMS # holds. It requires read:preview on SOURCE_KEY. page = 1 all_items = [] while True: resp = requests.get( f"{SOURCE_URL}/export?page={page}&pageSize=100&publishedOnly=false", 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`) ``` POST /bulk-delete ``` Delete up to **100 items** in a single request. Each item can be identified by its Mentra `id` (Firestore document ID) or by `externalId`. ### Request Body - `items` (required) — array of items to delete, max 100. Each item has: - `type` — content type: `standalone`, `level1`–`level4`. - `id` — Firestore document ID (one of `id` or `externalId` required). - `externalId` — external ID lookup. - `cascade` (optional, default `false`) — for hierarchy items, also delete all descendant items. - `dryRun` (optional, default `false`) — validate without deleting. ### Example ```bash curl -X POST "https://api.mentra.systems/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": "level1", "id": "abc123", "cascade": true } ], "dryRun": false }' ``` Response: ```json { "totalItems": 2, "deleted": 2, "notFound": 0, "failed": 0, "dryRun": false, "results": [ { "id": null, "externalId": "wp-post-1", "type": "standalone", "action": "deleted", "cascadeDeleted": 0 }, { "id": "abc123", "externalId": null, "type": "level1", "action": "deleted", "cascadeDeleted": 7 } ] } ``` **Cascade deletion is recursive.** Setting `cascade: true` on a Level 1 item also deletes all its Level 2 children, their Level 3 children, and so on down to Level 4. The `cascadeDeleted` field shows the total descendants removed. **Safety:** Always use `dryRun: true` first. The response will show `would_delete` actions and cascade counts, letting you validate before committing. --- # Migration Guides Import existing content from external sources into Mentra using the Bulk Import endpoint (`POST /import`). Both migration recipes below use a folder-to-hierarchy mapping and produce copy-paste-ready Python scripts with dry-run support. > **Where imported hierarchy content lands.** For tenants on the > structured-content model, `level1..4` imports write into the tenant's > **Curriculum Section** as a node tree (the same tree served by > `GET /sections/{key}/tree`) — a `level1` item becomes a root node in the > tenant's existing (default) Curriculum Section, whatever its key; only when > the tenant has no Curriculum Section yet is one auto-provisioned with the > key `academy`. Discover the actual key via `GET /sections` after importing > rather than assuming `academy`. `level2..4` map to the fixed > `lifeskill → course → journey → step` node types beneath it. The > folder-to-hierarchy mapping below is unchanged; only the storage behind it > moved. **Non-Curriculum Sections** (e.g. a two-level Knowledge Base) are > **not** populated over the import API — they are authored in the CMS. See > [Sections (Structured Content)](#sections-structured-content) for the full > authoring-vs-delivery split and the read API. ## Import from AWS S3 Scopes: `write:content`, `write:media`. Migrate content stored as Markdown, HTML, or JSON files in an AWS S3 bucket. The recommended layout mirrors Mentra's 4-level hierarchy: ``` bucket/ ├─ level1-programming/ │ ├─ meta.json # optional title/summary/tags override │ ├─ level2-python-basics/ │ │ ├─ meta.json │ │ ├─ level3-variables/ │ │ │ ├─ level4-intro.md # a Step (Level 4 content) │ │ │ └─ level4-types.md │ │ └─ level3-functions/ │ │ └─ level4-defining.md │ └─ media/ # images referenced by content │ └─ diagram.png └─ standalone/ └─ blog-post-1.md ``` `meta.json` is optional per folder and overrides title/summary/tags derived from the file name. Typical script flow: 1. List objects under each `levelN-*` folder. 2. For each Markdown/HTML file, build a `/import` payload using `externalId = s3_key` so re-runs are idempotent. 3. Upload media files via `POST /media/upload` (multipart) and substitute the returned `cdnUrl` in your content before import. 4. Run with `dryRun: true` first to catch conflicts or missing parents. 5. Run with `dryRun: false` to apply. ## Import from GitHub Scope: `write:content`. Migrate documentation or learning content from a GitHub repository. The script either clones your repo or uses the GitHub Contents API to read Markdown files. Expected repo layout mirrors the S3 layout above, with an optional `index.md` per folder for hierarchy-node metadata: ``` repo/ ├─ level1-programming/ │ ├─ index.md # YAML frontmatter: title, summary, tags │ ├─ level2-python-basics/ │ │ ├─ index.md │ │ ├─ level3-variables/ │ │ │ ├─ level4-intro.md │ │ │ └─ level4-types.md │ │ └─ level3-functions/ │ │ └─ level4-defining.md ``` ### Media - **Public repos:** reference images via `raw.githubusercontent.com` URLs — Mentra will proxy them through the CDN automatically on publish. - **Private repos:** upload images via `POST /media/upload` first and substitute the returned `cdnUrl` in your content. ### Tips - Use the commit SHA or file path as `externalId` (e.g. `gh:{repo}:{path}`) for idempotent re-imports. - For large repos, batch into 100-item chunks (the `/import` max). - Order items parents-before-children (Level 1 → 2 → 3 → 4 → Standalone). See also `06-endpoints.md` (Bulk Import + conflict strategies) and `08-export-and-bulk-delete.md` (round-trip verification with Export). --- # 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" }`. ```json { "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. ```json { "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": [ { "id": "block_123", "type": "hero", "props": { "heading": "Active Listening Techniques", "subheading": "Master the art...", "imageUrl": "https://..." } }, { "type": "richText", "props": { "content": "

...

" } } ], "contentHtml": "

Active Listening

...

", "contentJson": { "type": "doc", "content": [] }, "contentPlainText": "Active Listening...", "media": { "heroImage": "https://...", "images": ["https://..."], "videos": [] }, "seoMetadata": { "metaTitle": "Active Listening Techniques | Mentra", "metaDescription": "Learn proven techniques...", "focusKeywords": ["listening", "communication"], "canonicalUrl": "https://...", "readingTime": 5 }, "mnemonicSteps": [], "quizData": [], "scenarios": [], "createdAt": "2024-01-15T10:00:00Z", "updatedAt": "2024-11-20T14:30:00Z", "publishedAt": "2024-01-20T09:00:00Z" } ``` ### Content format fields - `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 `
`/`
` tags and alignment/size hint `data-*` attributes. - `contentJson` — TipTap/ProseMirror JSON document spec. Use this for fine-grained control over rendering (e.g., custom block renderers). See `11-content-json.md` for the full node/mark 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 Images with captions are rendered using semantic `
` and `
` tags. They include `data-*` attributes for alignment and sizing metadata (not CSS classes — read them and apply your own styles). ```html
Diagram of active listening
Figure 1: The Active Listening Cycle
Quick reference chart ``` `data-align` values: `left`, `center`, `right`. `data-size` values: `small` (25% width), `medium` (50%), `large` (75%), `xl` (100%). Use CSS attribute selectors to target these, e.g. `img[data-size="large"]` or `img[data-align="center"]`. ## 4. AI Extension Schemas ### Author (`author`) ```json { "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. ```json [ { "keyword": "LISTEN", "description": "Imagine a giant ear walking down the street...", "imageUrl": "https://storage.googleapis.com/.../image.png", "videoUrl": "https://storage.googleapis.com/.../video.mp4" } ] ``` ### Quiz Data (`quizData`) Multiple choice questions for testing understanding. ```json [ { "questionId": "q1", "question": "What is active listening?", "options": ["Hearing sounds", "Listening to understand", "Ignoring speaker"], "correctOptionIndex": 1, "explanation": "Active listening is about engagement..." } ] ``` ### Scenarios (`scenarios`) See `14-scenarios.md`. ### Research Sources (`researchSources`) Standalone detail endpoint only, when requesting `include=sources`. The research citations saved on the content piece during drafting — use them to render a "Sources" / "References" section. Only web sources are returned (title + URL); internal research material (page excerpts, notes, uploaded files, chat history) is never exposed. `null` when the piece has no URL-backed sources, or when the piece's editor has turned off **Include Research Sources** in its API Delivery Settings. ```json [ { "title": "WHO report on workplace stress", "url": "https://www.who.int/...", "type": "url" } ] ``` The same sources are also emitted as the schema.org `citation` property on the Article-type JSON-LD block in `seoMetadata.structuredData` — on every detail response, no `include=sources` needed — so search engines and AI crawlers see the citations too. The per-article opt-out suppresses both. ## 5. Layout Structure (Headless Page Builder) When requesting `include=layout`, the API returns a resolved `layout` array representing the visual structure defined in the Page Builder, with all dynamic data already filled in. Render the page exactly as designed without handling data mapping on the client side. ```json { "id": "unique_block_id", "type": "hero | richText | features | faq | ...", "props": { "heading": "Actual Title", "content": "

Resolved HTML content...

", "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`. ## When to use which 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. ```json { "type": "doc", "content": [ { "type": "heading", "attrs": { "level": 2 }, "content": [ { "type": "text", "text": "Getting Started", "marks": [] } ] }, { "type": "paragraph", "content": [ { "type": "text", "text": "Welcome to the course. This section covers " }, { "type": "text", "text": "key concepts", "marks": [{ "type": "bold" }] }, { "type": "text", "text": " you need to know." } ] } ] } ``` ## Node Type Reference The complete set of node types you may encounter in `contentJson`. This is the full surface area — there are no other node types. | Node Type | HTML Equivalent | Attributes | Has Children | |-------------------|--------------------------|-------------------------------------------------------------|--------------| | `doc` | (root container) | — | ✅ | | `paragraph` | `

` | — | ✅ | | `heading` | `

`–`

` | `level` (1–6) | ✅ | | `bulletList` | `
    ` | — | ✅ | | `orderedList` | `
      ` | — | ✅ | | `listItem` | `
    1. ` | — | ✅ | | `blockquote` | `
      ` | — | ✅ | | `codeBlock` | `
      `            | `language` (string)                                         | ✅           |
      | `hardBreak`       | `
      ` | — | — | | `horizontalRule` | `
      ` | — | — | | `image` | `` / `
      ` | `src`, `alt`, `caption`, `size`, `alignment` | — | | `table` | `` | — | ✅ | | `tableRow` | `` | — | ✅ | | `tableHeader` | `
      ` | — | ✅ | | `tableCell` | `` | — | ✅ | ### Node examples Heading with level: ```json { "type": "heading", "attrs": { "level": 2 }, "content": [ { "type": "text", "text": "Chapter Title" } ] } ``` Code block with language: ```json { "type": "codeBlock", "attrs": { "language": "python" }, "content": [ { "type": "text", "text": "def hello():\n print('Hello, world!')" } ] } ``` Nested list: ```json { "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: ```json { "type": "table", "content": [ { "type": "tableRow", "content": [ { "type": "tableHeader", "content": [ { "type": "paragraph", "content": [ { "type": "text", "text": "Term" } ] } ]}, { "type": "tableHeader", "content": [ { "type": "paragraph", "content": [ { "type": "text", "text": "Definition" } ] } ]} ] }, { "type": "tableRow", "content": [ { "type": "tableCell", "content": [ { "type": "paragraph", "content": [ { "type": "text", "text": "API" } ] } ]}, { "type": "tableCell", "content": [ { "type": "paragraph", "content": [ { "type": "text", "text": "Application Programming Interface" } ] } ]} ] } ] } ``` ## Mark Type Reference Marks are inline formatting applied to `text` nodes via the `marks` array. Multiple marks can be stacked on a single text node. | Mark Type | HTML Equivalent | Attributes | |-------------|-----------------|----------------------| | `bold` | `` | — | | `italic` | `` | — | | `underline` | `` | — | | `strike` | `` | — | | `code` | `` | — | | `link` | `` | `href`, `target` | Text node with multiple stacked marks: ```json { "type": "text", "text": "click here", "marks": [ { "type": "bold" }, { "type": "italic" }, { "type": "link", "attrs": { "href": "https://example.com", "target": "_blank" } } ] } ``` Renders as: `click here` ## Image Node Deep Dive All image URLs are pre-resolved to **ImageKit CDN URLs** at publish time — you do not need to perform any URL transformation. ```json { "type": "image", "attrs": { "src": "https://ik.imagekit.io/mentra/content/tr:w-800,q-85,f-webp/image.jpg", "alt": "Diagram showing the feedback loop", "caption": "Figure 1: The Feedback Loop", "size": "large", "alignment": "center" } } ``` | Attribute | Type | Required | Description | |-------------|-----------------|----------|-----------------------------------------------------------------------| | `src` | string | yes | CDN image URL (already optimized) | | `alt` | string | yes | Alt text for accessibility | | `caption` | string \| null | no | If present, render as `
      ` + `
      ` | | `size` | string \| null | no | Layout hint: `small` (25%), `medium` (50%), `large` (75%), `xl` (100%) | | `alignment` | string \| null | no | Layout hint: `left`, `center`, `right` | When rendering from `contentHtml`, `size` becomes `data-size` and `alignment` becomes `data-align` as attributes on the `` tag. Ready-to-use: see `@mentra/react` in `12-libraries.md` for a drop-in component that renders `contentJson` out of the box. --- # Libraries & SDKs Official client libraries and rendering tools for integrating Mentra content into your application. ## @mentra/react A drop-in React component that recursively renders `contentJson` from the Mentra API. Handles all standard TipTap/ProseMirror nodes, inline marks, and automatically interprets image sizing and alignment attributes. ### Installation ```bash npm install @mentra/react ``` ### Basic Usage Import `MentraContent` and pass the `contentJson` field from any content detail response. Optionally import the companion CSS for default structural styling. ```jsx import { MentraContent } from '@mentra/react'; import '@mentra/react/dist/index.css'; // Optional: basic structural styles export default function MyPage({ contentJson }) { return (
      ); } ``` ### 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). ```jsx import { MentraContent } from '@mentra/react'; import Link from 'next/link'; export default function MyPage({ contentJson }) { return ( { const Tag = `h${node.attrs?.level ?? 1}`; const classes = node.attrs?.level === 1 ? 'text-4xl font-bold' : 'text-2xl font-semibold'; return {children}; }, image: ({ node }) => ( {node.attrs?.alt} ), }} markOverrides={{ link: ({ mark, children }) => ( {children} ), }} /> ); } ``` ### Using contentHtml without React If you consume `contentHtml` in a non-React environment, you can still use our structural CSS for image alignments and sizing out of the box: ```html ``` ## Media API (`write:media`) Media endpoints require an API key with the `write:media` scope. Uploaded images are stored as originals and served via **ImageKit CDN** with on-the-fly optimization and transforms. ### CDN Image Transforms All image URLs in API responses (content `coverImageUrl`, media `cdnUrl`, and inline content images) are served via ImageKit CDN. Append transformation parameters to any CDN URL for responsive, optimized delivery. URL format: ``` https://ik.imagekit.io/MentraIntelligence/tr:TRANSFORMS/path/to/image.jpg ``` Common transforms: ``` # 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: use `cdnUrl` as-is for full quality, or append transform parameters for responsive `srcset` generation. ImageKit auto-negotiates the best format (WebP/AVIF) based on the browser's `Accept` header when using `f-auto`. ### Upload Media via URL ``` POST /media/upload-url ``` Download an image from a public URL and store it in your tenant's media library. The original is stored as-is; optimization and format conversion happen on-the-fly via the CDN. Request body: - `sourceUrl` (required) — public HTTP/HTTPS URL of the image to download - `filename` (optional) — override the filename (auto-detected if omitted) - `alt` (optional) — alt text for accessibility - `tags` (optional) — array of tag strings ```bash curl -X POST "https://api.mentra.systems/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"] }' ``` Response (`201 Created`): ```json { "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" } ``` **Always prefer `cdnUrl`** over `originalUrl`/`processedUrl` for displaying images. The CDN URL provides global edge caching, automatic format negotiation, and on-the-fly transforms. ### Upload Media File (multipart) ``` POST /media/upload ``` Upload a binary image file directly via `multipart/form-data`. Ideal for programmatic uploads from scripts or CI/CD pipelines. Form fields: - `file` (required) — binary image file - `alt` (optional) — alt text - `tags` (optional) — comma-separated tag string (e.g. `"hero,banner,course"`) ```bash curl -X POST "https://api.mentra.systems/api/v1/media/upload" \ -H "X-Api-Key: YOUR_API_KEY" \ -F "file=@./my-image.png" \ -F "alt=Course thumbnail" \ -F "tags=thumbnail,course" ``` Formats: JPEG, PNG, GIF, WebP, SVG, BMP, TIFF. Max file size: 20 MB. ### Get Media Metadata ``` GET /media/{mediaId} ``` ```bash curl "https://api.mentra.systems/api/v1/media/MEDIA_ID" \ -H "X-Api-Key: YOUR_API_KEY" ``` 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 + quality ### List Media ``` GET /media ``` Query parameters: - `limit` (optional, 1–100, default 20) — number of items per page - `offset` (optional, default 0) — number of items to skip - `tag` (optional) — filter by tag ```bash curl "https://api.mentra.systems/api/v1/media?limit=10&tag=thumbnail" \ -H "X-Api-Key: YOUR_API_KEY" ``` Response: ```json { "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 ``` DELETE /media/{mediaId} ``` Permanently deletes a media asset, including the original file. The freed storage quota is returned to your tenant. ```bash curl -X DELETE "https://api.mentra.systems/api/v1/media/MEDIA_ID" \ -H "X-Api-Key: YOUR_API_KEY" ``` Response: ```json { "mediaId": "abc123def456", "deleted": true, "message": "Media asset deleted successfully", "bytesFreed": 348160 } ``` --- # GEO Integration Guide How to render Mentra-delivered content so answer engines (ChatGPT, Claude, Perplexity, Google AI Overview) can cite it. This guide is for engineers integrating Mentra's API into a website or app. ## Why this matters Answer engines pull short, structured snippets — Q&As, step-by-step lists, definitions, comparison tables — from web pages tagged with JSON-LD schemas. Plain article markup gets ranked; tagged content gets **quoted**. Mentra's strategy engine plans content in citation-friendly formats (FAQ, Definition, HowTo, Comparison, Checklist, Glossary) and returns the matching JSON-LD schemas alongside the content body. Your site's job is to render them into the page's ``. If you don't render `seoMetadata`, none of the strategy work reaches answer engines. Three lines of integration unlocks the whole pipeline. ## What Mentra returns Every detail endpoint (e.g. `GET /api/v1/standalone/{id}`) includes a `seoMetadata` object on the response. The relevant fields for GEO discoverability: ```json { "id": "L4_123", "title": "Active Listening Techniques", "contentHtml": "

      ...

      ", "seoMetadata": { "metaTitle": "Active Listening Techniques | Mentra", "metaDescription": "Learn proven techniques for better listening...", "canonicalUrl": "https://your-site.com/active-listening", "metaTags": { "title": "Active Listening Techniques | Mentra", "metaDescription": "Learn proven techniques...", "keywords": "listening, empathy, attention", "openGraph": { "og:title": "Active Listening Techniques", "og:description": "Learn proven techniques...", "og:type": "article", "og:image": "https://cdn.../hero.jpg" }, "twitterCard": { "twitter:card": "summary_large_image", "twitter:title": "Active Listening Techniques", "twitter:description": "Learn proven techniques...", "twitter:image": "https://cdn.../hero.jpg" } }, "structuredData": [ { "@context": "https://schema.org", "@type": "Article", "headline": "Active Listening Techniques", "description": "Learn proven techniques...", "datePublished": "2024-01-20T09:00:00Z", "dateModified": "2024-11-20T14:30:00Z", "author": { "@type": "Organization", "name": "Your Tenant" } } ], "openGraph": { "title": "...", "description": "...", "image": "..." }, "twitterCardMetadata": { "card": "summary_large_image", "title": "..." } } } ``` `structuredData` is an **array** — Mentra emits one entry today (Article) and additional entries (HowTo / FAQPage / DefinedTerm) ship as the strategy engine plans citation-friendly content. Iterate the array; you do not need to know which schemas are present. ## Three-line integration recipe 1. Map `seoMetadata.metaTitle` / `metaDescription` / `metaTags` into your page's ``. 2. For every entry in `seoMetadata.structuredData`, render a ` ``` For pure SEO/GEO performance, prefer server-side rendering. Crawlers like Google's are fairly tolerant of client-side hydration these days, but some answer-engine crawlers still don't run JavaScript. ### Other frameworks - **Astro**: `