GeoVector Integration Guide

The GeoVector CMS Protocol

Implement four HTTP handlers so GeoVector can publish, update, read back, and retract articles on your site — no plugin, CMS vendor, or shared database required.

protocol: geovector-cms version: 1 transport: HTTPS + JSON auth: static Bearer document revision: 3 (2026-08-07) 简体中文版本

1. How it works

GeoVector pushes generated articles to your site over HTTPS. You own storage, rendering, routing, and caching; GeoVector owns generation and measurement. The only coupling is one JSON contract on one path.

GeoVector                                   Your website
─────────                                   ────────────
generate article
        │
        │  POST   /api/publish        (upsert by slug)   ──►  store + return public URL
        │  GET    /api/publish?slug=  (exists?)          ──►  { exists }
        │  GET    /api/publish?slug=&include=content     ──►  stored HTML (drift check)
        │  DELETE /api/publish        (unpublish)        ──►  { action }
        │  GET    /api/publish/meta   (capabilities)     ──►  categories, default, path shape
        ▼
measure AI visibility of the published URL

The essentials:

  • The slug is the primary key. POST is an upsert. Publishing the same slug must update in place, never create a duplicate.
  • Your POST response defines the canonical URL. Whatever returned url is what GeoVector monitors and attributes citations to. Return the final URL a human reaches after redirects.
  • You are the source of truth for content. GeoVector reads your stored HTML back to detect edits made on your side.
  • Storage is entirely yours. A database row, a Markdown file plus a git commit, or a headless-CMS API call all work.
Path is fixed

All operations live at {siteUrl}/api/publish and {siteUrl}/api/publish/meta. These paths are fixed in v1; use a rewrite if needed.

2. Authentication

Every request — including /meta — carries a static bearer token:

Authorization: Bearer <your-token>
  • You generate the token, not us. Use ≥32 bytes of CSPRNG output: openssl rand -hex 32.
  • Store it in your deployment's environment (e.g. CMS_PUBLISH_TOKEN). Never commit it, never expose it to the browser bundle.
  • Compare it in constant time. A naive === on a secret is a timing oracle.
  • Paste it into GeoVector at connection time. We encrypt it at rest (AES-256-GCM) and send it only to the configured host.
  • Reject anything unauthenticated with 401 and a body of {"error":"unauthorized"}. Do not reveal whether the slug exists to an unauthenticated caller.
Rotation

Accept CMS_PUBLISH_TOKEN and CMS_PUBLISH_TOKEN_PREVIOUS. Rotate by adding the new token, updating GeoVector, then removing the old one.

Redirects strip credentials

Configure the canonical host. GeoVector re-attaches the bearer after a redirect only within the same registrable domain and never after an HTTPS downgrade. Other redirects fail as redirect_offsite.

3. Endpoint reference

POST /api/publish — create or update

Request body (application/json):

{
  "title":    "How AI Assistants Choose Sources",
  "slug":     "how-ai-assistants-choose-sources",
  "category": "guide",
  "date":     "2026-08-06",
  "featured": false,
  "content":  "<article>…</article>",
  "format":   "html",

  "excerpt":      "One-paragraph summary used for cards and meta description.",
  "author":       "Acme Research",
  "readTime":     "7 min read",
  "tags":         ["ai-search", "citations"],
  "dateModified": "2026-08-06",
  "thumbnail":    { "gradientFrom": "#10b981", "gradientTo": "#0284c7" },
  "imageBase64":  "iVBORw0KGgo…"
}

Success response — 200:

{ "action": "created", "url": "https://acme.com/articles/how-ai-assistants-choose-sources" }
Two rules that matter more than the rest

Return an absolute url. It becomes the address GeoVector monitors for AI citations. A relative path, or a URL that then 301s somewhere else, degrades every downstream measurement.

Set the publication date once. On update, keep the original date and write dateModified instead. Re-dating an article to "today" on every edit damages its search and AI-retrieval standing.

GET /api/publish?slug=… — existence probe

Response 200: { "exists": true } or { "exists": false }. Missing slug400 {"error":"slug_required"}.

This doubles as GeoVector's connection test. At setup we call it with the reserved slug __verify__; answering {"exists": false} is the correct, successful response.

GET /api/publish?slug=…&include=content — read back

{ "exists": true, "content": "<article>…</article>", "format": "html" }
{ "exists": false }

Return the HTML exactly as stored. GeoVector compares a whitespace-normalised SHA-256 hash with the HTML sent at publish time to detect outside edits. Rewriting on ingest makes every article look modified, so validate then store byte-for-byte (see §9). If rewriting is unavoidable, tell us so we can hash your stored form.

DELETE /api/publish — unpublish

Body { "slug": "…" }. Response { "action": "deleted" } or { "action": "not_found" }. Both are 200 — a delete of something already gone is a success, not an error. Removing it from your index and serving 410 (or 404) at the URL is preferable to leaving an orphan page.

GET /api/publish/meta — advertise your options

{
  "protocol": "geovector-cms",
  "version": "1",
  "categories": ["guide", "comparison", "industry", "research"],
  "defaultCategory": "guide",
  "articlePathTemplate": "/articles/{slug}",
  "capabilities": {
    "protocolVersions": ["1"],
    "supportsDelete": true,
    "maxBodyBytes": 10485760,
    "imageMimeTypes": ["image/png", "image/webp"],
    "locales": ["en"],
    "fields": ["excerpt", "tags", "metaDescription", "canonicalUrl"]
  }
}

GeoVector fetches and caches this at connection time. Categories become a dropdown; articlePathTemplate is display-only, while the POST response remains the URL authority. A 404 falls back to free-text categories.

capabilities — say what you cannot do

