Content blocks & AI image disclosure

This page documents the content_blocks wire format: the JSON shape blog-maker POSTs to a "funnelexpert"-type publish target. It's what powers our own hosted sites and every third-party backend we've connected so far (WordPress and custom_rest targets use a different, simpler format, see the links at the bottom).

When to use this

Building a receiver for a site that isn't WordPress and needs full structural fidelity (headings, lists, FAQ blocks, images) rather than a single flat HTML string. If flat HTML is enough for your backend, use the simpler custom_rest format instead.

Top-level request shape

Every publish is a single POST with this shape:

FieldTypeDescription
slugstring, requiredURL slug for the article.
status"draft" | "published", optionalDefaults to published.
categorystring, optionalCategory name; created on your side if it does not exist yet.
seoobject, requiredfocus_keyword, seo_title, meta_description, secondary_keywords[].
translationsobject, requiredKeyed by language code ("en", "de", ...); each value has title, excerpt, content_blocks[].
sourcestring, optionalIdentifies the sender, e.g. "seo_blog_generator".
source_idstring, optionalOur own article id, useful for idempotent updates on your side.

Content block types

Each entry in translations[lang].content_blocks has a type and type-specific fields:

TypeFieldsDescription
heading-2 … heading-6content, levelA heading. level is clamped to 2 through 6 on our side, so a translated body never emits a second page H1.
paragraphcontentInline HTML is allowed inside content (<strong>, <em>, <a>); treat it as untrusted and sanitize on your side.
listitems[], orderedordered true renders <ol>, otherwise <ul>.
blockquotecontentPlain quote.
calloutcontentA "good to know" tip box, visually distinct from a plain blockquote.
tablecontentRaw table HTML, passed through as-is.
imagesrc, alt, caption, ai_originSee the AI image disclosure section below for ai_origin.
divider(none)A horizontal rule.
iframesrcTrusted embeds only (allowlisted hosts); anything else renders as nothing.
faqquestions[] ({question, answer})A structured FAQ block, meant to also drive your FAQPage schema if you have one.
htmlcontentA passthrough for a self-closing block comment we didn't recognize while converting (e.g. an SEO plugin marker). Safe to ignore or drop; nothing depends on you rendering it.

Full example

A complete request and the expected response:

POST https://example.com/api/blog
Authorization: Bearer <your-api-key>
Content-Type: application/json

{
  "slug": "how-to-set-up-blog-automation",
  "status": "published",
  "category": "guides",
  "seo": {
    "focus_keyword": "blog automation",
    "seo_title": "How to Set Up Blog Automation",
    "meta_description": "Learn step by step how content automation works.",
    "secondary_keywords": ["content pipeline", "SEO workflow"]
  },
  "translations": {
    "en": {
      "title": "How to Set Up Blog Automation",
      "excerpt": "A step-by-step guide to automating your content pipeline.",
      "content_blocks": [
        { "type": "heading-2", "content": "Getting started", "level": 2 },
        { "type": "paragraph", "content": "Blog automation starts with a clear <strong>content pipeline</strong>." },
        {
          "type": "image",
          "src": "https://blog-maker.com/images/articles/pipeline-hero.webp",
          "alt": "A content pipeline diagram",
          "caption": "The pipeline, end to end.",
          "ai_origin": "generated"
        },
        { "type": "list", "items": ["Plan", "Write", "Review", "Publish"], "ordered": true },
        {
          "type": "faq",
          "questions": [
            { "question": "How long does this take?", "answer": "Most brands are live within a day." }
          ]
        }
      ]
    }
  },
  "source": "seo_blog_generator",
  "source_id": "8421"
}
{
  "ok": true,
  "id": "post-8421",
  "url": "https://example.com/blog/how-to-set-up-blog-automation"
}

AI image disclosure (ai_origin)

