Skip to content

CMS platform internals

Per-platform API mechanics: auth, schema discovery, and what publish() actually does on the wire for each CMS. The shared contract (template method, capability table, value funnel) lives in CMS adapter layer — this page is the platform-by-platform detail underneath it.

User-facing setup instructions (where customers click to create credentials) live in the public docs: frontend/src/docs/cms-setup.md.

Payload CMS

Credentials: Payload CMS URL, API Key.

Self-hosted headless CMS with a straightforward REST API.

  • Auth: static API key sent as Authorization: users API-Key {key} on every request. No token refresh — the key is permanent.
  • Base URL: the customer's own instance + /api/ (e.g. https://cms.example.com/api/blogs).

Schema discovery — Payload has no "list fields" endpoint:

  1. GET /api/access → all collection slugs (blogs, tags, media, …).
  2. Fetch 5 sample documents per collection (?limit=5&depth=1&locale=all).
  3. Infer field types from actual values (object with root.children = Lexical richtext, array of objects with id = relationship).

Publishing:

  1. Markdown body → Lexical JSON (markdownToLexical()).
  2. Cover image: multipart FormData upload to /api/media → numeric media ID.
  3. Build payload from the field map (handles dot-notation like meta.title).
  4. POST /api/{collection}?locale={primaryLang} → ID + slug.
  5. Each additional language: PATCH /api/{collection}/{id}?locale={lang}.

Characteristics: Lexical richtext · upload → numeric media IDs · ?locale= param i18n (shared document) · numeric entity IDs · self-hosted, no rate limits.

Webflow

Credentials: API Token, Site ID.

Hosted platform with a centralized API. Its full-fidelity updateArticle + hard delete (live-proven, capabilities OFF since 2026-08-14 per the v1 operating doctrine, plans/cms-contract-v1.md §0) now live in git history — the contract's both-directions gate keeps implementations and flags paired, so reviving is re-add + flip together.

  • Auth: static Bearer token (Authorization: Bearer {token}).
  • Base URL: always https://api.webflow.com/v2/.
  • Rate limits: 60 req/min (starter) or 120 req/min (CMS/Business). The adapter watches X-RateLimit-Remaining and retries on 429.

Schema discovery — first-class endpoints, the cleanest of all:

  1. GET /v2/sites/{siteId}/collections → all collections.
  2. GET /v2/collections/{collectionId} → complete field definitions (name, type, required, validations) — no sample inference needed.