This object and all its fields are optional. Declare limits at setup so they are not discovered only when publishing.

FieldTypeMeaning when absent
protocolVersionsstring[]["1"]
supportsDeletebooleanYes — DELETE is in the v1 baseline. Send false if you are append-only.
maxBodyBytesintegerUnknown. Declares the ceiling behind your 413.
imageMimeTypesstring[]Unknown. An explicit [] means you accept no images — say that instead of dropping them quietly.
localesstring[]Unknown. Names the locales you can actually route.
fieldsstring[]Unknown. Names the optional payload fields you actually persist, so a sender can tell which parts of an article survive the trip.
Absence means unknown, never unsupported

A missing field or object means “assume the v1 baseline.” Omit the object rather than sending {}, and never advertise fields you silently discard.

What GeoVector acts on today

GeoVector currently changes behaviour only for categories, defaultCategory, and articlePathTemplate. Fields inside capabilities are validated but advisory: for example, supportsDelete: false does not yet hide Unpublish. Declare them accurately for future enforcement.

Status codes

CodeWhenBody
200Success, including not_found on deleteoperation-specific
400Body is not JSON at all{"error":"invalid_json"}
400JSON that violates the field schema{"error":"invalid_payload","issues":{…}}
400GET without a slug query parameter{"error":"slug_required"}
400HTML outside your content policy{"error":"unsafe_content","violations":[…]}
401Missing or wrong bearer{"error":"unauthorized"}
404/api/publish/meta only, when you advertise nothing{"error":"meta_not_advertised"}
413Body over your size limit{"error":"payload_too_large"}
429Rate limited — include Retry-After{"error":"rate_limited"}
500Your storage layer threw{"error":"storage_error"}

Keep 500 bodies generic. Log the exception on your side; do not echo database errors back over the wire.

Your status code decides what the customer sees

Status codes determine the integration-health verdict shown in the customer's dashboard:

  • 400the article's fault. The publish fails; integration health is unchanged.
  • 401 / 403the credentials' fault. The connection is marked failing.
  • 5xx, timeout, or unreachable host — your site's fault. The connection is marked failing and your response body is shown, trimmed to 500 characters.

The next successful request clears the mark. Return 400, not 500, for rejected payloads.

We send once

Each publish gets one request, no retry, with a 30-second deadline. A failure requires a person to publish again, so move slow rebuilds and image work out of the request. GeoVector does not yet honor Retry-After; 429 fails the publish without marking the site unhealthy.

4. Article fields

FieldTypeReq.Notes
titlestringMUSTPlain text, not HTML. The body already contains its own <h1>.
slugstringMUSTPrimary key; matches ^[a-z0-9][a-z0-9-]*$. Accept up to 200 characters and validate on every verb.
categorystringMUSTOne of the values you advertise in /meta. Fall back to your default rather than rejecting an unknown one.
datestringMUSTISO-8601: 2026-08-06 or 2026-08-06T09:30:00Z. Reject long-form dates and impossible days such as 2026-02-30.
contentstringMUSTThe article as an HTML fragment. When the article has a Q&A section, a trailing <script type="application/ld+json"> FAQPage block follows the closing </article> — render it into the page as-is. See §10.
format"html"MUSTLiteral. Reject other values so a future format cannot be silently mis-stored.
featuredbooleanMAYDefaults false. Honour it or ignore it.
excerptstringSHOULDUse for card summaries and <meta name="description">. Derive from the first paragraph if absent.
authorstringSHOULDDisplay name. Default to your brand.
readTimestringMAYPre-formatted ("7 min read"). Compute at ~200 wpm if absent.
tagsstring[]MAYFree-form.
dateModifiedstringSHOULDISO-8601, same rules as date. Emit in your Article JSON-LD. Stamp now on update if absent.
metaDescriptionstringSHOULD<meta name="description">, up to 500 characters. Fall back to excerpt when absent. Plain text, like title — HTML-escape it when you render it into an attribute.
canonicalUrlstringSHOULDAbsolute http(s) URL for syndicated content. Absence means self-canonical. See the URL rules below.
ogImageUrlstringMAYAbsolute http(s) hosted image URL. A sender supplies this or imageBase64, not both.
localestringMAYBCP-47 tag (en, en-GB, zh-Hans) for <html lang>. Declare supported values in capabilities.locales.
noindexbooleanMAY<meta name="robots" content="noindex">, and leave the page out of your sitemap. Absence means index. Treating absence as noindex would bury every article published by a sender that predates this field.
thumbnailobjectMAY{gradientFrom, gradientTo} hex pair for card art on sites without hero images.
imageBase64stringMAYRaw base64 (no data-URI prefix), no MIME type declared. Sniff the magic bytes, cap the decoded size, store it in object storage, and use it as the OG image. Dropping it is acceptable in v1.
Unknown fields

Ignore unknown fields; optional fields are added without changing protocol v1.

The one field you will not see is jsonLd. Structured data travels inline in content, as a <script type="application/ld+json"> block the content policy already validates. A parallel field would mean you merging two sources and us choosing between them.

URL fields are attacker-reachable — a parser is not a validator

For canonicalUrl and ogImageUrl, require an absolute http or https URL. A scheme check alone is unsafe: many parsers accept raw characters that can escape an HTML attribute. Reject any character below before storing the value.

Rejected anywhere in a URL fieldWhy
" ' `Closes the quote around href="…", so the rest of the value is rendered as markup rather than as part of the URL.
< >Opens a tag directly, which is enough on its own if the attribute was written unquoted.
Any whitespace, including U+00A0Splits one attribute into two and hands the second half to whoever supplied the URL. Your language's \s class already covers the full set.
Any control character, U+0000U+001F and U+007FNever appears unescaped in a real URL, and truncates or reframes the value in some parsers, log sinks and terminals.

