ServAEO CMS Content Engine

Publish API contract 1.0

Give this page to the team that owns the destination CMS. The engine POSTs a self-contained JSON article to your ingest URL. You host the public page. The engine never does.

Download sample JSON

HTTP request

The engine sends one POST per article. There is no HMAC, signature, or timestamp header.

POST {your-ingest-url}
Content-Type: application/json
Authorization: Bearer {destination-secret}
X-Idempotency-Key: {32-char-hex-article-id}
ItemValue
MethodPOST
This stand-inhttp://splash.basespin.com/ingest (also accepts POST /)
Content-Typeapplication/json
AuthorizationRequired. Bearer plus the secret issued for this destination.
X-Idempotency-KeyRequired. 32-character hex. Same value as idempotencyKey in the body. Same key means update in place, not a second article.
Content-EncodingPresent and set to gzip only when this destination has gzip enabled. The JSON is the same after inflate.
Extra headersOptional string headers configured on the destination. Ignore unknown headers.
X-ServAEO-TestOptional. Value 1 on operator test POSTs. Treat like a real publish if you want, or store separately. Tests should still return the same status codes.
Max body26,214,400 bytes (25 MiB) on this stand-in. Production uses the destination maxBodyBytes (default 25 MiB). Oversize: 413.

Authentication

  • Bearer only. Compare the token with a fixed-time equals.
  • Missing, wrong scheme, or wrong secret: 401 with { "error": "..." }.
  • Do not accept query-string secrets, Basic auth, or unsigned requests.

JSON payload

Root object, camelCase, schemaVersion always 1.0. Unknown properties should be ignored so later additive fields do not break you.

{
  schemaVersion,      // "1.0"
  idempotencyKey,     // 32-char hex, same as X-Idempotency-Key
  publishedAt,        // ISO-8601 with offset, e.g. 2026-08-22T16:00:00.0000000+00:00
  offering:  { id, name, category, subcategory, ctaUrl },
  article:   { id, title, slug, summary, bodyHtml },
  seo:       { metaTitle, metaDescription, canonicalPath, robots, keywords, imageRole, openGraph, twitter },
  jsonLd:    { Article + ImageObject + citations },
  images:    [ { role, alt, caption, contentType, bytesBase64 } ],
  sources:   [ { label, url } ]
}

Minimum fields this stand-in validates: article.title and article.slug. Implement the full object anyway. Production articles always send every key above.

Field reference

Root

FieldTypeNotes
schemaVersionstringAlways 1.0.
idempotencyKeystring32-char lowercase hex of the article GUID. Use this (or the header) as your unique key. Replay updates the same record.
publishedAtstringRound-trip ISO-8601 (O format). Use for <time>, Open Graph, and JSON-LD dates.

offering

FieldTypeNotes
idstringGUID with hyphens.
namestringDisplay name. Used as kicker / site name / publisher.
categorystring or nullBroad topic.
subcategorystring or nullPreferred section label when present.
ctaUrlstring or nullAbsolute Learn more URL. Also present in bodyHtml when set.

article

FieldTypeNotes
idstringGUID with hyphens. Different format than idempotencyKey.
titlestringRequired. Page title / H1 source.
slugstringRequired. URL segment only. No leading slash.
summarystring or nullShort standfirst. Also used as default meta description.
bodyHtmlstringInner HTML only. You wrap <article>. See bodyHtml rules.

seo

FieldTypeNotes
metaTitlestringUse in <title>.
metaDescriptionstring or nullUse in name="description".
canonicalPathstringSite-relative, {canonicalPathPrefix}/{slug}, e.g. /answers/how-does-a-heat-pump-work. Prefix comes from the destination (default /answers). Build the public URL as https://{your-host}{canonicalPath}.
robotsstringAlways index,follow.
keywordsstring[]May be empty. Split from operator keywords.
imageRolestring or nullhero when a hero image exists, otherwise null.
openGraphobjectKeys are the literal meta property names.
openGraph["og:type"]stringarticle.
openGraph["og:title"]string
openGraph["og:description"]string or null
openGraph["og:site_name"]string or nullOffering display name.
openGraph["og:url"]stringSame site-relative path as canonicalPath. Prefix your host when emitting the tag.
openGraph["og:locale"]stringBCP 47 with underscore: en-US becomes en_US.
openGraph["article:published_time"]stringISO-8601.
openGraph["article:modified_time"]stringISO-8601. Same as published on first delivery.
openGraph["article:section"]string or nullSubcategory, else category.
openGraph["og:image"]nullAlways null on the wire. You fill this after storing the image.
openGraph["og:image:alt"]string or null
openGraph["og:image:type"]stringMIME type, default image/png.
twitter["twitter:card"]stringsummary_large_image with a hero, else summary.
twitter["twitter:title"]string
twitter["twitter:description"]string or null
twitter["twitter:image"]nullAlways null on the wire. You fill this after storing the image.
twitter["twitter:image:alt"]string or null