Publishing:

  1. Markdown body → HTML (markdownToHtml()).
  2. Cover image: passed directly as { url, alt } — Webflow downloads it, no upload step.
  3. Field map → fieldData object.
  4. POST /v2/collections/{collectionId}/items/live → creates AND publishes in one call.
  5. Additional languages: PATCH .../items/live with cmsLocaleId (resolved from the site's locale config).

Characteristics: HTML richtext · { url, alt } image passthrough · cmsLocaleId i18n (shared item) · string UUIDs · built-in name + slug fields on every collection · rich text silently drops <code> content · delete needs publishSite or the CDN keeps serving the page.

Drupal

Credentials: Site URL, OAuth Client ID, OAuth Client Secret, OAuth Scope (optional; required for Simple OAuth 6.x non-default scopes).

Uses the JSON:API spec (core since 8.7) with OAuth2 (Simple OAuth module) — the most involved integration of the set.

  • Auth: OAuth2 Client Credentials grant. POST /oauth/token with client_id + client_secret → short-lived Bearer token (expires_in ~300s), cached in memory and refreshed with a 30-second buffer. The request goes through cmsFetch, which preserves the POST across a canonical-host redirect — a stored https://www.example.com for a site serving the apex previously arrived as GET /oauth/token and returned Simple OAuth's 405 HTML page (the HRTH outage).
  • Timeouts: OAUTH_TIMEOUT_MS 30s, REQUEST_TIMEOUT_MS 20s, both as timeoutMs so each redirect hop gets its own budget. The token request is generous on purpose: it gates the whole publish and is the slowest call on a Drupal behind antibot/rate-limiting. At 15s it was timing out and killing entire republishes.
  • Base URL: the customer's instance + /jsonapi/.
  • Content-Type: application/vnd.api+json (JSON:API spec, not plain JSON).

Schema discovery — index endpoint, but no field-definitions endpoint:

  1. GET /jsonapi → links to all resource types.
  2. Filter node--* (content types) and taxonomy_term--* (vocabularies).
  3. Fetch 5 samples per content type; infer attribute types from values (object with value + format = richtext, ISO date string = date) and relationship types from the relationships section (taxonomy_term--* = relationship, media--image = media).
  4. Doc counts: core JSON:API returns no total count on collections (meta.count only exists with contrib modules), so a full sample page triggers countResources — a paged walk (50/page, sparse fieldsets, capped at 1,000) for nodes and taxonomies alike. Before this, discovery reported the sample size: "5 docs" for a library of hundreds (found on MICE Magazine).
  5. Meta fields: the Metatag module's computed metatag output (rendered title/description/og:*) is treated as internal — it is read-only and every write to it fails. Its storage field (per-node overrides; field_meta_tags by default, any name — MICE uses field_meta) is detected via the field-config API when the Consumer's user may read config, by name convention (field_meta, field_metatags, …) otherwise — value inference never can, because it reads null on every node without an override. A match becomes a managed field carrying adapterMeta.drupalFieldType: 'metatag'.

Meta title/description publish in whichever form the site has: plain SEO text fields map through the ordinary metaTitle/metaDescription roles (the field_meta_title/field_meta_description aliases); a Metatag storage field gets both values injected post-funnel as one JSON string (_applyMetatag — Metatag 2.x stores overrides as JSON, and Drupal's JSON:API flattens single-property field items to their scalar, so an object or { value } envelope 422s the node write); a site with neither keeps Drupal's global tag patterns. Both write paths (node create + the companion module's translation upsert) go through the same injection; the wire shape is locked by the Metatag guard in cms-publish-dryrun.mjs.

Publishing:

  1. Markdown body → HTML, wrapped in Drupal's body structure: { value, format: "full_html", summary }. Paragraphs-based content types (e.g. field_paragraphs) instead get paragraph entities created per block and referenced from the node (primary-locale paragraph failures abort the publish; a translation's paragraph failure falls back to patching the node body).
  2. Slug: path.alias must be set explicitly (e.g. /blog/my-post-title) — Pathauto doesn't fire via API.
  3. Cover image is a 3-step chain: download to buffer → POST /jsonapi/media/image/field_media_image as application/octet-stream (file entity UUID) → POST /jsonapi/media/image (Media entity UUID). The Media UUID goes on the node's cover/meta-image fields; the file UUID + public URL become an inline <img> prepended to the FIRST body paragraph's HTML, which is how the image appears in the article itself.
    • Never as its own image paragraph. Themes commonly render the node title off whichever paragraph sorts first — on one live site the article's only <h1> came from the first paragraph's template — so a cover paragraph in front of the body silently strips the title from the page. A dedicated bundle also inherits that bundle's styling (there, a heart-shaped clip-path), while an inline image renders as ordinary body content. Guarded by cms-publish-dryrun.mjs.
    • The <img> carries data-entity-type="file" + data-entity-uuid so Drupal's editor_file_reference filter keeps the src resolved and registers file usage. src, alt and both data attributes are inside the default basic_html allowed-tag list; a text format that strips <img> loses the in-body image (the cover field is still set).
  4. Split the payload into attributes (text, body, status, path) vs relationships (taxonomy/media/author UUIDs) — entity references MUST go in relationships.
  5. Wrap in the JSON:API envelope { data: { type: "node--article", attributes, relationships } }POST /jsonapi/node/{contentType}.
  6. Additional languages: see Translations below. Core JSON:API has no route that creates one.

Translations — the orbit_translations companion module

Drupal core cannot create a translation over HTTP. Not an adapter gap: POST /{lang}/jsonapi/node/{type} creates a NEW node and ignores the prefix for its langcode (asserted by core's own JsonApiFunctionalMultilingualTest); POST with attributes.langcode is 403 unless the site enables language_alterable; PATCH /{lang}/jsonapi/... 405s when the translation does not exist; the REST module has no method guard at all; jsonapi_translation is an abandoned 2021 sandbox and core's successor (#3199697) is unmerged. The only mechanism is server-side PHP.

So the PHP ships with us: backend/src/workspace/collections/cms/drupal-module/orbit_translations/ — a Drupal 10/11 module the customer installs. Its README.md is the customer-facing contract; the source and this adapter are two halves of one thing and change together.

  • Wire (services/drupal-orbit.js, separate from services/drupal.js because it is not JSON:API):GET /orbit/v1/status (handshake) · GET /orbit/v1/translatability/node/{bundle} (per-field, per-language) · POST /orbit/v1/node/{uuid}/translations/{langcode} (create-or-update). Same Simple OAuth bearer token; no second credential.
  • Detection is by status code, and is strict. 404 = not installed · 403 = installed, role lacks the permission · 200 = check the body. A 200 alone is never enough — the payload must carry module: 'orbit_translations' and a matching api version, or a permissive router/proxy/marketing page would be read as multilingual-capable and every translation would be written into nothing. (This is also what keeps cms-publish-dryrun.mjs's catch-all stub from faking the module.) Cached 5 min per site URL; clearOrbitCache() resets it.
  • Adapter surface: _orbit() (one probe per instance) → _localeModel() (shared | primary-only) and translationSupport() (public — Settings renders it). _addTranslationViaModule builds the locale's parts, flattens JSON:API's attributes/relationships into the plain field map the module takes (_flattenForOrbit: a reference becomes its UUID, or {uuid, target_revision_id} for Paragraphs), and takes the slug from the module's post-save path.
  • The shared-field guard, on both sides. A field that is not translatable holds ONE value across every language, so writing the NL body into it would replace the EN one on the live site. The module refuses those per field and reports skipped: [{field, reason, message}]; the adapter asks /translatability first (_sharedFields) so a shared paragraph body never has its entities built — creating them and then dropping the reference leaves orphans in the customer's CMS on every publish. Both paths surface the remedy through _warn, so the publish envelope's warnings names the checkbox. Locked by three cases in cms-publish-dryrun.mjs.
  • Server-side safety (all in the module): route _permission + _entity_access: entity.update, update access re-checked on the translation, access('edit') per field, a denylist of structural fields (langcode, nid, …), one lock + one transaction per node, revisions following the bundle's own setting, and validation split into fatal (a field this write set) vs warning (a pre-existing defect).
  • Distribution: drupal-module/package.js builds a byte-stable .tar.gz in memory (hand-rolled ustar + zlib, no dependency) served by GET /api/onboarding/:ws/cms/drupal/module; readiness comes from GET /api/onboarding/:ws/cms/drupal/translations. The UI is frontend/src/components/DrupalTranslationsPanel.tsx.

Without the module, nothing changed. _localeModel() stays primary-only, publish() drops the extra locales before a single write, and _assertTranslationExists remains as the guard on the legacy path.

Characteristics: HTML-in-{value, format} richtext · binary upload → Media UUIDs · i18n only with the companion module (then a shared node) · UUIDs everywhere · strict attributes/relationships split · OAuth2 token exchange · pagination hard-capped at 50/page · self-hosted, no rate limits.

Site requirements: JSON:API module with write operations enabled (/admin/config/services/jsonapi), Simple OAuth with a configured Consumer (+ encryption keys), Media + Media Library, a text format (full_html) usable by the consumer's role; for multilingual, Content Translation + Language plus the orbit_translations module, translation enabled for the content type AND its fields, and the orbit create content translations permission on the API role; paragraphs_type_permissions + create paragraph content when the content type uses Paragraphs.

Store the canonical host. drupalSiteUrl must be the host the site actually serves on, not one that redirects to it — see the cmsFetch note above.

Shopify

Credentials: Store Domain, Client ID, Client Secret.

E-commerce platform with a built-in blog — not a traditional CMS. The only GraphQL adapter (Admin API, version 2025-10).

  • Auth: OAuth client credentials grantPOST https://{store}.myshopify.com/admin/oauth/access_token (form-urlencoded) exchanges the Dev Dashboard app's Client ID + Secret for a short-lived access token, cached per store+credentials and refreshed 5 minutes before expiry. Requests then send X-Shopify-Access-Token. (Uses native fetch — node-fetch's JA3 TLS fingerprint trips Cloudflare's bot detection.)
  • Base URL: https://{store}.myshopify.com/admin/api/{version}/graphql.json.
  • Rate limits: cost-based points (mutations ~10 points; standard plans restore 50 points/sec). Errors arrive as userErrors in the body, not HTTP status codes.

Schema discovery — fixed content model, simplest of all: list blogs via GraphQL (blogs(first: 50)) — these are the "collections"; article fields are hardcoded; tags are collected from existing articles (no taxonomy collections).

Publishing: one articleCreate mutation carries everything — title, HTML body, summary, handle (slug), flat string tags, image as { url, altText } (Shopify downloads it), SEO metafields (global.title_tag / global.description_tag), isPublished + publishDate. Additional languages: fetch translatableContentDigest values, then a translationsRegister mutation per locale — the most complex i18n of the set.

Characteristics: GraphQL + GIDs (gid://shopify/Article/123) · HTML richtext · URL passthrough images · flat tag strings only · metafield SEO · cursor pagination (max 250) · fixed content model → collectionModel: 'single'.

WordPress

Credentials: Site URL, Username, Application Password.

Built-in REST API (since 4.7) with Application Passwords (built-in since 5.6) over HTTP Basic Auth.

  • Auth: Authorization: Basic base64(username:appPassword). No refresh, no expiry. HTTPS required — WP blocks Application Passwords on plain HTTP.
  • Base URL: {siteUrl}/wp-json/wp/v2/.

Schema discovery: GET /wp-json/wp/v2/types (post types) + GET /wp-json/wp/v2/taxonomies (categories, tags, custom), plus sample posts to detect the SEO plugin (Yoast → yoast_head_json, RankMath → rank_math).

Publishing:

  1. Markdown body → HTML (renders as a "Classic" block in Gutenberg).
  2. Cover image: download binary → POST /wp/v2/media with Content-Disposition → numeric media ID.
  3. POST /wp-json/wp/v2/posts with title, content, excerpt, slug, featured_media, categories/tags (numeric IDs), status, author, and SEO meta.
  4. Additional languages (Polylang): a new post per language, lang + translations[{primaryLang}] sent as both query args (the vendor-documented form) and body fields — hence localeModel: 'separate'.

SEO meta is detected, not guessed. WordPress silently drops any meta key not registered with show_in_rest, so a guessed key is indistinguishable from a successful write. getWritableMetaKeys() reads the post type's own schema (OPTIONS /wp/v2/{restBase}) and _resolveSeoMetaKeys() picks the first matching pair from SEO_META_KEYS (Yoast → RankMath → AIOSEO → SEO Press), cached per adapter instance; an explicit wordpressSeoPlugin setting still overrides. When nothing is writable the adapter sends no SEO meta and warns.

Whether a given plugin registers its keys is per-site, not per-plugin — it depends on the plugin, its version, and any snippets the site runs. Two live Yoast sites (ledoux.be, marcomwisdom.be) DO expose _yoast_wpseo_title/_yoast_wpseo_metadesc in their schema; Rank Math by default registers nothing. That is exactly why this is detected rather than declared — do not re-introduce a per-plugin claim in the user-facing copy.

Characteristics: HTML richtext · binary upload → numeric IDs · plugin-dependent i18n (Polylang Pro / WPML), separate post per locale · native ?slug= lookup (cleanest slug resolution of all adapters) · plugin-dependent SEO meta · no built-in rate limits · fixed post shape → collectionModel: 'single'.

Wix

Credentials: API Key, Site ID, Member ID (author).

Hosted platform; publishing goes through the Wix Blog v3 draft-posts API + Media Manager.

  • Auth: static API key (IST.…) in the Authorization header, with the site targeted via its Site ID. Requires a Premium site with the Blog app installed.
  • Content model: platform-fixed blog → collectionModel: 'single'; categories/tags are real entities matched by ID.

Publishing — overrides _publish() wholesale with a draft-first flow:

  1. Markdown body → Ricos JSON (markdownToRicos()), Wix's structured rich-content format.
  2. Cover image: uploaded to the Media Manager (imageMode: 'upload').
  3. Create a draft post carrying memberId (author — API-created posts have no author without it), media, taxonomy IDs, seoData tags (title/description), and seoSlug.
  4. Each locale is its own draft post, linked into one group via translationIdlocaleModel: 'separate'.
  5. publishDraftPost per draft flips them live.

Characteristics: Ricos richtext · Media Manager upload · separate post per locale (translation groups) · UUID entity IDs · draft-group-then-publish flow · Premium plan required.

Sanity

Credentials: Project ID + Dataset + API Token (Editor permissions, created at sanity.io/manage → API → Tokens).

Structured-content platform (Content Lake). The API host is FIXED — https://{projectId}.api.sanity.io/{apiVersion} (version pinned as a date string, SANITY_API_VERSION in services/sanity.js) — so unlike the customer-URL platforms there is no redirect handling and no SSRF surface; the projectId is validated against ^[a-z0-9-]+$ before any URL assembly because it becomes a hostname label.

  • Auth: Authorization: Bearer {token} on every call.
  • Queries: GROQ via POST /data/query/{dataset} with { query, params }{ result }. Always POST (no GET size limit to manage).
  • Writes: the Mutation API — POST /data/mutate/{dataset}?returnIds=true[&returnDocuments=true] with { mutations: [{ create | createOrReplace | patch | delete }] }. Creating a document WITHOUT the drafts. prefix IS publishing — there is no separate publish step.
  • Images: binary POST /assets/images/{dataset}?filename=…{ document: { _id: 'image-…' } }; fields reference the asset as { _type: 'image', asset: { _type: 'reference', _ref } }. The uploader mirrors Payload's contract (null on failure — a cover that won't upload degrades the publish, never fails it).

Discovery (GROQ-driven, no schema endpoint). The Studio owns the schema, so like Payload the adapter infers from real documents — but with two advantages: array::unique(*[]._type) lists every document type directly (no probe list), and Sanity values are self-describing (_type: 'block' → richtext, _type: 'image' / an image--prefixed asset._ref → media, _type: 'reference' → relationship, _type: 'slug' → its .current exposed as the mappable text path). Ten newest published documents per type, merged with the same specificity ranking as Payload; every query filters !(_id in path("drafts.**")) so unpublished drafts never leak into counts, samples, or taxonomy options.

Publishing (default flow, Tier 1 only):

  1. Markdown body → Portable Text (format/markdown-to-portable-text.js — keyed blocks/spans/markDefs; hr lines dropped, no standard PT node).
  2. The generic funnel builds the payload; _toSanityDoc then applies what the funnel can't know: _type (the target document type) on the root, relationship ids wrapped as { _type: 'reference', _ref } (arrays get _key per member), any field mapped at <x>.current stamped { _type: 'slug' }, and — because Sanity never generates slugs server-side (Studio-only behavior) — the article bag carries a slugified title so a mapped slug field is always filled (a stored custom value wins via funnel precedence).
  3. One create mutation with returnDocuments=true; the returned _id is the cms_id, slug.current the slug.
  4. localeModel: 'primary-only' — see the spine page's footnote; extra locales are dropped pre-write with a Studio-pointing remedy message.

Classifier enrichment: _buildItemAttributesForClassifier fetches option documents whole (*[_id in $ids]) and flattens them to short prompt strings (Portable Text fields → first block's text; slug objects → .current; references/images skipped).

Wire locks: the dry-run's Sanity guard asserts the full shape on one request — root _type, keyed Portable Text body, keyed reference arrays, the slug object with the slugified title, the asset-backed image object, and nested dot-path meta.

Custom endpoint

Status: coming_soon (UI-gated for new connections; existing setups keep publishing). A fixed envelope POSTed/PATCHed against a customer-hosted base URL with a configurable auth header — the receiver adapts to our shape, so there is no field mapping (usesFieldMapping: false) and the body ships as markdown (or HTML, configurable).

Side-by-side comparison

PayloadWebflowDrupalShopifyWordPressWix
API styleRESTRESTJSON:APIGraphQLRESTREST
AuthStatic API keyStatic Bearer tokenOAuth2 (token exchange)OAuth2 client credentialsBasic Auth (App Passwords)Static API key
Rich textLexical JSONHTML stringHTML in { value, format }HTML stringHTML stringRicos JSON
ImagesUpload → numeric IDURL passthroughBinary → Media UUIDURL passthroughBinary → numeric IDUpload (Media Manager)
TaxonomyCollections, numeric IDsCollections, string IDsVocabularies, UUIDsFlat tag stringsCategories + Tags, numeric IDsCategories + Tags, UUIDs
SEO fieldsRegular fieldsCustom fieldsMetatag moduleMetafieldsPlugin meta keysseoData tags
Localization?locale= paramcmsLocaleId paramURL prefixTranslations APIPlugin (separate posts)Translation groups (separate posts)
Entity IDsNumericString UUIDsUUIDsGIDsNumericUUIDs
Rate limitsNone60–120 req/minNoneCost-based pointsNonePlatform-managed
SlugAutoAuto from nameMust set path.aliasAuto ("handle")Native ?slug= filterseoSlug
Pagination maxUnlimited10050250 (cursor)100100
Content modelUser-definedUser-definedUser-definedFixedSemi-fixedFixed