Reject rather than rewrite; rewriting hides invalid input.

5. Building it in another language

For non-Node stacks, implement the four handlers from these stable, language-neutral files:

FileWhat it is
geovector-cms.openapi.yamlOpenAPI 3.1 description of every endpoint, status code and error body. Normative — where this guide and the spec disagree, the spec is right. Most languages can generate server stubs and request validators from it.
article-html-policy.v1.jsonThe HTML allowlist and structural rules, as data. Generated from the same constants our own receiver enforces, so it cannot fall behind them.
html-policy-conformance.v1.jsonTest vectors for that policy: {id, why, html, expect}. Run every one through your content check.
verify-receiver.shPOSIX shell and curl. Exercises the whole contract against your live endpoint and tells you what is wrong.
curl -sO https://www.geovector.ai/docs/cms/geovector-cms.openapi.yaml
curl -sO https://www.geovector.ai/docs/cms/article-html-policy.v1.json
curl -sO https://www.geovector.ai/docs/cms/html-policy-conformance.v1.json
curl -sO https://www.geovector.ai/docs/cms/verify-receiver.sh && chmod +x verify-receiver.sh

Re-fetch before releases, especially the generated policy file.

What you actually have to write

Most of the contract maps directly to framework features. The HTML policy needs care:

PieceEffortNotes
Routing, JSON parsing, status codesTrivialGenerate it from the OpenAPI spec.
Slug validationTrivialOne regex, applied on POST, GET and DELETE.
Constant-time token compareTrivialYour standard library already has it — see below.
Body size capSmallMost frameworks have a native request-size limit. Make sure it fires before your JSON parser runs.
Date validationSmallMatch the ISO-8601 shape, then check the day exists. Most standard-library parsers accept 2026-02-30 and hand you 2 March; the format check alone will not catch it.
URL field validationTrivialRequire scheme http/https on canonicalUrl and ogImageUrl, and reject every character in the §4 table from the raw value — the scheme match is case-insensitive, the character rules are not optional. Parsing alone is not validation — see the callout under §4.
The HTML content policyThe real workA scanner over the allowlist table. Perhaps a hundred lines. This is the one to take seriously, and the one the conformance vectors exist for.
Do not skip the content policy

You render remotely generated HTML on your domain. Without the allowlist, a leaked API key becomes stored XSS.

Writing the scanner

Read article-html-policy.v1.json and enforce it directly. The shape: elements maps each allowed element to the attributes it may carry beyond globalAttributes; urlAttributes lists the ones whose value must pass the url rules; jsonLd describes the single permitted <script> form; structure covers markup shape.

Four non-obvious rules have matching test vectors:

  1. Every < must begin a well-formed allowed tag. Reject bare less-than characters.
  2. Decode entities and strip control characters before checking URL schemes. Browsers resolve forms such as &#106;avascript:.
  3. Refuse unquoted attribute values. Tokenizers disagree about ambiguous markup such as href=x onclick=y.
  4. Do not feed script content to the tag scanner. For permitted JSON-LD, forbid < in the body to prevent an early </script> breakout.

Use own-key lookups for allowed elements; inherited keys such as toString and constructor must fail. Two conformance vectors cover this.

Validate, do not sanitize

Reject content outside the policy. Do not strip the offending markup and store the rest.

Rewriting breaks byte-based drift detection, and hand-written stripping can create unsafe leftovers. Reject the whole payload instead.

Constant-time token comparison

Compare digests, not the raw strings — a comparison that returns early on a length mismatch leaks the token's length. Your standard library has this:

PHP       hash_equals(hash('sha256', $expected), hash('sha256', $given))
Python    hmac.compare_digest(sha256(expected).digest(), sha256(given).digest())
Go        subtle.ConstantTimeCompare(e[:], g[:])   // e, g := sha256.Sum256(...)
Ruby      OpenSSL.secure_compare(Digest::SHA256.digest(expected), ...)
Java      MessageDigest.isEqual(sha256(expected), sha256(given))
Node      crypto.timingSafeEqual(sha256(expected), sha256(given))

Order of checks

This order is not cosmetic. Each step exists to keep the next one from running on input it cannot handle safely:

1. Bearer auth                    -> 401
2. Body size, before parsing      -> 413
3. JSON parse                     -> 400 invalid_json
4. Schema, including slug pattern -> 400 invalid_payload
5. Content policy                 -> 400 unsafe_content
6. Store

Then check your work

chmod +x verify-receiver.sh
./verify-receiver.sh https://your-site.com "$YOUR_TOKEN"

It publishes and deletes geovector-conformance-check and verifies unsafe HTML is refused. Use --read-only only when writes are impossible; run the full test before launch.

The rest of this guide still applies

Sections 8–16 are language-neutral. Sections 6–7 are worked JavaScript examples.

6. Reference implementation — Next.js App Router

Complete: paste into app/api/publish/route.ts, implement storage (§8), set CMS_PUBLISH_TOKEN. It includes the hardening from §9 — do not strip it out. The only dependency is the HTML allowlist check: checkContent is defined in §9 and is the one place we recommend a library rather than hand-written code, because writing your own HTML tokenizer is how sanitizers get broken.

// app/api/publish/route.ts
import { timingSafeEqual, createHash } from 'node:crypto';
import { storage } from '@/lib/cms-storage';

export const dynamic = 'force-dynamic';   // never cache a mutation endpoint

const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
const MAX_SLUG = 200;             // senders cap at 100; leave headroom
const MAX_BODY_BYTES = 10 * 1024 * 1024;  // 10 MB, matching our reference receiver
const CATEGORIES = ['guide', 'comparison', 'industry', 'research'] as const;
const DEFAULT_CATEGORY = 'guide';
const SITE = 'https://acme.com';

