Blog (Headless CMS) Integration

Last updated: Sep 13, 2026Section: Integrations

Full API reference: https://api.mailoo.app/docs/v1

Use Mailoo as a central headless CMS for your blog: create and manage articles in the dashboard and deliver them via an external API by project. External sites (or your own) can consume the same API.

Overview

The Blog integration works like other Mailoo integrations (e.g. Form): you add a Blog integration to a project. All articles for that blog belong to that integration. The API does not provide access to blog articles without authorization. Read API: list articles and get one by slug; requires X-API-Key with scope blog.external-read; access is keyed by project UID and integration ID. Slug detail includes public likeCount / dislikeCount / feedbackCount. Marks write API: visitor like/dislike/feedback via POST …/slug/{slug}/reaction and …/feedback with scope blog.article-mark (use @mailoo/blog BFF handlers so the key stays on the server). Manage API: create, update, and delete articles with X-API-Key and scope blog.manage (same path prefix /api/v1/blog/.../articles). Agents can use the Mailoo MCP server with a personal MCP token created in Profile → Security to read mark counts and feedback texts (no MCP write for marks); see mailoo-mcp{.interpreted-text role="doc"}.

Capabilities:

  • Create, edit, and delete articles in the Mailoo dashboard (per integration), or via the Manage API / MCP with blog.manage
  • Publish as draft or published; optional excerpt, SEO metadata (metaTitle, metaDescription, ogImageMediaId → public ogImageUrl), classifiers (themes, intents, audiences, SEO clusters), tags
  • Read API (X-API-Key required): list published articles and get one by slug, keyed by project UID and integration ID
  • Manage API (X-API-Key + blog.manage): POST/GET/PUT/DELETE …/blog/{projectUid}/integrations/{integrationId}/articles; list classifier values with blog.external-read or blog.manage
  • Article response (list): id, title, slug, excerpt, content (Markdown, source of truth), htmlContent (HTML generated from content by the API; use for display), status, publishedAt, createdAt, updatedAt, author (id, name, email?, avatar, bio --- localized when locale is set), category (primary theme for backward compatibility: first theme by slug, or null), themes, intents, audiences, seoClusters (each an array of { id, slug, name }), metaTitle, metaDescription (null when unset --- consumers may fall back to title / excerpt), ogImageUrl (null when unset), canonicalUrl (absolute URL when the integration canonical template is set; otherwise null), seoWords ([{ slug, word }] --- union of integration SEO-word catalog entries linked to the article's SEO clusters; localized when locale is set; empty array when none), featured, readTimeMinutes, accessCount (page visits from external GET by slug; list / json-ld / MCP reads do not increment), tags ([{ slug, name }] from the catalog; localized when locale is set; empty array when none). Pagination includes page, limit, total, totalPages, hasNextPage, hasPrevPage. Inline images use URLs from the Mailoo image API (see images{.interpreted-text role="doc"}); there is no separate top-level article image field.
  • Article response (single by slug): same fields as the list item, plus optional linkedLinks --- an ordered array (max 10 per article) of curated links: id, type (internal_article | update_announcement | external_resource), label, url, intro (nullable), date (nullable ISO datetime), sortOrder. Internal links use a same-site path /blog/{targetSlug} in url; prefix with your locale segment when building the public href (e.g. /{locale}/blog/...). Editors manage links in the dashboard; internal_article rows can auto-fill label/date/intro from the target article with optional overrides.
  • JSON-LD (optional): GET …/slug/{slug}/json-ld?locale= returns Schema.org BlogPosting. When the integration canonical template is configured, url / mainEntityOfPage / inLanguage / publisher name are absolute (or locale-filled). When unset, placeholders {{canonicalUrl}}, {{origin}}, {{locale}} remain for the integrator to replace. Not included in the default list/slug payload. Same-origin BFF: GET /api/v1/blog/slug/{slug}/json-ld.

Page SEO for consumers: Map metaTitle ?? title → document / OG title, metaDescription ?? excerpt → description, ogImageUrl (else first body image) → OG/Twitter image, canonicalUrl (or slug + your locale prefix and site origin when null) → canonical / rel=canonical. Optional seoWords / tags may feed <meta name="keywords"> or internal linking. Absolute canonicals are not stored per article; configure a per-integration canonical template on Connection & settings (publicBaseUrl + path pattern with {locale} / {slug}).

Author profiles (dashboard)

Each account can maintain user-owned author profiles (display name, slug, optional public email, avatar URL, bio, optional per-locale overrides). Articles store a live reference to the selected profile so updates propagate. Pseudonyms are supported (isAlias). One profile can be marked as your global default; you can also set a per--blog-integration default (your preference only --- not stored in shared integration config).

  • Authors tab on the Blog integration: list and create/edit profiles.
  • Connection & settings tab: Default author saves the per-integration override (falls back to the global default when unset). Canonical URL template stores config.canonicalTemplate (publicBaseUrl, pathPattern) so external reads can return canonicalUrl. IndexNOW notify (optional config.indexNow) POSTs article identifiers to your site on publish/change --- you call IndexNOW (see indexnow-notify{.interpreted-text role="doc"}). Stock photos stores per-integration Unsplash / Pexels API keys (encrypted) so editors can search and import stock images into articles; imported photos become normal Mailoo UserMedia embeds (see images{.interpreted-text role="doc"}). Agents can read/update the same sanitized settings via GET/PATCH /api/v1/blog/.../settings or MCP tool manage_blog_integration_settings (see mailoo-mcp{.interpreted-text role="doc"}).
  • New/Edit article: choose an author profile or leave integration / global default so the API resolves the author automatically.

Creating a Blog Integration

  1. Go to Dashboard → Projects → [Your Project]
  2. Click Create New Integration
  3. Select Blog (Headless CMS)
  4. Set a name and status (e.g. Active)
  5. After creation, open the integration to see the Articles list and Connection block

Managing Articles

On the integration page you can:

  • Articles table: Title, slug, classifiers summary, status, views (accessCount), date, and Edit for each article; quick filters by theme, intent, audience, and SEO cluster; Export Markdown report (with or without full article text) for AI-assisted content planning
  • Create article: Opens the new-article form (title, excerpt, SEO metadata, content, status). Content is entered in Markdown (headings, lists, bold, links, images via the dashboard image panel --- including the Stock tab for Unsplash/Pexels when keys are configured --- or public embed URLs); the API converts it to HTML on save.
  • Edit: Opens the edit form for that article (save as draft, publish, or archive). Same Markdown editor for the default locale and for each translation (locales).
  • Related links: On create/edit, you can attach up to 10 curated linked links (type, URL or internal target article, optional label/intro/date). The external get by slug API exposes them as linkedLinks for headless frontends (e.g. release notes on a portfolio project page).

Content format: Article body is stored as content in Markdown. The API generates htmlContent from it. Consumers should use htmlContent for display to get correct structure and typography.

Links in htmlContent: Absolute http:// / https:// URLs and protocol-relative //… links include target="_blank" and rel="noopener noreferrer". Same-tab behavior applies to site-root paths (/path), explicit path-relative links (./…, ../…), in-page anchors (#section), and query-only URLs (?q=1).

Same-site links --- use a leading slash. In Markdown, [label](support/docs) becomes HTML href="support/docs". That is a path-relative URL: browsers resolve it per RFC 3986 against the directory of the page that displays the article, not against the site root. For example, on a post at https://example.com/en/blog/my-post, support/docs resolves to /en/blog/support/docs. To point at a site section from the root, write [label](/support/docs) (or the full https://… URL). Single-segment links without a slash (e.g. [other](other-slug)) are path-relative too, so they stay under the same directory as the post URL --- useful for sibling posts only when your public post URLs share that path prefix.

Mermaid in htmlContent: Fenced code blocks labeled mermaid are converted to static inline SVG (inside <figure class="mailoo-mermaid">). No browser-side Mermaid runtime is required; the same HTML is suitable for email-style rendering. Invalid Mermaid syntax causes the API to reject the save (articles, localized content, campaigns, etc.).

Printable summary blocks in htmlContent: Fenced code blocks labeled mailoo-print become a self-describing printable section. Authors write:

```mailoo-print Short summary title

## Key points

- First **takeaway**
- Second [link](/docs)
```

The API converts this to HTML similar to:

<section class="mailoo-printable" data-mailoo-print="true" data-print-title="Short summary title">
  <div class="mailoo-printable-body">…parsed Markdown as HTML…</div>
</section>

The optional title after mailoo-print on the fence info line becomes data-print-title. Inner content supports full Markdown (headings, lists, links, images, nested mermaid blocks). Consumers (your site or this repo's blog) should detect [data-mailoo-print], inject a print button in the browser, and print only .mailoo-printable-body --- the API does not embed onclick handlers (CSP-safe). See blog-nextjs-example{.interpreted-text role="doc"} for a client enhancer pattern.

Managing themes (categories) and classifiers

Blog content is organized on four classifier axes, each with many-to-many links to articles:

  • THEME (replaces the former "category" model): pillar topics; the dashboard Categories block still manages theme values for this integration (same CRUD paths as before: …/categories).
  • INTENT, AUDIENCE, SEO_CLUSTER: optional axes for editorial and SEO alignment (e.g. informational vs transactional intent, ICP, query clusters). Manage values under GET/POST …/blog-classifier-values?type=… (dashboard / Bearer API).

Each classifier value has a stable slug, a canonical English name, and optional locales ({ [locale]: { name } }). External list/article payloads resolve name when ?locale= is set.

Catalog tags (localized) --- navigational tags with stable slug, EN name, and optional locales. Dashboard Products tab manages them (…/blog-tags). Articles link via tagIds (not free-form strings). External article tags are [{ slug, name }]; filter with ?tag=<slug>.

SEO words (localized phrases) --- optional per integration: short phrases for SEO planning, not stored on articles. Each SEO word has a URL-style slug, a canonical English word, optional locales overrides (same JSON shape idea as article translations), and a many-to-many link to SEO_CLUSTER classifier values only. Dashboard Products tab: GET/POST /api/v1/projects/{projectUid}/integrations/{integrationId}/blog-seo-words and PUT/DELETE …/blog-seo-words/{wordId} (Bearer). List supports optional ?clusterId= (CUID of an SEO cluster value). When creating or editing an article, the form shows read-only the union of SEO words linked to the SEO clusters selected on that article.

When creating or editing an article, pick any number of values per axis and catalog tags. External list filters support category / categoryId for themes plus intentId, audienceId, seoClusterId (CUID of a classifier value) and tag (tag slug).

Theme description (optional): For a theme value, choose an article as descriptionPostId (same as the old category description). External GET …/categories/slug/{slug}/description still resolves theme slugs.

System themes (about / portfolio)

Mailoo reserves two theme slugs per Blog integration: about and portfolio. Use them for standalone pages that should not appear in the default public article list.

  • Provisioning: Creating a Blog integration automatically creates theme values About (slug about) and Portfolio (slug portfolio) with default locales (e.g. ru «О нас» / «Портфолио»).
  • Default list: GET …/blog/{projectUid}/integrations/{integrationId} with no category and no categoryId omits published articles linked to a theme whose slug is about or portfolio. Articles with no theme classifiers but a legacy category string matching those slugs (case-insensitive) are also omitted.
  • Explicit filter: Pass category (theme slug or legacy string) or categoryId (theme value id) to include those articles.
  • Dashboard article list shows all articles unless you apply filters.
  • Slug vs display name: You may change a theme's name (e.g. "About"); only the slug controls feed exclusion.
  • No API lock on slug: Dashboard PUT / DELETE on system themes is allowed. The API does not reject rename or delete of about / portfolio.
  • Renaming slug: If you change a system theme's slug (e.g. portfolioprojects), its articles appear in the default feed because exclusion matches slug strings only.
  • Deleting a theme: Deleted themes are not recreated on every API request. about and portfolio are ensured again when a Blog integration is created (idempotent skipDuplicates).
  • Duplicate slug: Creating another classifier with the same type + slug returns 409.
  • Get by article slug: GET …/articles/slug/{slug} (or BFF GET /api/v1/blog/slug/{slug}) returns the article as usual, including articles in about / portfolio themes.
  • Category list: GET …/categories includes system themes. Hiding them from main-feed filter chips is the integrator's responsibility.

The category query parameter filters by theme BlogClassifierValue (type THEME) slug or the legacy post category string. If both category and categoryId are set, both conditions apply (logical AND).

Dashboard Markdown report

Authenticated project editors can download a structured Markdown report for a Blog integration:

GET /api/v1/projects/{projectUid}/integrations/{integrationId}/blog/report?includeFullText=true|false&format=md&publishedFrom=YYYY-MM-DD&publishedTo=YYYY-MM-DD

Query parameters:

  • includeFullText --- when true, each article section includes the full Markdown body.
  • format --- only md is supported.
  • publishedFrom / publishedTo (optional, inclusive, UTC calendar dates YYYY-MM-DD) --- filter by publishedAt. When either is set, only posts with a non-null publishedAt are included (drafts without a publish date are excluded). If both are set, publishedFrom must be on or before publishedTo.

The dashboard Export report flow opens a dedicated page (breadcrumb navigation) to pick month, quarter, or a custom range, preview the Markdown, and save it to a file.

The file includes metadata, aggregates per classifier axis, a theme×intent matrix, and a per-article section (optionally with full Markdown bodies). Intended for prompting external LLMs for content planning.

Connection (External API)

The Connection block on the integration page shows the environment values to copy (API base URL, project UID, integration ID) and a ready-to-paste .env snippet. For web applications, the only recommended approach is BFF (API key on the server); direct API calls with X-API-Key are for server-to-server use. Full endpoint paths are documented below; they are not duplicated in the dashboard. For Next.js, see blog-nextjs-example{.interpreted-text role="doc"}.

BFF routes (Next.js, same-origin) --- use env vars MAILOO_BLOG_PROJECT_UID, MAILOO_BLOG_INTEGRATION_ID, MAILOO_BLOG_API, MAILOO_BLOG_API_KEY:

  • List published articles: GET /api/v1/blog Query: page, limit, category, categoryId, intentId, audienceId, seoClusterId, search, locale, featured, tag (optional); filters combine with logical AND. Without category and categoryId, articles in reserved theme slugs about and portfolio are excluded (see System themes above). tag matches a catalog tag slug (not a localized display name). search is a case-insensitive substring match on canonical title / excerpt / content; when locale is also set, it also matches locales[locale].title|excerpt|content (canonical fields stay in the OR as fallback when a translation is missing). locale alone still only affects response localization. Returns { success, data: [...], pagination } with hasNextPage, hasPrevPage. Next.js SDK createMailooBlogClient().listPosts keeps data, pagination, and imagePublicEmbedBaseUrl (see nextjs-packages{.interpreted-text role="doc"}).

  • Get one article by slug: GET /api/v1/blog/slug/{slug} Query: locale (optional). Returns { success, data: article }. Only published articles are returned.

  • JSON-LD by slug: GET /api/v1/blog/slug/{slug}/json-ld Query: locale (optional). Returns { success, data: { jsonLd } } (BlogPosting with placeholders). Next.js: mount createBlogJsonLdHandler(); RSC/sitemap should call createMailooBlogClient(config).getPostJsonLd (direct API, no BFF loopback). See nextjs-packages{.interpreted-text role="doc"}.

  • List categories (themes): GET /api/v1/blog/categories Query: locale (optional) --- localizes theme name from classifier locales. Returns { success, data: Category[] } (id, slug, name) --- theme classifier values only (backward-compatible path name).

  • Get category description by slug: GET /api/v1/blog/categories/slug/{slug}/description Query: locale (optional). Returns { success, data: { description, descriptionHtml } }. If category has no description article, both fields are empty strings. If category slug does not exist, returns 404.

  • List tags: GET /api/v1/blog/tags Query: locale (optional). Returns { success, data: [{ slug, name }] } from the integration tag catalog.

  • Get tag by slug: GET /api/v1/blog/tags/slug/{slug} Query: locale (optional). Returns 404 when the slug is not in the catalog.

  • Public author profile (by id or slug): same-origin BFF is not required; call the API directly with X-API-Key:

    GET {baseUrl}/api/v1/blog/{projectUid}/integrations/{integrationId}/authors/{authorSlugOrId}

    Query: locale (optional). Returns localized name, bio, avatar, and email only when the profile exposes a public email (pseudonyms do not leak the account email).

Direct API calls --- require X-API-Key header and scope blog.external-read:

  • List: GET {baseUrl}/api/v1/blog/{projectUid}/integrations/{integrationId} Optional query: page, limit, category, categoryId, intentId, audienceId, seoClusterId, search, locale, featured, tag (same semantics as the BFF list route above).
  • Single: GET {baseUrl}/api/v1/blog/{projectUid}/integrations/{integrationId}/slug/{slug} Query: locale (optional). Includes public likeCount / dislikeCount / feedbackCount.
  • JSON-LD: GET {baseUrl}/api/v1/blog/{projectUid}/integrations/{integrationId}/slug/{slug}/json-ld Query: locale (optional).
  • Categories: GET {baseUrl}/api/v1/blog/{projectUid}/integrations/{integrationId}/categories Query: locale (optional).
  • Category description: GET {baseUrl}/api/v1/blog/{projectUid}/integrations/{integrationId}/categories/slug/{slug}/description Query: locale (optional). Same behavior as BFF endpoint above.
  • Tags: GET …/tags and GET …/tags/slug/{slug} (optional locale).
  • Author profile: GET {baseUrl}/api/v1/blog/{projectUid}/integrations/{integrationId}/authors/{authorSlugOrId} Query: locale (optional). Public read of a project owner's author profile for bylines and author pages.

Visitor marks --- require X-API-Key and scope blog.article-mark (prefer @mailoo/blog BFF so the key stays server-side):

  • Reaction: POST …/slug/{slug}/reaction --- body visitorKey, action set[|``clear``, optional ``sentiment`` ``LIKE``|](##SUBST##|clear, optional sentiment LIKE|)DISLIKE, optional comment (required for new DISLIKE).
  • Feedback: POST …/slug/{slug}/feedback --- private note; may coexist with like/dislike. Clearing feedback while dislike is set returns 400.

Manage API --- require X-API-Key and scope blog.manage (FULL keys also work):

  • List (all statuses): GET {baseUrl}/api/v1/blog/{projectUid}/integrations/{integrationId}/articles
  • Create: POST …/articles --- body matches dashboard create (title, Markdown content, optional locales including per-locale metaTitle / metaDescription, themeValueIds / intentValueIds / audienceValueIds / seoClusterValueIds, tagIds, metaTitle, metaDescription, ogImageMediaId, status, slug, authorProfileId, ...). Author defaults to the key owner's default profile when omitted; 400 if none exists.
  • Get / update / delete: GET|PUT|DELETE …/articles/{articleId}
  • Classifier values (read or manage): GET …/classifier-values?type=THEME|INTENT|AUDIENCE|SEO_CLUSTER --- requires blog.external-read or blog.manage. Create/update accept optional locales ({ [locale]: { name } }).
  • Tags (catalog): GET|POST …/tags, PUT|DELETE …/tags/{tagId} --- slug + EN name + optional locales; articles link via tagIds.

Agent discovery (any valid API key; projects owned by the key user only):

  • GET {baseUrl}/api/v1/agent/projects
  • GET {baseUrl}/api/v1/agent/projects/{uid}/integrations?type=BLOG

For MCP setup, see mailoo-mcp{.interpreted-text role="doc"}.

Base URL is your Mailoo API (e.g. https://api.mailoo.app). projectUid and integrationId are shown in the Connection block. Images in article bodies use the Mailoo image upload API and public embed URLs inside content / htmlContent (see images{.interpreted-text role="doc"} for storage, scopes, and how to read bytes with Bearer or X-API-Key). Tags are catalog entries returned as [{ slug, name }] (localized when locale is set); empty array when none. Articles used as category descriptions are excluded from the external article list, so they are not duplicated in regular feed results. The same default list also excludes about and portfolio category articles unless you pass an explicit category filter. For full response fields and error codes, see the API docs above.

For a full Next.js example, see blog-nextjs-example{.interpreted-text role="doc"}.