jsonLd

Ready-to-emit schema.org Article. Put it in <script type="application/ld+json"> after you fill image.contentUrl and turn path fields into absolute URLs.

FieldTypeNotes
@contextstringhttps://schema.org
@typestringArticle
headline / namestringArticle title (not the SEO title override).
urlstringSite-relative canonical path. Prefix your host.
inLanguagestringen-US
isAccessibleForFreebooleantrue
datePublished / dateModifiedstringISO-8601
descriptionstring or nullMeta description, else summary.
keywordsstringComma-separated, not an array.
publisherobject{ "@type": "Organization", "name": "{offering.name}" }
mainEntityOfPageobject{ "@type": "WebPage", "@id": "{canonicalPath}" }. Prefix your host on @id.
imageobjectImageObject. contentUrl is always null on the wire.
image.contentUrlnullYou set this to the public asset URL.
image.namestring or nullAlt text.
image.captionstring or null
image.encodingFormatstringMIME type, default image/png.
image.representativeOfPagebooleantrue
image.rolestring or nullhero or null.
citationarrayEach item: { "@type": "CreativeWork", "name", "url" }. May be empty.

images[]

FieldTypeNotes
rolestringNormalized: cover becomes hero; chart / diagram become infographic. Empty role becomes hero.
altstring or null
captionstring or null
contentTypestring or nulle.g. image/png, image/jpeg, image/webp.
bytesBase64stringRaw image bytes, standard Base64. Decode and store as a file. Do not keep the Base64 on the public page.

The array may be empty. Only images with bytes are included.

sources[]

FieldTypeNotes
labelstringAnchor text.
urlstringAbsolute URL. Also listed under #sources in bodyHtml and in jsonLd.citation.

bodyHtml rules

article.bodyHtml is the inner HTML of the page. Wrap it yourself:

<article>
  {bodyHtml}
</article>

The engine already:

  • Converts markdown to HTML unless the body already looks like HTML (starts with < and contains >).
  • Strips wrapping <article> tags and a leading markdown # title.
  • Prepends a <header> (optional kicker, <h1>, optional summary, <time>) only when no <h1> exists.
  • Injects missing role figures: hero before the body, other roles after. <img src=""> is intentional.
  • Appends <section id="sources"> when sources exist and the body does not already have one.
  • Appends one Learn more CTA (p.cms-cta) when offering.ctaUrl is set and not already in the HTML.
  • Appends p.cms-disclaimer only when the offering has a disclaimer.

You may style .kicker, .summary, .cms-cta, .cms-disclaimer, and #sources. Do not strip them.

Images and URL rewrite

Image URL slots stay null on the wire so you can assign your own public URLs.

  1. For each images[] entry, decode bytesBase64 and store it (use contentType for the extension).
  2. Set seo.openGraph["og:image"], seo.twitter["twitter:image"], and jsonLd.image.contentUrl to that public URL (hero / first image).
  3. Replace empty src="" on matching data-image-role images in bodyHtml with the same URL.

Figures look like:

<figure data-image-role="hero">
  <img data-image-role="hero" src="" alt="...">
  <figcaption>...</figcaption>
</figure>

Responses you must return

The engine treats any 2xx as delivered. If the body is JSON it reads optional id and url for the delivery ledger.

StatusWhenBody
201 First time this idempotency key is stored { "id": "{idempotencyKey}", "url": "/your/public/path" }
200 Same key already exists (update in place) { "replayed": true, "id": "{idempotencyKey}", "url": "/your/public/path" }
401 Missing or wrong bearer { "error": "Invalid or missing bearer token" }
413 Body larger than your max { "error": "Body too large" }
422 Not JSON, or missing article.title / article.slug { "error": "article.title and article.slug are required" }

Five failed attempts or an oversize payload marks the engine delivery dead. Do not return 2xx unless the article is stored and publishable.

Implementation checklist

  1. Expose POST at the URL you give ServAEO (HTTPS in production).
  2. Require Authorization: Bearer {secret} with a fixed-time compare. Reject others with 401.
  3. Read X-Idempotency-Key. Upsert by that key. First write 201, later writes 200 with replayed: true.
  4. If Content-Encoding: gzip, inflate before parsing JSON.
  5. Reject bodies over your size cap with 413.
  6. Parse JSON. Require article.title and article.slug or return 422.
  7. Persist the full payload (or the fields you render).
  8. Decode images[].bytesBase64, store files, rewrite image URL slots and empty src="".
  9. Publish at https://{host}{seo.canonicalPath}.
  10. Emit <title>, description, robots, canonical, Open Graph, Twitter, and JSON-LD. Prefix site-relative paths with your host.
  11. Render <article>{bodyHtml}</article>. Do not wrap a second <article> if you already have one in your template around the injection point only.
  12. Return JSON with id and public url on success.