const json = (status: number, body: unknown) =>
  new Response(JSON.stringify(body), {
    status,
    headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
  });

/** Constant-time bearer check over fixed-length digests (no length leak). */
function authorized(req: Request): boolean {
  const header = req.headers.get('authorization') ?? '';
  const m = header.match(/^Bearer\s+(.+)$/);
  if (!m) return false;
  const tokens = [process.env.CMS_PUBLISH_TOKEN, process.env.CMS_PUBLISH_TOKEN_PREVIOUS]
    .filter((t): t is string => !!t && t.length >= 32);
  const given = createHash('sha256').update(m[1]).digest();
  return tokens.some((t) => timingSafeEqual(given, createHash('sha256').update(t).digest()));
}

function validSlug(slug: unknown): slug is string {
  return typeof slug === 'string' && slug.length <= MAX_SLUG && SLUG_RE.test(slug);
}

/** Reads the body with a hard byte ceiling, metered as the bytes arrive. */
async function readJson(req: Request): Promise<{ ok: true; data: any } | { ok: false; res: Response }> {
  const tooLarge = { ok: false as const, res: json(413, { error: 'payload_too_large' }) };

  const declared = Number(req.headers.get('content-length'));
  if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) return tooLarge;

  // Content-Length is a hint: absent on a chunked request, and not binding on
  // any request. Meter the stream and abandon an oversized body mid-flight
  // rather than buffering it first and checking afterwards.
  const reader = req.body?.getReader();
  if (!reader) return { ok: false, res: json(400, { error: 'invalid_json' }) };

  const chunks: Uint8Array[] = [];
  let total = 0;
  for (;;) {
    const { done, value } = await reader.read();
    if (done) break;
    total += value.byteLength;
    if (total > MAX_BODY_BYTES) { await reader.cancel(); return tooLarge; }
    chunks.push(value);
  }

  try {
    // Decode over the joined buffer: a multi-byte character can straddle chunks.
    return { ok: true, data: JSON.parse(Buffer.concat(chunks).toString('utf8')) };
  } catch {
    return { ok: false, res: json(400, { error: 'invalid_json' }) };
  }
}

export async function POST(req: Request) {
  if (!authorized(req)) return json(401, { error: 'unauthorized' });

  const body = await readJson(req);
  if (!body.ok) return body.res;
  const b = body.data;

  const issues: string[] = [];
  if (typeof b?.title !== 'string' || !b.title.trim()) issues.push('title');
  if (!validSlug(b?.slug)) issues.push('slug');
  if (typeof b?.content !== 'string' || !b.content.trim()) issues.push('content');
  if (b?.format !== 'html') issues.push('format');
  if (typeof b?.date !== 'string' || Number.isNaN(Date.parse(b.date))) issues.push('date');
  if (issues.length) return json(400, { error: 'invalid_payload', issues });

  const category = CATEGORIES.includes(b.category) ? b.category : DEFAULT_CATEGORY;

  // REQUIRED — see §9. You render this HTML on your own domain, so a leaked
  // token is stored XSS unless you decide what markup you are willing to keep.
  const verdict = checkContent(b.content);
  if (!verdict.ok) return json(400, { error: 'unsafe_content', violations: verdict.violations });

  try {
    const { action } = await storage.upsert({
      title: b.title,
      slug: b.slug,
      category,
      date: new Date(b.date),
      dateModified: b.dateModified ? new Date(b.dateModified) : new Date(),
      featured: b.featured === true,
      // Stored exactly as received. Nothing was rewritten, so the hash we
      // return from GET ?include=content still matches what the sender sent.
      content: b.content,
      excerpt: typeof b.excerpt === 'string' ? b.excerpt : undefined,
      author: typeof b.author === 'string' ? b.author : 'Acme',
      readTime: typeof b.readTime === 'string' ? b.readTime : undefined,
      tags: Array.isArray(b.tags) ? b.tags.filter((t: unknown) => typeof t === 'string') : [],
      thumbnail: b.thumbnail ?? null,
    });
    return json(200, { action, url: `${SITE}/articles/${b.slug}` });
  } catch (err) {
    console.error('[cms] upsert failed', err);          // details stay server-side
    return json(500, { error: 'storage_error' });
  }
}

export async function GET(req: Request) {
  if (!authorized(req)) return json(401, { error: 'unauthorized' });

  const url = new URL(req.url);
  const slug = url.searchParams.get('slug');
  if (!slug) return json(400, { error: 'slug_required' });
  // Validate here too: an unvalidated slug is path traversal on a file-backed store.
  if (!validSlug(slug)) return json(200, { exists: false });

  if (url.searchParams.get('include') === 'content') {
    const remote = await storage.fetch(slug);
    return remote
      ? json(200, { exists: true, content: remote.content, format: 'html' })
      : json(200, { exists: false });
  }
  return json(200, { exists: await storage.exists(slug) });
}

export async function DELETE(req: Request) {
  if (!authorized(req)) return json(401, { error: 'unauthorized' });

  const body = await readJson(req);
  if (!body.ok) return body.res;
  if (!validSlug(body.data?.slug)) return json(400, { error: 'invalid_payload', issues: ['slug'] });

  const { action } = await storage.delete(body.data.slug);
  return json(200, { action });
}

And the metadata sub-route:

// app/api/publish/meta/route.ts
export const dynamic = 'force-dynamic';