The EU AI Act (Art. 50(4)) requires a clear disclosure when a website shows an AI-generated or AI-manipulated image. blog-maker tracks this at the source: an image content_block carries "ai_origin": "generated" only when the image is one we generated and no human photo credit was applied. The field is absent for everything else (a real photo, an author upload, an external URL). Unknown origin never claims AI, so a receiver that ignores the field simply shows no disclosure rather than a false one.

Your receiver's only job is to render a visible badge on that specific image when ai_origin is present. The markup and CSS below are the exact ones our own six connected sites use; keep the class names unchanged so the design stays consistent and any future update to the badge (a new language string, a design refresh) is a copy-paste, not a rebuild.

Markup

<span class="ai-origin-frame">
  <img src="..." alt="..." loading="lazy">
  <span class="ai-origin-badge">AI-generated</span>
</span>

<!-- small thumbnails (listing cards, related grids): -->
<span class="ai-origin-frame">
  <img src="..." alt="..." loading="lazy">
  <span class="ai-origin-badge ai-origin-badge--micro">AI</span>
</span>

CSS (one block, both tiers)

.ai-origin-frame { position: relative; display: block; line-height: 0; }
.ai-origin-frame img { display: block; width: 100%; height: auto; margin: 0; }

.ai-origin-badge {
  position: absolute;
  left: 10px;
  bottom: 10px;
  z-index: 2;
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 4px 9px 4px 5px;
  border-radius: 999px;
  background: rgba(10, 14, 20, 0.55);
  -webkit-backdrop-filter: blur(2px);
  backdrop-filter: blur(2px);
  color: #fff;
  font-size: 10.5px;
  font-weight: 500;
  line-height: 1.3;
  letter-spacing: 0.01em;
  white-space: nowrap;
  pointer-events: none;
}
.ai-origin-badge::before {
  content: "";
  width: 13px;
  height: 13px;
  flex: 0 0 auto;
  background-image: url("data:image/svg+xml,%3Csvg id='Calque_1' data-name='Calque 1' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 566.93 566.93'%3E%3Cdefs%3E%3Cstyle%3E .cls-1 { fill: %23fff; fill-rule: evenodd; } .cls-2 { fill: %231d1d1b; } %3C/style%3E%3C/defs%3E%3Cpath class='cls-1' d='M272.03,100.72c100.92,0,182.74,81.82,182.74,182.75s-81.82,182.74-182.74,182.74-182.75-81.82-182.75-182.74,81.82-182.75,182.75-182.75'/%3E%3Cg%3E%3Cpath class='cls-2' d='M170.79,353.74c-1.08,0-2.05-.43-2.92-1.31-.88-.87-1.31-1.84-1.31-2.92,0-.67.07-1.27.2-1.81l47.34-129.32c.4-1.48,1.24-2.79,2.52-3.93,1.27-1.14,3.05-1.71,5.34-1.71h29.81c2.28,0,4.06.57,5.34,1.71,1.27,1.14,2.11,2.45,2.52,3.93l47.14,129.32c.27.54.4,1.14.4,1.81,0,1.08-.44,2.05-1.31,2.92s-1.91,1.31-3.12,1.31h-24.78c-2.01,0-3.52-.5-4.53-1.51-1.01-1.01-1.65-1.91-1.91-2.72l-7.86-20.55h-53.78l-7.65,20.55c-.27.81-.88,1.71-1.81,2.72-.94,1.01-2.55,1.51-4.83,1.51h-24.78ZM218.13,299.96h37.47l-18.93-53.18-18.53,53.18Z'/%3E%3Cpath class='cls-2' d='M328.11,353.74c-1.48,0-2.69-.47-3.63-1.41-.94-.94-1.41-2.15-1.41-3.63v-130.93c0-1.48.47-2.68,1.41-3.63s2.15-1.41,3.63-1.41h26.99c1.48,0,2.68.47,3.63,1.41.94.94,1.41,2.15,1.41,3.63v130.93c0,1.48-.47,2.69-1.41,3.63-.94.94-2.15,1.41-3.63,1.41h-26.99Z'/%3E%3C/g%3E%3C/svg%3E");
  background-size: contain;
  background-repeat: no-repeat;
}