Complete sample

This is a full 1.0 POST body, including a 1x1 PNG in bytesBase64. Real images are larger. Open raw JSON.

{
  "schemaVersion": "1.0",
  "idempotencyKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
  "publishedAt": "2026-08-22T16:00:00.0000000+00:00",
  "offering": {
    "id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
    "name": "Sample offering",
    "category": "HVAC",
    "subcategory": "Heat pumps",
    "ctaUrl": "https://example.com/learn"
  },
  "article": {
    "id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
    "title": "How does a heat pump work?",
    "slug": "how-does-a-heat-pump-work",
    "summary": "A heat pump moves heat rather than generating it.",
    "bodyHtml": "<header><p class=\"kicker\">Sample offering</p><h1>How does a heat pump work?</h1><p class=\"summary\">A heat pump moves heat rather than generating it.</p><time datetime=\"2026-08-22T16:00:00.0000000+00:00\">2026-08-22</time></header><figure data-image-role=\"hero\"><img data-image-role=\"hero\" src=\"\" alt=\"A heat pump outdoor unit\"><figcaption>Typical air-source heat pump</figcaption></figure><p>A heat pump moves heat from one place to another.</p>\n<h2>Key takeaways</h2>\n<ul>\n<li>It transfers heat</li>\n<li>It can heat or cool</li>\n</ul>\n<h2>FAQ</h2>\n<p><strong>What is a heat pump?</strong> A device that moves heat.</p><section id=\"sources\"><h2>Sources</h2><ol><li><a href=\"https://example.com/doe-heat-pumps\" rel=\"nofollow noopener\">DOE heat pumps</a></li></ol></section><p class=\"cms-cta\"><a href=\"https://example.com/learn\">Learn more</a></p>"
  },
  "seo": {
    "metaTitle": "How does a heat pump work?",
    "metaDescription": "A heat pump moves heat rather than generating it.",
    "canonicalPath": "/answers/how-does-a-heat-pump-work",
    "robots": "index,follow",
    "keywords": ["heat pump", "HVAC"],
    "imageRole": "hero",
    "openGraph": {
      "og:type": "article",
      "og:title": "How does a heat pump work?",
      "og:description": "A heat pump moves heat rather than generating it.",
      "og:site_name": "Sample offering",
      "og:url": "/answers/how-does-a-heat-pump-work",
      "og:locale": "en_US",
      "article:published_time": "2026-08-22T16:00:00.0000000+00:00",
      "article:modified_time": "2026-08-22T16:00:00.0000000+00:00",
      "article:section": "Heat pumps",
      "og:image": null,
      "og:image:alt": "A heat pump outdoor unit",
      "og:image:type": "image/png"
    },
    "twitter": {
      "twitter:card": "summary_large_image",
      "twitter:title": "How does a heat pump work?",
      "twitter:description": "A heat pump moves heat rather than generating it.",
      "twitter:image": null,
      "twitter:image:alt": "A heat pump outdoor unit"
    }
  },
  "jsonLd": {
    "@context": "https://schema.org",
    "@type": "Article",
    "headline": "How does a heat pump work?",
    "name": "How does a heat pump work?",
    "url": "/answers/how-does-a-heat-pump-work",
    "inLanguage": "en-US",
    "isAccessibleForFree": true,
    "datePublished": "2026-08-22T16:00:00.0000000+00:00",
    "dateModified": "2026-08-22T16:00:00.0000000+00:00",
    "description": "A heat pump moves heat rather than generating it.",
    "keywords": "heat pump, HVAC",
    "publisher": {
      "@type": "Organization",
      "name": "Sample offering"
    },
    "mainEntityOfPage": {
      "@type": "WebPage",
      "@id": "/answers/how-does-a-heat-pump-work"
    },
    "image": {
      "@type": "ImageObject",
      "contentUrl": null,
      "name": "A heat pump outdoor unit",
      "caption": "Typical air-source heat pump",
      "encodingFormat": "image/png",
      "representativeOfPage": true,
      "role": "hero"
    },
    "citation": [
      {
        "@type": "CreativeWork",
        "name": "DOE heat pumps",
        "url": "https://example.com/doe-heat-pumps"
      }
    ]
  },
  "images": [
    {
      "role": "hero",
      "alt": "A heat pump outdoor unit",
      "caption": "Typical air-source heat pump",
      "contentType": "image/png",
      "bytesBase64": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
    }
  ],
  "sources": [
    {
      "label": "DOE heat pumps",
      "url": "https://example.com/doe-heat-pumps"
    }
  ]
}