export async function GET(req: Request) {
  // Same bearer check as /api/publish — extract it into a shared module.
  if (!authorized(req)) {
    return new Response(JSON.stringify({ error: 'unauthorized' }), {
      status: 401,
      headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
    });
  }
  return Response.json({
    protocol: 'geovector-cms',
    version: '1',
    categories: ['guide', 'comparison', 'industry', 'research'],
    defaultCategory: 'guide',
    articlePathTemplate: '/articles/{slug}',
  }, { headers: { 'cache-control': 'no-store' } });
}

7. Reference implementation — Express

Same contract, same rules. Note the explicit body limit and that DELETE carries a JSON body.

import express from 'express';
import { timingSafeEqual, createHash } from 'node:crypto';
import { storage } from './cms-storage.js';

const router = express.Router();
router.use((_req, res, next) => {
  res.set('Cache-Control', 'no-store');
  next();
});
router.use(express.json({ limit: '10mb' }));  // rejects past the ceiling

// body-parser throws before any route runs. Without this, Express answers with
// its default HTML error page instead of the JSON envelope this API promises.
router.use((err, _req, res, next) => {
  if (err?.type === 'entity.too.large') return res.status(413).json({ error: 'payload_too_large' });
  if (err?.type === 'entity.parse.failed') return res.status(400).json({ error: 'invalid_json' });
  return next(err);
});

const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
const validSlug = (s) => typeof s === 'string' && s.length <= 200 && SLUG_RE.test(s);

function auth(req, res, next) {
  const m = (req.get('authorization') || '').match(/^Bearer\s+(.+)$/);
  const expected = process.env.CMS_PUBLISH_TOKEN;
  if (!m || !expected) return res.status(401).json({ error: 'unauthorized' });
  const a = createHash('sha256').update(m[1]).digest();
  const b = createHash('sha256').update(expected).digest();
  if (!timingSafeEqual(a, b)) return res.status(401).json({ error: 'unauthorized' });
  next();
}

router.post('/api/publish', auth, async (req, res) => {
  const b = req.body;
  if (!validSlug(b?.slug) || b?.format !== 'html' || !b?.title || !b?.content) {
    return res.status(400).json({ error: 'invalid_payload' });
  }
  // REQUIRED — see §9. Store the HTML exactly as received, or not at all.
  const verdict = checkContent(b.content);
  if (!verdict.ok) return res.status(400).json({ error: 'unsafe_content', violations: verdict.violations });
  try {
    const { action } = await storage.upsert(b);
    res.json({ action, url: `https://acme.com/articles/${b.slug}` });
  } catch (err) {
    req.log?.error(err);
    res.status(500).json({ error: 'storage_error' });
  }
});

router.get('/api/publish', auth, async (req, res) => {
  const { slug, include } = req.query;
  if (!slug) return res.status(400).json({ error: 'slug_required' });
  if (!validSlug(slug)) return res.json({ exists: false });
  if (include === 'content') {
    const remote = await storage.fetch(slug);
    return res.json(remote ? { exists: true, content: remote.content, format: 'html' }
                           : { exists: false });
  }
  res.json({ exists: await storage.exists(slug) });
});

router.delete('/api/publish', auth, async (req, res) => {
  if (!validSlug(req.body?.slug)) return res.status(400).json({ error: 'invalid_payload' });
  res.json(await storage.delete(req.body.slug));
});

router.get('/api/publish/meta', auth, (_req, res) => res.json({
  protocol: 'geovector-cms',
  version: '1',
  categories: ['guide', 'comparison', 'industry', 'research'],
  defaultCategory: 'guide',
  articlePathTemplate: '/articles/{slug}',
}));

export default router;

8. The storage adapter

Four methods. Everything above is generic; this is the only part specific to your stack.

interface CmsStorage {
  upsert(input: ArticleInput): Promise<{ action: 'created' | 'updated' }>;
  delete(slug: string):        Promise<{ action: 'deleted' | 'not_found' }>;
  exists(slug: string):        Promise<boolean>;
  fetch(slug: string):         Promise<{ content: string; format: string } | null>;
}

Requirements

  • Upsert on slug, and report which happened. Determine created vs updated by checking existence before writing — GeoVector surfaces the distinction to the user.
  • Never re-date on update. Set date on create only; write dateModified on update.
  • Invalidate caches inside the write. A publish that returns 200 while the page still serves the old body for ten minutes reads as a broken integration. On Next.js: revalidateTag() or revalidatePath() for the article, the index, and the sitemap. On a CDN: issue the purge before responding.
  • Slug must not reach the filesystem unvalidated. If you write files, build the path from an already-regex-validated slug and resolve it against your content root, and verify the result is still inside that root.
  • Make writes idempotent. Repeated or replayed payloads must update one article, never create duplicates.

Common shapes

Backing storeApproachWatch out for
SQL rowUPSERT … ON CONFLICT (slug); HTML in a text columnSimplest and recommended. Cache-tag your reads.
Markdown + gitWrite file, commit, push, let CI deployPOST must not block on the build. Return 200 once committed; the URL goes live shortly after.
Headless CMSProxy to the vendor APIMap their id to your slug, and keep their rate limits away from our 30 s deadline.
Object storageKey by articles/{slug}.htmlPurge the CDN inside the write, not after.

9. Hardening — required before you go live

This endpoint renders remote HTML on your domain. Treat it as a privileged write API.

Non-negotiable
  1. Apply the HTML policy on receive. Refuse everything outside the exact allowlist below; otherwise a leaked token becomes stored XSS.
  2. Validate slug against the regex on POST, GET and DELETE. The read and delete paths are the ones people forget, and they are the traversal risk.
  3. Cap the body before parsing and answer 413. Use 5 MB without imageBase64 or 10 MB with it. Meter the stream; Content-Length may be absent.
  4. Constant-time token comparison over fixed-length digests.
  5. HTTPS only, with HSTS. Never accept the token over plain HTTP.
  6. No caching on the endpoint. Cache-Control: no-store, and make sure no CDN rule caches /api/publish.