.ai-origin-badge--micro {
  left: 6px;
  bottom: 6px;
  padding: 2px 6px 2px 5px;
  gap: 4px;
  background: rgba(10, 14, 20, 0.55);
  -webkit-backdrop-filter: blur(1px);
  backdrop-filter: blur(1px);
  opacity: 1;
  font-size: 9px;
  font-weight: 700;
  border-radius: 999px;
}
.ai-origin-badge--micro::before {
  width: 11px;
  height: 11px;
}

/* Add the --right modifier class below when your layout needs the badge in
   the bottom-right corner instead (e.g. a hero with a headline overlay on
   the left). Icon, size and colors stay identical, only the corner flips. */
.ai-origin-badge.ai-origin-badge--right { left: auto; right: 10px; }

Build it with an AI assistant

Copy one of these into Claude, ChatGPT, or your assistant of choice. Each one embeds the full contract above, so the assistant can build a working receiver without reading this page again.

Claude

I'm connecting my website to blog-maker.com, an SEO content platform. It will
POST published articles to an endpoint I control, using its "content_blocks"
JSON format. Build me a minimal receiver in [YOUR LANGUAGE/FRAMEWORK] that:

1. Accepts POST requests, authenticated with a Bearer token I configure.
2. Parses this request shape (fields I don't recognize should be ignored,
   never cause an error):

POST https://example.com/api/blog
Authorization: Bearer <your-api-key>
Content-Type: application/json

{
  "slug": "how-to-set-up-blog-automation",
  "status": "published",
  "category": "guides",
  "seo": {
    "focus_keyword": "blog automation",
    "seo_title": "How to Set Up Blog Automation",
    "meta_description": "Learn step by step how content automation works.",
    "secondary_keywords": ["content pipeline", "SEO workflow"]
  },
  "translations": {
    "en": {
      "title": "How to Set Up Blog Automation",
      "excerpt": "A step-by-step guide to automating your content pipeline.",
      "content_blocks": [
        { "type": "heading-2", "content": "Getting started", "level": 2 },
        { "type": "paragraph", "content": "Blog automation starts with a clear <strong>content pipeline</strong>." },
        {
          "type": "image",
          "src": "https://blog-maker.com/images/articles/pipeline-hero.webp",
          "alt": "A content pipeline diagram",
          "caption": "The pipeline, end to end.",
          "ai_origin": "generated"
        },
        { "type": "list", "items": ["Plan", "Write", "Review", "Publish"], "ordered": true },
        {
          "type": "faq",
          "questions": [
            { "question": "How long does this take?", "answer": "Most brands are live within a day." }
          ]
        }
      ]
    }
  },
  "source": "seo_blog_generator",
  "source_id": "8421"
}

3. Renders each content_blocks entry to HTML for my site. Block types and
   their fields:

- heading-2 … heading-6: content, level : A heading. level is clamped to 2 through 6 on our side, so a translated body never emits a second page H1.
- paragraph: content : Inline HTML is allowed inside content (<strong>, <em>, <a>); treat it as untrusted and sanitize on your side.
- list: items[], ordered : ordered true renders <ol>, otherwise <ul>.
- blockquote: content : Plain quote.
- callout: content : A "good to know" tip box, visually distinct from a plain blockquote.
- table: content : Raw table HTML, passed through as-is.
- image: src, alt, caption, ai_origin : See the AI image disclosure section below for ai_origin.
- divider: (none) : A horizontal rule.
- iframe: src : Trusted embeds only (allowlisted hosts); anything else renders as nothing.
- faq: questions[] ({question, answer}) : A structured FAQ block, meant to also drive your FAQPage schema if you have one.
- html: content : A passthrough for a self-closing block comment we didn't recognize while converting (e.g. an SEO plugin marker). Safe to ignore or drop; nothing depends on you rendering it.

