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.
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}
| Item | Value |
|---|---|
| Method | POST |
| This stand-in | http://splash.basespin.com/ingest (also accepts POST /) |
| Content-Type | application/json |
| Authorization | Required. Bearer plus the secret issued for this destination. |
| X-Idempotency-Key | Required. 32-character hex. Same value as idempotencyKey in the body. Same key means update in place, not a second article. |
| Content-Encoding | Present and set to gzip only when this destination has gzip enabled. The JSON is the same after inflate. |
| Extra headers | Optional string headers configured on the destination. Ignore unknown headers. |
| X-ServAEO-Test | Optional. 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 body | 26,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:
401with{ "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
| Field | Type | Notes |
|---|---|---|
schemaVersion | string | Always 1.0. |
idempotencyKey | string | 32-char lowercase hex of the article GUID. Use this (or the header) as your unique key. Replay updates the same record. |
publishedAt | string | Round-trip ISO-8601 (O format). Use for <time>, Open Graph, and JSON-LD dates. |
offering
| Field | Type | Notes |
|---|---|---|
id | string | GUID with hyphens. |
name | string | Display name. Used as kicker / site name / publisher. |
category | string or null | Broad topic. |
subcategory | string or null | Preferred section label when present. |
ctaUrl | string or null | Absolute Learn more URL. Also present in bodyHtml when set. |
article
| Field | Type | Notes |
|---|---|---|
id | string | GUID with hyphens. Different format than idempotencyKey. |
title | string | Required. Page title / H1 source. |
slug | string | Required. URL segment only. No leading slash. |
summary | string or null | Short standfirst. Also used as default meta description. |
bodyHtml | string | Inner HTML only. You wrap <article>. See bodyHtml rules. |
seo
| Field | Type | Notes |
|---|---|---|
metaTitle | string | Use in <title>. |
metaDescription | string or null | Use in name="description". |
canonicalPath | string | Site-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}. |
robots | string | Always index,follow. |
keywords | string[] | May be empty. Split from operator keywords. |
imageRole | string or null | hero when a hero image exists, otherwise null. |
openGraph | object | Keys are the literal meta property names. |
openGraph["og:type"] | string | article. |
openGraph["og:title"] | string | |
openGraph["og:description"] | string or null | |
openGraph["og:site_name"] | string or null | Offering display name. |
openGraph["og:url"] | string | Same site-relative path as canonicalPath. Prefix your host when emitting the tag. |
openGraph["og:locale"] | string | BCP 47 with underscore: en-US becomes en_US. |
openGraph["article:published_time"] | string | ISO-8601. |
openGraph["article:modified_time"] | string | ISO-8601. Same as published on first delivery. |
openGraph["article:section"] | string or null | Subcategory, else category. |
openGraph["og:image"] | null | Always null on the wire. You fill this after storing the image. |
openGraph["og:image:alt"] | string or null | |
openGraph["og:image:type"] | string | MIME type, default image/png. |
twitter["twitter:card"] | string | summary_large_image with a hero, else summary. |
twitter["twitter:title"] | string | |
twitter["twitter:description"] | string or null | |
twitter["twitter:image"] | null | Always 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.
| Field | Type | Notes |
|---|---|---|
@context | string | https://schema.org |
@type | string | Article |
headline / name | string | Article title (not the SEO title override). |
url | string | Site-relative canonical path. Prefix your host. |
inLanguage | string | en-US |
isAccessibleForFree | boolean | true |
datePublished / dateModified | string | ISO-8601 |
description | string or null | Meta description, else summary. |
keywords | string | Comma-separated, not an array. |
publisher | object | { "@type": "Organization", "name": "{offering.name}" } |
mainEntityOfPage | object | { "@type": "WebPage", "@id": "{canonicalPath}" }. Prefix your host on @id. |
image | object | ImageObject. contentUrl is always null on the wire. |
image.contentUrl | null | You set this to the public asset URL. |
image.name | string or null | Alt text. |
image.caption | string or null | |
image.encodingFormat | string | MIME type, default image/png. |
image.representativeOfPage | boolean | true |
image.role | string or null | hero or null. |
citation | array | Each item: { "@type": "CreativeWork", "name", "url" }. May be empty. |
images[]
| Field | Type | Notes |
|---|---|---|
role | string | Normalized: cover becomes hero; chart / diagram become infographic. Empty role becomes hero. |
alt | string or null | |
caption | string or null | |
contentType | string or null | e.g. image/png, image/jpeg, image/webp. |
bytesBase64 | string | Raw 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[]
| Field | Type | Notes |
|---|---|---|
label | string | Anchor text. |
url | string | Absolute 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) whenoffering.ctaUrlis set and not already in the HTML. - Appends
p.cms-disclaimeronly 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.
- For each
images[]entry, decodebytesBase64and store it (usecontentTypefor the extension). - Set
seo.openGraph["og:image"],seo.twitter["twitter:image"], andjsonLd.image.contentUrlto that public URL (hero / first image). - Replace empty
src=""on matchingdata-image-roleimages inbodyHtmlwith 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.
| Status | When | Body |
|---|---|---|
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
- Expose
POSTat the URL you give ServAEO (HTTPS in production). - Require
Authorization: Bearer {secret}with a fixed-time compare. Reject others with401. - Read
X-Idempotency-Key. Upsert by that key. First write201, later writes200withreplayed: true. - If
Content-Encoding: gzip, inflate before parsing JSON. - Reject bodies over your size cap with
413. - Parse JSON. Require
article.titleandarticle.slugor return422. - Persist the full payload (or the fields you render).
- Decode
images[].bytesBase64, store files, rewrite image URL slots and emptysrc="". - Publish at
https://{host}{seo.canonicalPath}. - Emit
<title>, description, robots, canonical, Open Graph, Twitter, and JSON-LD. Prefix site-relative paths with your host. - Render
<article>{bodyHtml}</article>. Do not wrap a second<article>if you already have one in your template around the injection point only. - Return JSON with
idand publicurlon 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"
}
]
}