Strongly recommended beyond that:

  • Rate limit — 60 requests/minute per token is far above real publishing volume. Return 429 with Retry-After.
  • Log every mutation — timestamp, verb, slug, action, response code, and a request id. When a page is wrong, this is the first thing both sides will ask for.
  • Echo a request id — return X-Request-Id (reflecting the inbound one when present) so a support thread can be correlated across both systems.
  • Alert on 500s. A 5xx marks the connection failing and exposes your generic response message in the dashboard; details belong only in your logs.
  • Keep it fast. Our client aborts at 30 s. Long work (image processing, site rebuilds) belongs in a background job kicked off after you respond.

The content policy — what to allow, and reject vs. strip

The table is a readable view of our receiver's allowlist. Implement from the generated article-html-policy.v1.json and test with html-policy-conformance.v1.json; they include rules the table cannot express.

CategoryAllowed
Structurearticle, section, header, footer, main, aside, div, figure, figcaption
Headingsh1h6
Textp, span, strong, b, em, i, u, s, small, sub, sup, mark, abbr, br, hr, wbr, time (datetime)
Listsul, ol (start, reversed, type), li (value), dl, dt, dd
Quotes & codeblockquote/q (cite), cite, pre, code, kbd, samp, var
Tablestable, caption, colgroup/col (span), thead, tbody, tfoot, tr, th (scope, colspan, rowspan, abbr, headers), td (colspan, rowspan, headers)
Links & mediaa (href, rel, hreflang), img (src, alt, width, height, loading, decoding)
Disclosuredetails (open), summary
Global attributesclass, id, title, lang, dir — on any element above
URLshref/src/cite must be relative or use http, https, mailto

The sole script exception is <script type="application/ld+json">: require that exact type, no other attributes, valid JSON, and no < in the body. When embedding other untrusted JSON-LD, escape < as \u003c.

Reject, don't strip

Answer 400 {"error":"unsafe_content","violations":[…]} and store nothing:

  1. Drift detection depends on byte equality. Rewriting makes every article appear modified immediately.
  2. A rejection is a signal. Unexpected HTML indicates a pipeline change or token misuse and should remain visible in logs.

If rewriting is unavoidable, use sanitize-html or DOMPurify with jsdom, store the cleaned form, and tell us so drift checks use that form.

The §6 sample uses sanitize-html as a validator: any rewrite is a rejection. JSON-LD is checked separately because the library cannot express its exact permitted shape.

import sanitizeHtml from 'sanitize-html';

const POLICY = {
  allowedTags: ['article','section','header','footer','main','aside','div','figure','figcaption',
    'h1','h2','h3','h4','h5','h6','p','span','strong','b','em','i','u','s','small','sub','sup',
    'mark','abbr','br','hr','wbr','time','ul','ol','li','dl','dt','dd','blockquote','q','cite',
    'pre','code','kbd','samp','var','table','caption','colgroup','col','thead','tbody','tfoot',
    'tr','th','td','a','img','details','summary'],
  allowedAttributes: {
    '*': ['class', 'id', 'title', 'lang', 'dir'],
    a: ['href', 'rel', 'hreflang'],
    img: ['src', 'alt', 'width', 'height', 'loading', 'decoding'],
    th: ['scope', 'colspan', 'rowspan', 'abbr', 'headers'],
    td: ['colspan', 'rowspan', 'headers'],
    ol: ['start', 'reversed', 'type'],
    li: ['value'],
    col: ['span'], colgroup: ['span'], time: ['datetime'],
    blockquote: ['cite'], q: ['cite'], details: ['open'],
  },
  allowedSchemes: ['http', 'https', 'mailto'],
  allowedSchemesAppliedToAttributes: ['href', 'src', 'cite'],
  disallowedTagsMode: 'discard',
} as const;

// The one script we accept. Every part of this shape is load-bearing: that
// exact type, no other attributes, and a body containing no `<` at all — so
// there is nothing in it a browser can read as the end of the element.
const JSON_LD_BLOCK = /<script type="application\/ld\+json">([^<]*)<\/script>/g;

function checkContent(html: string): { ok: true } | { ok: false; violations: string[] } {
  const violations: string[] = [];

  // Lift the JSON-LD out before sanitizing. `sanitize-html` drops script bodies
  // by design (`nonTextTags`), so handing it one would make every article that
  // carries structured data look modified. Validate it on its own terms
  // instead; anything script-shaped that this regex does *not* match is not a
  // well-formed inert block and is caught immediately below.
  const withoutJsonLd = html.replace(JSON_LD_BLOCK, (_match, body: string) => {
    try {
      JSON.parse(body);
    } catch {
      violations.push('invalid-json-ld');
    }
    return '';
  });
  if (/<script/i.test(withoutJsonLd)) violations.push('script');

  const cleaned = sanitizeHtml(withoutJsonLd, POLICY);
  if (cleaned !== withoutJsonLd) {
    // Any mismatch is a rejection, named or not. Being unable to name what was
    // removed is not a reason to accept it: a stripped `onerror` leaves the
    // element name in the allowlist, so an unnamed violation is exactly the
    // case you least want to let through.
    const unknown = [...withoutJsonLd.matchAll(/<([a-zA-Z][a-zA-Z0-9]*)/g)]
      .map((m) => m[1].toLowerCase())
      .filter((t) => !POLICY.allowedTags.includes(t as never));
    violations.push(...(unknown.length > 0 ? unknown : ['sanitizer-mismatch']));
  }

  if (violations.length === 0) return { ok: true };
  return { ok: false, violations: [...new Set(violations)] };
}
Store the original, not withoutJsonLd