4. For "image" blocks: when ai_origin is "generated", render the image
   wrapped in this exact markup and include this exact CSS (do not change
   the class names, they must stay ai-origin-frame / ai-origin-badge /
   ai-origin-badge--micro), since this is an EU AI Act Art. 50(4) disclosure
   requirement, not just decoration. When ai_origin is absent, render the
   image with no badge at all.

Markup (standard tier for hero/inline images, micro tier for small
thumbnails/listing cards):
<span class="ai-origin-frame">
  <img src="..." alt="..." loading="lazy">
  <span class="ai-origin-badge">AI-generated</span>
</span>

<!-- small thumbnails (listing cards, related grids): -->
<span class="ai-origin-frame">
  <img src="..." alt="..." loading="lazy">
  <span class="ai-origin-badge ai-origin-badge--micro">AI</span>
</span>

CSS (one block, both tiers):
.ai-origin-frame { position: relative; display: block; line-height: 0; }
.ai-origin-frame img { display: block; width: 100%; height: auto; margin: 0; }

.ai-origin-badge {
  position: absolute;
  left: 10px;
  bottom: 10px;
  z-index: 2;
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 4px 9px 4px 5px;
  border-radius: 999px;
  background: rgba(10, 14, 20, 0.55);
  -webkit-backdrop-filter: blur(2px);
  backdrop-filter: blur(2px);
  color: #fff;
  font-size: 10.5px;
  font-weight: 500;
  line-height: 1.3;
  letter-spacing: 0.01em;
  white-space: nowrap;
  pointer-events: none;
}
.ai-origin-badge::before {
  content: "";
  width: 13px;
  height: 13px;
  flex: 0 0 auto;
  background-image: url("data:image/svg+xml,%3Csvg id='Calque_1' data-name='Calque 1' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 566.93 566.93'%3E%3Cdefs%3E%3Cstyle%3E .cls-1 { fill: %23fff; fill-rule: evenodd; } .cls-2 { fill: %231d1d1b; } %3C/style%3E%3C/defs%3E%3Cpath class='cls-1' d='M272.03,100.72c100.92,0,182.74,81.82,182.74,182.75s-81.82,182.74-182.74,182.74-182.75-81.82-182.75-182.74,81.82-182.75,182.75-182.75'/%3E%3Cg%3E%3Cpath class='cls-2' d='M170.79,353.74c-1.08,0-2.05-.43-2.92-1.31-.88-.87-1.31-1.84-1.31-2.92,0-.67.07-1.27.2-1.81l47.34-129.32c.4-1.48,1.24-2.79,2.52-3.93,1.27-1.14,3.05-1.71,5.34-1.71h29.81c2.28,0,4.06.57,5.34,1.71,1.27,1.14,2.11,2.45,2.52,3.93l47.14,129.32c.27.54.4,1.14.4,1.81,0,1.08-.44,2.05-1.31,2.92s-1.91,1.31-3.12,1.31h-24.78c-2.01,0-3.52-.5-4.53-1.51-1.01-1.01-1.65-1.91-1.91-2.72l-7.86-20.55h-53.78l-7.65,20.55c-.27.81-.88,1.71-1.81,2.72-.94,1.01-2.55,1.51-4.83,1.51h-24.78ZM218.13,299.96h37.47l-18.93-53.18-18.53,53.18Z'/%3E%3Cpath class='cls-2' d='M328.11,353.74c-1.48,0-2.69-.47-3.63-1.41-.94-.94-1.41-2.15-1.41-3.63v-130.93c0-1.48.47-2.68,1.41-3.63s2.15-1.41,3.63-1.41h26.99c1.48,0,2.68.47,3.63,1.41.94.94,1.41,2.15,1.41,3.63v130.93c0,1.48-.47,2.69-1.41,3.63-.94.94-2.15,1.41-3.63,1.41h-26.99Z'/%3E%3C/g%3E%3C/svg%3E");
  background-size: contain;
  background-repeat: no-repeat;
}

