Appearance
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:
GET /api/access→ all collection slugs (blogs, tags, media, …).- Fetch 5 sample documents per collection (
?limit=5&depth=1&locale=all). - Infer field types from actual values (object with
root.children= Lexical richtext, array of objects withid= relationship).
Publishing:
- Markdown body → Lexical JSON (
markdownToLexical()). - Cover image: multipart FormData upload to
/api/media→ numeric media ID. - Build payload from the field map (handles dot-notation like
meta.title). POST /api/{collection}?locale={primaryLang}→ ID + slug.- 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-Remainingand retries on 429.
Schema discovery — first-class endpoints, the cleanest of all:
GET /v2/sites/{siteId}/collections→ all collections.GET /v2/collections/{collectionId}→ complete field definitions (name, type, required, validations) — no sample inference needed.
Publishing:
- Markdown body → HTML (
markdownToHtml()). - Cover image: passed directly as
{ url, alt }— Webflow downloads it, no upload step. - Field map →
fieldDataobject. POST /v2/collections/{collectionId}/items/live→ creates AND publishes in one call.- Additional languages:
PATCH .../items/livewithcmsLocaleId(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/tokenwithclient_id+client_secret→ short-lived Bearer token (expires_in~300s), cached in memory and refreshed with a 30-second buffer. The request goes throughcmsFetch, which preserves the POST across a canonical-host redirect — a storedhttps://www.example.comfor a site serving the apex previously arrived asGET /oauth/tokenand returned Simple OAuth's405HTML page (the HRTH outage). - Timeouts:
OAUTH_TIMEOUT_MS30s,REQUEST_TIMEOUT_MS20s, both astimeoutMsso 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:
GET /jsonapi→ links to all resource types.- Filter
node--*(content types) andtaxonomy_term--*(vocabularies). - Fetch 5 samples per content type; infer attribute types from values (object with
value+format= richtext, ISO date string = date) and relationship types from therelationshipssection (taxonomy_term--*= relationship,media--image= media). - Doc counts: core JSON:API returns no total count on collections (
meta.countonly exists with contrib modules), so a full sample page triggerscountResources— 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). - Meta fields: the Metatag module's computed
metatagoutput (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_tagsby default, any name — MICE usesfield_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 readsnullon every node without an override. A match becomes amanagedfield carryingadapterMeta.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:
- 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). - Slug:
path.aliasmust be set explicitly (e.g./blog/my-post-title) — Pathauto doesn't fire via API. - Cover image is a 3-step chain: download to buffer →
POST /jsonapi/media/image/field_media_imageasapplication/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
imageparagraph. 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-shapedclip-path), while an inline image renders as ordinary body content. Guarded bycms-publish-dryrun.mjs. - The
<img>carriesdata-entity-type="file"+data-entity-uuidso Drupal'seditor_file_referencefilter keeps the src resolved and registers file usage.src,altand both data attributes are inside the defaultbasic_htmlallowed-tag list; a text format that strips<img>loses the in-body image (the cover field is still set).
- Never as its own
- Split the payload into attributes (text, body, status, path) vs relationships (taxonomy/media/author UUIDs) — entity references MUST go in
relationships. - Wrap in the JSON:API envelope
{ data: { type: "node--article", attributes, relationships } }→POST /jsonapi/node/{contentType}. - 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 fromservices/drupal.jsbecause 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 matchingapiversion, 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 keepscms-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) andtranslationSupport()(public — Settings renders it)._addTranslationViaModulebuilds 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-savepath. - 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/translatabilityfirst (_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'swarningsnames the checkbox. Locked by three cases incms-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.jsbuilds a byte-stable.tar.gzin memory (hand-rolled ustar +zlib, no dependency) served byGET /api/onboarding/:ws/cms/drupal/module; readiness comes fromGET /api/onboarding/:ws/cms/drupal/translations. The UI isfrontend/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 grant —
POST 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 sendX-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
userErrorsin 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:
- Markdown body → HTML (renders as a "Classic" block in Gutenberg).
- Cover image: download binary →
POST /wp/v2/mediawithContent-Disposition→ numeric media ID. POST /wp-json/wp/v2/postswith title, content, excerpt, slug,featured_media, categories/tags (numeric IDs), status, author, and SEO meta.- Additional languages (Polylang): a new post per language,
lang+translations[{primaryLang}]sent as both query args (the vendor-documented form) and body fields — hencelocaleModel: '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 theAuthorizationheader, 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:
- Markdown body → Ricos JSON (
markdownToRicos()), Wix's structured rich-content format. - Cover image: uploaded to the Media Manager (
imageMode: 'upload'). - Create a draft post carrying
memberId(author — API-created posts have no author without it), media, taxonomy IDs,seoDatatags (title/description), andseoSlug. - Each locale is its own draft post, linked into one group via
translationId→localeModel: 'separate'. publishDraftPostper 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 thedrafts.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):
- Markdown body → Portable Text (
format/markdown-to-portable-text.js— keyed blocks/spans/markDefs; hr lines dropped, no standard PT node). - The generic funnel builds the payload;
_toSanityDocthen applies what the funnel can't know:_type(the target document type) on the root, relationship ids wrapped as{ _type: 'reference', _ref }(arrays get_keyper member), any field mapped at<x>.currentstamped{ _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). - One
createmutation withreturnDocuments=true; the returned_idis the cms_id,slug.currentthe slug. 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
| Payload | Webflow | Drupal | Shopify | WordPress | Wix | |
|---|---|---|---|---|---|---|
| API style | REST | REST | JSON:API | GraphQL | REST | REST |
| Auth | Static API key | Static Bearer token | OAuth2 (token exchange) | OAuth2 client credentials | Basic Auth (App Passwords) | Static API key |
| Rich text | Lexical JSON | HTML string | HTML in { value, format } | HTML string | HTML string | Ricos JSON |
| Images | Upload → numeric ID | URL passthrough | Binary → Media UUID | URL passthrough | Binary → numeric ID | Upload (Media Manager) |
| Taxonomy | Collections, numeric IDs | Collections, string IDs | Vocabularies, UUIDs | Flat tag strings | Categories + Tags, numeric IDs | Categories + Tags, UUIDs |
| SEO fields | Regular fields | Custom fields | Metatag module | Metafields | Plugin meta keys | seoData tags |
| Localization | ?locale= param | cmsLocaleId param | URL prefix | Translations API | Plugin (separate posts) | Translation groups (separate posts) |
| Entity IDs | Numeric | String UUIDs | UUIDs | GIDs | Numeric | UUIDs |
| Rate limits | None | 60–120 req/min | None | Cost-based points | None | Platform-managed |
| Slug | Auto | Auto from name | Must set path.alias | Auto ("handle") | Native ?slug= filter | seoSlug |
| Pagination max | Unlimited | 100 | 50 | 250 (cursor) | 100 | 100 |
| Content model | User-defined | User-defined | User-defined | Fixed | Semi-fixed | Fixed |