The lifted copy is only for validation. Persist the original content, including JSON-LD, exactly as received.

Caveat: sanitize-html normalizes some safe markup, so comparison can produce sanitizer-mismatch. Test real articles. If necessary, normalize before comparing or store the cleaned form and accept the drift consequence. Our receiver scans directly to remain byte-exact.

10. Rendering the HTML we send

content is a fragment, not a document: no <html>, <head>, or <body>. Its shape:

<article>
<h1>How AI Assistants Choose Sources</h1>
<main>
  <p>Opening paragraph…</p>
  <h2>Section heading</h2>
  <p>Body copy with a <a href="https://example.com/study">citation</a>.<sup id="fnref-1"><a href="#fn-1">1</a></sup></p>
  <ul><li>List item</li></ul>
  <table><thead>…</thead><tbody>…</tbody></table>
  <blockquote><p>Pull quote</p></blockquote>
  <h2>Sources</h2>
  <ol><li id="fn-1">Source title — publisher, 2026. <a href="…">link</a></li></ol>
</main>
</article>
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "FAQPage",
  "mainEntity": [{ "@type": "Question", "name": "…", "acceptedAnswer": { "@type": "Answer", "text": "…" } }]
}
</script>
  • Render server-side. The article HTML must be present in the initial response. Content injected by client-side JavaScript is invisible to most AI crawlers — which defeats the purpose of publishing it.
  • The fragment carries its own <h1>. Do not add a second one from title; use title for <title>, OG tags, and listing cards.
  • The trailing FAQPage block is part of the fragment. Render it unchanged. Before converting HTML to text for search, excerpts, or /llms.txt, remove script blocks with their bodies so raw JSON does not leak into prose.
  • Do not strip id/href anchors. Footnote refs pair #fnref-N with #fn-N; an over-eager sanitizer breaks every citation jump link.
  • Style tables properly. Comparison tables are among the most-quoted blocks in AI answers. Wrap them in an overflow-x: auto container for mobile.
  • Keep outbound links real. Do not add nofollow to cited sources or rewrite them through a redirector — citation graphs are part of what gets measured.

11. Page requirements for AI visibility

These properties let AI assistants find, fetch, and cite the published page.

RequirementWhy
Server-rendered HTML (SSR/SSG), no JS-only bodyMost AI fetchers do not execute JavaScript. No HTML, no citation.
<link rel="canonical"> matching the url you returnedSplitting the same article across two addresses splits its authority and breaks attribution.
Article JSON-LD with headline, datePublished, dateModified, author, publisherMachine-readable provenance; strongly correlated with being cited rather than paraphrased.
FAQPage JSON-LD when the body has a Q&A section — we ship this inside content, so you only need to render it through unchangedQuestion-shaped blocks are what assistants lift verbatim.
<meta name="description"> from excerpt, plus OG/Twitter tagsUsed for snippets and link previews across surfaces.
Article appears in sitemap.xml within minutes of publishRegenerate the sitemap in the same write path as the upsert.
robots.txt allows AI crawlers you want citations fromGPTBot, OAI-SearchBot, ClaudeBot, PerplexityBot, Google-Extended, Bingbot. Blocking them makes the whole exercise moot.
No bot-challenge wall on article URLsAn aggressive WAF or interstitial serves a challenge page to crawlers instead of the article. Allowlist the article path.
Stable URLs; 301 when they must changeCitations accumulate against an address. Moving one without a redirect discards that history.
Fast TTFB (<800 ms) and no login wallCrawlers time out; gated content is never quoted.
Optional: /llms.txt listing your published articlesAn emerging convention for pointing assistants at your best content.

12. Verify with curl

Run these against your deployment before connecting. All ten must behave exactly as shown.

Or run the whole thing at once

./verify-receiver.sh https://acme.com "$TOKEN" also checks the size cap, content policy, traversal slugs, and byte-identical read-back.

curl -sO https://www.geovector.ai/docs/cms/verify-receiver.sh
chmod +x verify-receiver.sh
./verify-receiver.sh https://acme.com "$TOKEN"

It publishes a probe article and deletes it again. Add --read-only to skip every write and check the read paths alone.

SITE=https://acme.com
TOKEN=your-token

# 1 — auth is enforced (expect 401)
curl -s -o /dev/null -w '%{http_code}\n' "$SITE/api/publish?slug=__verify__"

# 2 — the exact probe GeoVector runs at connect (expect {"exists":false})
curl -s -H "Authorization: Bearer $TOKEN" "$SITE/api/publish?slug=__verify__"

# 3 — capabilities (expect the protocol envelope)
curl -s -H "Authorization: Bearer $TOKEN" "$SITE/api/publish/meta"

# 4 — publish (expect {"action":"created","url":"https://acme.com/articles/..."})
curl -s -X POST "$SITE/api/publish" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"title":"Integration Test","slug":"geovector-integration-test",
       "category":"guide","date":"2026-08-06","format":"html","featured":false,
       "excerpt":"Verifying the GeoVector CMS protocol.",
       "content":"<article><h1>Integration Test</h1><main><p>Hello.</p></main></article>"}'

# 5 — the returned URL actually serves the article, server-rendered
curl -s "$SITE/articles/geovector-integration-test" | grep -c 'Integration Test'

# 6 — read back (content must match byte-for-byte what you stored)
curl -s -H "Authorization: Bearer $TOKEN" \
  "$SITE/api/publish?slug=geovector-integration-test&include=content"

# 7 — re-publish is an update, not a duplicate (expect "updated")
#     repeat step 4 verbatim

# 8 — unpublish (expect {"action":"deleted"}), then again (expect "not_found")
curl -s -X DELETE "$SITE/api/publish" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"slug":"geovector-integration-test"}'