.ai-origin-badge--micro {
  left: 6px;
  bottom: 6px;
  padding: 2px 6px 2px 5px;
  gap: 4px;
  background: rgba(10, 14, 20, 0.55);
  -webkit-backdrop-filter: blur(1px);
  backdrop-filter: blur(1px);
  opacity: 1;
  font-size: 9px;
  font-weight: 700;
  border-radius: 999px;
}
.ai-origin-badge--micro::before {
  width: 11px;
  height: 11px;
}

/* Add the --right modifier class below when your layout needs the badge in
   the bottom-right corner instead (e.g. a hero with a headline overlay on
   the left). Icon, size and colors stay identical, only the corner flips. */
.ai-origin-badge.ai-origin-badge--right { left: auto; right: 10px; }

5. Store the article, then respond with:
{
  "ok": true,
  "id": "post-8421",
  "url": "https://example.com/blog/how-to-set-up-blog-automation"
}

6. Return HTTP 401 for a missing/wrong Authorization header, HTTP 400 for a
   malformed body, and 2xx only on real success.

ChatGPT / generic

I need a receiver endpoint for blog-maker.com's article publishing API. It
POSTs JSON in a "content_blocks" format (full spec below) to my endpoint,
Bearer-authenticated. Please write it in [YOUR LANGUAGE/FRAMEWORK].

Request shape:
POST https://example.com/api/blog
Authorization: Bearer <your-api-key>
Content-Type: application/json

{
  "slug": "how-to-set-up-blog-automation",
  "status": "published",
  "category": "guides",
  "seo": {
    "focus_keyword": "blog automation",
    "seo_title": "How to Set Up Blog Automation",
    "meta_description": "Learn step by step how content automation works.",
    "secondary_keywords": ["content pipeline", "SEO workflow"]
  },
  "translations": {
    "en": {
      "title": "How to Set Up Blog Automation",
      "excerpt": "A step-by-step guide to automating your content pipeline.",
      "content_blocks": [
        { "type": "heading-2", "content": "Getting started", "level": 2 },
        { "type": "paragraph", "content": "Blog automation starts with a clear <strong>content pipeline</strong>." },
        {
          "type": "image",
          "src": "https://blog-maker.com/images/articles/pipeline-hero.webp",
          "alt": "A content pipeline diagram",
          "caption": "The pipeline, end to end.",
          "ai_origin": "generated"
        },
        { "type": "list", "items": ["Plan", "Write", "Review", "Publish"], "ordered": true },
        {
          "type": "faq",
          "questions": [
            { "question": "How long does this take?", "answer": "Most brands are live within a day." }
          ]
        }
      ]
    }
  },
  "source": "seo_blog_generator",
  "source_id": "8421"
}

Block types to render: heading-2 … heading-6, paragraph, list, blockquote, callout, table, image, divider, iframe, faq, html. Full
field list per type:
heading-2 … heading-6: content, level
paragraph: content
list: items[], ordered
blockquote: content
callout: content
table: content
image: src, alt, caption, ai_origin
divider: (none)
iframe: src
faq: questions[] ({question, answer})
html: content

Special case: an "image" block with "ai_origin": "generated" is a legally
required EU AI Act Art. 50(4) disclosure and must render with this exact
badge markup and CSS (class names must stay exactly as given):
<span class="ai-origin-frame">
  <img src="..." alt="..." loading="lazy">
  <span class="ai-origin-badge">AI-generated</span>
</span>

<!-- small thumbnails (listing cards, related grids): -->
<span class="ai-origin-frame">
  <img src="..." alt="..." loading="lazy">
  <span class="ai-origin-badge ai-origin-badge--micro">AI</span>
</span>

.ai-origin-frame { position: relative; display: block; line-height: 0; }
.ai-origin-frame img { display: block; width: 100%; height: auto; margin: 0; }