# 9 — traversal is refused, not executed
curl -s -X DELETE "$SITE/api/publish" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"slug":"../../etc/passwd"}'          # expect 400, and nothing deleted

# 10 — a non-ISO date is refused (expect 400 invalid_payload)
curl -s -X POST "$SITE/api/publish" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"title":"x","slug":"geovector-integration-test","category":"guide",
       "date":"August 06, 2026","format":"html","featured":false,
       "content":"<article><h1>x</h1><main><p>x</p></main></article>"}'
Also confirm

After step 4, the article shows up in your index page and sitemap.xml without a manual rebuild. Cache invalidation is the most common thing that "works locally" and fails in production.

13. Connecting in GeoVector

  1. Open Settings → Integrations → GeoVector CMS.
  2. Enter Site URL — scheme and host only, canonical form, no trailing slash (https://www.acme.com). Not the /api/publish path.
  3. Enter API key — your bearer token. Stored encrypted; never displayed again.
  4. Click Verify. We probe GET /api/publish?slug=__verify__, then fetch /api/publish/meta, and save only if the probe succeeds.
  5. Pick your default category from the dropdown populated by your /meta.

Publishing records and begins monitoring the returned URL for AI citations.

14. Troubleshooting

What GeoVector reportsCauseFix
unauthorizedToken mismatch (401/403)Compare byte-for-byte — trailing newline from echo is the classic culprit. Confirm the env var is set in the deployed environment, not only locally.
not_geovector_cms404, or 200 with a non-JSON body/api/publish isn't deployed, or a catch-all route/CDN is answering first. Verify with curl step 2.
invalid_response200 JSON without a boolean existsThe GET handler must return {"exists": <boolean>} — no wrapper envelope.
unreachableDNS failure, TLS error, or >8 s to respondCheck the certificate chain and cold-start latency. A WAF challenging our probe also looks like this.
blocked_private_hostThe host resolves to a private/loopback addressWe refuse non-public targets. Use the public URL; staging must be publicly reachable to be connected.
redirect_offsiteYour site redirects to a different registrable domainEnter the destination host instead — we surface it as a one-click suggestion.
server_errorYour handler threwCheck your logs for the request. Most often a missing CMS_PUBLISH_TOKEN or an unmigrated database.
Publish succeeds, page 404surl doesn't match your real route, or the cache wasn't purgedReturn the address your router actually serves; invalidate inside the write.
"Modified externally" on an untouched articleYour read-back HTML differs from what you storedSanitize/normalise once at ingest, store the result, and return it verbatim. Do not re-transform on read.
Every publish creates a duplicateInsert instead of upsertKey on slug with a unique constraint.
Publish times out at 30 sSynchronous rebuild or image processing in the requestRespond first; do the slow work in a background job.
Settings shows "Publishing failing"The last request that reached you returned 5xx, timed out, or was refused with 401/403The message on the banner is your own response body. Fix the cause, then publish anything and the mark clears itself; re-verifying and saving the connection clears it too. Nothing was disabled.
"Publishing failing" after one bad draftYour receiver answers 500 where it means 400Return 400 for anything you refuse about the payload. A 5xx tells us your site is down (§3).

15. Forward compatibility

  • Ignore unknown request fields. v1.x adds optional fields only; a strict receiver breaks on the day we ship them.
  • Return unknown-to-us fields freely in your responses — we ignore what we don't recognise.
  • Advertise honestly in /meta. The capabilities object is where you declare max body size, image support, locales, and which optional fields you persist. Absence of any of it means "unknown, assume the baseline" — so a receiver that stays silent is conforming, and one that overstates is the only real failure mode.
  • Breaking changes get a new version and are announced before rollout; v1 receivers keep working.
  • Reserved slug: __verify__ is used by the connection probe. Never store an article under it.

Additive v1 fields already shipped: metaDescription, canonicalUrl, ogImageUrl, locale, noindex, and capabilities. ISO-8601 validation for date and dateModified is now enforced.

Document revisions

Document revisions do not change the protocol version.

Rev.DateWhat changed
1 2026-08-06 First public revision with companion files. Added status-code effects; corrected retry and capabilities behaviour.
2 2026-08-07 Added missing error responses and the complete URL-character rejection table. No wire change.
3 2026-08-07 Condensed repeated guidance while preserving the contract, examples, and security requirements. No wire change.

16. Launch checklist

  • POST /api/publish upserts by slug and returns an absolute url
  • GET /api/publish?slug= returns { exists }
  • GET /api/publish?slug=&include=content returns the stored HTML unchanged
  • DELETE /api/publish removes the article and returns deleted / not_found
  • GET /api/publish/meta advertises protocol, version, categories, default, path template
  • Bearer required on all five; constant-time comparison; 401 otherwise
  • Slug regex enforced on POST, GET and DELETE
  • HTML checked against an allowlist before storage, with 400 unsafe_content for anything outside it
  • Accepted HTML stored byte-for-byte, so drift detection stays meaningful
  • Body size capped with a 413 response
  • Payload rejections answer 400, never 500 — a 5xx reports your site as down to the customer
  • Endpoint served over HTTPS with no-store, never cached by a CDN
  • Rate limit and mutation logging in place
  • date and dateModified validated as ISO-8601, including that the day exists
  • date set on create only; dateModified written on update
  • canonicalUrl and ogImageUrl rejected unless absolute http(s)
  • noindex honoured in both the meta tag and the sitemap; absence means index
  • Caches, index page, and sitemap.xml invalidated inside the write
  • Article page is server-rendered with canonical link and Article JSON-LD
  • robots.txt allows the AI crawlers you want citations from
  • All ten curl checks in §12 pass against production
  • Token stored in the deployment environment; rotation procedure written down