.ai-origin-badge {
  position: absolute;
  left: 10px;
  bottom: 10px;
  z-index: 2;
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 4px 9px 4px 5px;
  border-radius: 999px;
  background: rgba(10, 14, 20, 0.55);
  -webkit-backdrop-filter: blur(2px);
  backdrop-filter: blur(2px);
  color: #fff;
  font-size: 10.5px;
  font-weight: 500;
  line-height: 1.3;
  letter-spacing: 0.01em;
  white-space: nowrap;
  pointer-events: none;
}
.ai-origin-badge::before {
  content: "";
  width: 13px;
  height: 13px;
  flex: 0 0 auto;
  background-image: url("data:image/svg+xml,%3Csvg id='Calque_1' data-name='Calque 1' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 566.93 566.93'%3E%3Cdefs%3E%3Cstyle%3E .cls-1 { fill: %23fff; fill-rule: evenodd; } .cls-2 { fill: %231d1d1b; } %3C/style%3E%3C/defs%3E%3Cpath class='cls-1' d='M272.03,100.72c100.92,0,182.74,81.82,182.74,182.75s-81.82,182.74-182.74,182.74-182.75-81.82-182.75-182.74,81.82-182.75,182.75-182.75'/%3E%3Cg%3E%3Cpath class='cls-2' d='M170.79,353.74c-1.08,0-2.05-.43-2.92-1.31-.88-.87-1.31-1.84-1.31-2.92,0-.67.07-1.27.2-1.81l47.34-129.32c.4-1.48,1.24-2.79,2.52-3.93,1.27-1.14,3.05-1.71,5.34-1.71h29.81c2.28,0,4.06.57,5.34,1.71,1.27,1.14,2.11,2.45,2.52,3.93l47.14,129.32c.27.54.4,1.14.4,1.81,0,1.08-.44,2.05-1.31,2.92s-1.91,1.31-3.12,1.31h-24.78c-2.01,0-3.52-.5-4.53-1.51-1.01-1.01-1.65-1.91-1.91-2.72l-7.86-20.55h-53.78l-7.65,20.55c-.27.81-.88,1.71-1.81,2.72-.94,1.01-2.55,1.51-4.83,1.51h-24.78ZM218.13,299.96h37.47l-18.93-53.18-18.53,53.18Z'/%3E%3Cpath class='cls-2' d='M328.11,353.74c-1.48,0-2.69-.47-3.63-1.41-.94-.94-1.41-2.15-1.41-3.63v-130.93c0-1.48.47-2.68,1.41-3.63s2.15-1.41,3.63-1.41h26.99c1.48,0,2.68.47,3.63,1.41.94.94,1.41,2.15,1.41,3.63v130.93c0,1.48-.47,2.69-1.41,3.63-.94.94-2.15,1.41-3.63,1.41h-26.99Z'/%3E%3C/g%3E%3C/svg%3E");
  background-size: contain;
  background-repeat: no-repeat;
}

.ai-origin-badge--micro {
  left: 6px;
  bottom: 6px;
  padding: 2px 6px 2px 5px;
  gap: 4px;
  background: rgba(10, 14, 20, 0.55);
  -webkit-backdrop-filter: blur(1px);
  backdrop-filter: blur(1px);
  opacity: 1;
  font-size: 9px;
  font-weight: 700;
  border-radius: 999px;
}
.ai-origin-badge--micro::before {
  width: 11px;
  height: 11px;
}

/* Add the --right modifier class below when your layout needs the badge in
   the bottom-right corner instead (e.g. a hero with a headline overlay on
   the left). Icon, size and colors stay identical, only the corner flips. */
.ai-origin-badge.ai-origin-badge--right { left: auto; right: 10px; }

Expected success response:
{
  "ok": true,
  "id": "post-8421",
  "url": "https://example.com/blog/how-to-set-up-blog-automation"
}

Please also handle: wrong/missing Bearer token -> 401, malformed JSON body
-> 400, and make repeated POSTs with the same "slug" update the existing
article instead of creating duplicates.

Machine-readable version

A plain-text copy of this contract lives at a stable URL, meant for an agent to fetch directly: https://blog-maker.com/docs/content-blocks.md