Content API reference

The content API is how a person or AI assistant changes a Moseik website. You author per-site content (HTML, CSS, JS, theme tokens); you never touch platform code. Every change is versioned, validated by a safety gate, classified, audited, and made reversible.

Base URL: your platform host (e.g. https://api.moseik.app). Request and response bodies are JSON unless noted. Errors are application/problem+json with a machine-readable code.

Authentication

  1. POST /v1/auth/token with { "clientId": "...", "clientSecret": "..." } → { "token": "..." }. Short-lived, scoped to one tenant.
  2. Send Authorization: Bearer <token> on every subsequent call. The tenant is carried by the token — you never pass a tenant id in the body.

Keep the client secret out of chat logs and prompts.

The same token works as the bearer on https://api.moseik.app/mcp.

Draft websites: build first, the human claims it

POST /v1/drafts { "businessName", "vertical"? }, unauthenticated. Returns 201 with tenantId, a private previewUrl, a single-use claimUrl, expiresAt and a credential for that one site. The site starts from the self-serve template with the name filled in.

Sites created as a copy

A site can start as a copy of another one — a draft from POST /v1/drafts and a site a human starts in the dashboard are both copies of the starter template. Each of its assets then has one version authored by duplicate, so the [cross-writer rule](#writes-that-remove-another-writers-lines-are-refused) and the [deleted-lines guard](#writes-that-delete-live-content-are-refused) would ordinarily refuse replacing it. Both stand down for your first write to each asset, while the copy has no custom domain serving — replace the starter page, stylesheet or script wholesale, with no acknowledgment.

A copy is kept out of search results until it has a domain of its own, and its contact record starts empty: set it with PUT /v1/site { "contact": … }.

Reading a previous version

Every versioned asset — a page, and also the singleton stylesheet, site script and chrome sections — exposes its history through the pages routes:

GET /v1/pages/{pageId}/versions             -> [{ id, editClass, state, createdBy, createdAt }, ...]
GET /v1/pages/{pageId}/versions/{versionId} -> { payload, ... }

For a singleton, take pageId from that asset's own response — GET /v1/stylesheet returns it. This is how you recover content an overwrite removed, and how you fetch a common base to merge against instead of pushing last-write-wins.

?version= on the asset endpoint is refused with 400, deliberately: it used to be ignored and return current content with 200, so a recovery attempt looked like it had succeeded and handed back the very content being recovered from. That refusal covers every content read — /v1/stylesheet, /v1/script, /v1/chrome, /v1/pages and /v1/pages/{id} — and the error body's versionsPath names the route that works.

Knowing whether live is still yours

Every content read also reports who wrote what is live, and when:

GET /v1/stylesheet -> { css, pageId, latestVersionId, lastWrittenBy, lastWrittenAt, ... }

lastWrittenBy is a credential id, and you already know your own, so the check is one comparison with no extra call:

if (live.lastWrittenBy && live.lastWrittenBy !== myClientId) {
  // someone else has written since you last synced — re-read and merge
  // BEFORE building a write on a base you never saw.
}

Both fields are present on /v1/stylesheet, /v1/script, /v1/chrome (per section), /v1/pages/{id}, /v1/partials, and on /v1/pages?include=payload rows. They are null when nothing has been written yet — never absent, so !== myClientId cannot misread a missing key as a fork. The plain /v1/pages index omits them to stay cheap; use ?include=payload when you want them in bulk.

This is the early warning; the refusals below are the late ones. Prefer this — it fires while you can still merge cheaply, rather than at the moment a write is already trying to destroy something.

Two more places state the same fact so it cannot be missed:

changed: [{ action, pageId, route, by, at }], changedMore }. otherWrites: 0 means nothing moved while you were away. changed[] names the assets other credentials touched — one entry per asset, newest first, capped at 20 (changedMore` counts the rest). Re-read exactly those before you edit, or everything you build on a stale asset has to be redone on the rebased copy.

Writes that remove another writer's lines are refused

When you were not the last writer of an asset, the platform computes the lines that entered it since your own last version — everything, if you have never written it — and refuses the write with 422 write.cross_writer_overwrite if your payload removes any of them outright. The response names the other credential (lastWrittenBy), when they wrote (lastWrittenAt), the count of real removals (droppedForeignLines) and a sample.

Four properties worth knowing:

To remove another writer's work deliberately, resend with X-Mk-Overwriting-Writer: <their credential id> — the id is in the refusal and on every content read, so producing it proves you know whose work you are removing. System-minted versions (a revert, seeded content) count as foreign for the same reason: they restored content you do not hold locally.

The owner edits too

A site's owner can change text and images on a page from their dashboard. Each change publishes at once as a version authored by dashboard:<siteId>, with no lease and no hold, so your next write to that page meets If-Match and this rule. The refusal names the dashboard author: re-read, carry their change into your copy, and resend. Acknowledge it with X-Mk-Overwriting-Writer only when the owner asked you to undo their change. The owner can also change the business details under Site details, so read GET /v1/site before stating what is on file.

If you go probing this, probe authorship — not size. Truncating a page by N lines to find "the threshold" gives a confident wrong answer: the cut only trips the rule once it reaches a line the other credential added, which depends on where their edits sit. Count is not the variable, authorship is. Delete one line you know is theirs and one you know is yours, and the behaviour is legible immediately.

Note that "yours" means present in your own last version of this asset. On an asset you have never written there is no such version, so every live line is foreign and a deletion is refused — even if you wrote that line under a different credential. (Restructuring it still passes.)

Taking over an asset someone else created. Your first write to it is a special case worth expecting: you hold no version of your own, so every line in the asset is another writer's and the whole file is measured against your submission. Restructuring it wholesale still passes — only lines that end up gone with nothing in their place refuse — but a ground-up replacement of their content is exactly the case the acknowledgment is for.

Writes that delete live content are refused

A wholesale write (PUT /v1/stylesheet, /v1/script, /v1/chrome/:section, a page payload, a partial) is checked for content it deletes before anything is stored. If it removes more than 20 lines that are live right now and are not in what you sent, it is refused with 422 write.destructive_overwrite.

A line that is live several times and comes back fewer times counts each lost copy, up to three per distinct line — so dropping a record out of a repeated structure costs what it destroyed rather than only what was unique about it. The sample lists each such line once, so it is often shorter than the count.

The response names the count, the current latestVersionId, and a sample of what would be lost. To proceed deliberately, resend with X-Mk-Deleting-Lines: <count> matching exactly.

Read the header and its value off acknowledgeWith — do not assemble them. The refusal carries the exact pair to send back, so nothing has to be parsed out of the prose or guessed from a field name:

{
  "code": "write.destructive_overwrite",
  "detail": "…deletes 35 lines that are live right now…",
  "deletedLines": 35,
  "latestVersionId": "ver_…",
  "acknowledgeWith": { "header": "X-Mk-Deleting-Lines", "value": "35" }
}

Resend the identical request with acknowledgeWith.header: acknowledgeWith.value.

The same shape carries every acknowledgeable refusal, so one branch in your client handles all of them — write.destructive_overwrite (value: the line count), write.cross_writer_overwrite and write.cross_writer_fields (value: the credential id whose work you are overriding), and redirect.overwrite_existing (same). Read acknowledgeWith, echo the pair, resend — only the line count appears in detail.

This runs whether or not you sent If-Match, deliberately, because the two answer different questions. If-Match asks _is this still the version I read?_ It cannot ask _is the content I am sending descended from what is live?_ — so re-reading the version id immediately before a write satisfies it perfectly while sending content from an hour ago.

Do not use net size as your own safety check. That write GREW the file by a kilobyte while destroying those lines. A size comparison — in either direction — cannot see a deletion that lands alongside an addition.

When you hit this, the fix is almost always: re-read the asset, merge the lines you had not seen into your copy, and send the merged result.

Errors carry a fix, not just a diagnosis

Every 4xx is application/problem+json with a machine-readable code, a human detail saying what went wrong, and — this is the part worth reading — a remediation saying what to do next:

{
  "title": "Conflict",
  "status": 409,
  "code": "version.conflict",
  "detail": "The page changed since you read it.",
  "remediation": "Someone else wrote since you read. GET /v1/pages/:id/versions/:vid for the current payload, merge your change onto it, and re-send with the new base — do not re-push your local copy over the top."
}

Read remediation before deciding a request is impossible. It exists because the alternative is measurable: an agent once read "file type not in the allowlist" as "this platform cannot do uploads" and told a real client to use a dashboard that does not exist. The diagnosis was accurate; the conclusion was invented, because the response did not contain one.

A few codes carry no remediation at all. That is deliberate and means there is genuinely nothing you can change — a server-side failure, or a capability absent from the whole deployment. Report those rather than reshaping the request to get past them, and never substitute your own implementation of a platform capability that is temporarily unavailable.

A refusal's STATUS tells you whether retrying can ever work

A payload write can be refused for six different reasons. They split into two families, and the status is the split:

Branch on code for the remedy; branch on the status to know whether a retry is even the right shape. Both families used to answer 409, and two independent agent teams misdiagnosed the same guard the same way because of it — re-reading the version id fixes exactly one of the six.

codeStatusWhat is actually wrongWhat to do
version.conflict409your base moved — or your If-Match is missing or * (the detail says which)re-read the PAYLOAD, apply your change to it, resend
lease.required409you hold no live lease; if you took one, it expiredPOST /v1/lease again and retry. Your version id was never the issue
lease.held409another credential has the pen (heldByLabel)wait for expiresAt, or say who is editing. Don't spin
page.archived409the page is retired behind a redirect or tombstonecreate a page at the freed route instead
write.cross_writer_overwrite422your payload removes lines another writer addedmerge the lines the response names, resend
write.destructive_overwrite422your payload drops >20 live lines that appear nowhere in itre-read, merge, resend
write.unguarded_truncation422no If-Match, and the write deletes ≥ 40% of a shared assetre-read, confirm you meant it, resend with If-Match
write.cross_writer_fields422your PATCH changes title/meta fields another writer setleave theirs at current values, or acknowledge by name

The author a refusal names may be ours

duplicate, onboard and system are platform actors, not other agents, so a refusal names them with a description: onboard (the starter content this site was created with).

Onboarding no longer seeds content, so a site created now cannot produce an onboard refusal — your first write to each asset is a first write. You will still meet one on a site onboarded earlier, which does carry onboard-authored versions: acknowledging is the correct move there and nothing clears with time, because unlike a duplicate there is no source site still holding those lines.

X-Mk-Overwriting-Writer takes the bare id (onboard), never the description.

The If-Match value may be the bare version id or a quoted ETag ("ver_…", W/"ver_…"). * is not accepted on a payload write — it asserts only that the page exists, which is the thing CAS is here to check.

Retrying with Idempotency-Key

Every write accepts Idempotency-Key: <unique string>. A key is remembered for 48 hours, and only writes that LANDED are stored — a replay comes back byte-identical with Idempotency-Replayed: true and mints no second version.

So reusing one key across your own retries is safe: a retry after any refusal really runs. Use a new key when you mean a genuinely new write, and the same key when you are retrying the same one.

Discovery

It is content-addressed, like /docs. The response carries a manifestVersion derived from its own content plus a per-section hash in manifestFreshness.sections, and the endpoint honours If-None-Match — so "has anything changed?" is one conditional request that usually answers 304. When it does change, compare the section hashes to see which part moved and re-read only that. manifestFreshness.buildTag is the deploy tag, reported separately so a no-op redeploy does not read as a content change.

forms.fieldTypes is the field-type vocabulary, so you never hardcode it. form is the complete set data-fields[].type accepts on <mk-component name="form">; collectionOnly (image, link) is what a collection schema may add and a form may not, and formOnly (file) is the reverse. fileKinds lists what a file field's accept may name. Both are derived from the renderer's own constants, so they cannot lag what a form actually renders; a copy in your own code can, and a wrong copy means either a type you never emit or a page the gate rejects. Editing either set moves manifestFreshness.sections.forms.

Write budget — and why you must not infer it

GET /v1/limits reports the caller's real budget right now:

{
  "buildMode": { "active": true, "reason": "no-live-domain", "effect": "…", "endsWhen": "…" },
  "frozen": false,
  "rateLimits": {
    "write": { "used": 3, "limit": 30, "remaining": 27, "windowSeconds": 60,
               "windowResetsAt": 1785900000000, "enforced": false }
  },
  "autoFreeze": { "enforced": true, "mode": "build-backstop", "thresholds": {…},
                  "current": { "writesLastHour": 12, "rejectionsLastHour": 0 } }
}

Read enforced, not remaining. The limits are not constant:

You do not have to poll for the transition. Every write response carries the current posture, and the write that crosses the boundary says what changed:

On a write responseMeaning
X-Mk-Build-Mode: active / endedthe posture right now, on every write — including a rejected one
warnings[].code = limits.build_mode_endedbuild mode ended since your previous write. Fires once per credential, and names the new caps
warnings[].code = limits.freeze_approachingyou are past ~75% of the freeze ceiling; the detail carries how many writes are left
warnings[].code = trial.expiredthe write is saved, but the site's free trial has ended and nobody can see it until a plan is chosen
X-Mk-Limits-Warningthe same codes, as a header, for a script that does not read the body

The warnings are appended to the warnings array the gate already returns — so if you are reading gate warnings, you are already reading these. On a refusal (application/problem+json) the body keeps its exact shape and only the headers carry the notice.

The auto-freeze is the constraint that matters, and it is not a throttle: exceeding roughly 60 writes or 20 rejections in an hour freezes the tenant, which needs a human to clear and does not reset when the hour rolls over. autoFreeze.current tells you how close you are. A rejected write counts toward the rejection ceiling, so retrying a failing write in a loop is the fastest way to freeze a site.

Calling /v1/limits consumes none of the budget it reports.

Forms and leads — prove delivery, don't infer it

A form is the one thing on a Moseik site whose failure is invisible from the authoring side. Every PUT, gate check and publish returns success whether or not a submission will ever be delivered, because delivery happens on the visitor's POST. A form can therefore be broken for weeks while every signal you have says the page is fine, and the only thing lost is leads — which nobody counts until someone asks why there aren't any.

One site shipped 40 pages with two forms and captured zero leads, ever. Both forms were broken, in two different ways, and the gate passed, the dry run passed, the payload PUT returned 200 and the screenshot rendered the form perfectly the whole time.

Top-level buildRouting says whether any of those recipients is receiving anything yet. Before a site's own domain serves, email notifications go to a build inbox instead, so notify describes where leads will go rather than where they are going.

nextOffset is the completeness field — check it. Page-declared forms are read 30 published pages at a time, so nextOffset is the index the next window starts at, and null only when this one reached the end. Walk it with ?offset= to get a complete list on a site of any size:

`` GET /v1/forms → nextOffset: 30 GET /v1/forms?offset=30 → nextOffset: null (complete) ``

scanned / totalPages and a scanNote say the same thing in prose. A launch check that read .forms and iterated it once reported success over three live forms it had never been shown — the caveat was a sibling key, easy not to notice. nextOffset is harder to ignore, because acting on it is a next call rather than an arithmetic. Site-wide forms (chrome, partials) are included in every window.

`` POST /v1/forms/enquiry/test { "pageId"?: "pg_…", "data"?: { … } } POST /v1/forms/enquiry/test?notify=1 → also send the email POST /v1/forms/pg_…~enquiry/test → the page-scoped key, as rendered ``

One key on several pages is several submit URLs, and a bare key exercises one of them. Name the page — with { pageId }, or by posting to the page-scoped key pg_…~enquiry, which is the instance id the rendered form actually posts to and the one you can read off the page you are debugging.

A named page is read directly, so this reaches a form beyond the listing's scan window above. Without it, a form on page 31 of a 33-page site could not be verified through the API at all.

``json { "ok": true, "reached": "stored", "stages": ["registered", "spam-checks", "stored", "notify-skipped"], "status": 200, "leadId": "lead_…", "instanceId": "pg_…~enquiry", "lead": { "stored": true, "isTest": true }, "crm": { "forwarded": false, "note": "Not exercised…" }, "fidelity": { "hiddenFieldsFrom": "served-object" } } ``

Read reached, not just ok. The stage it got to is the diagnosis — a bare 403 is indistinguishable from "I wired this wrong", and that ambiguity is most of why the original incident took so long to find. A failure also carries nextStep.

reached is the furthest rung of the delivery ladder below, and only ever one of these four values (or not-registered):

reachedWhat it means
not-registeredthe handler cannot find the form in any published payload — publish what declares it
registereda spam check or field validation refused it; error is what a visitor would see
spam-checksit passed the spam checks but was not stored
storeddelivery works
notifiedthe email path ran too (?notify=1)

stages is a log, not a ladder — it also records OUTCOME events that are not progress and not failures: notify-skipped, and crm-skipped / crm-synced / crm-failed. Those appear in stages and never in reached.

That distinction matters if you have a check pinned to an exact string: reached used to be "the last thing appended to stages", and since the CRM event is appended last, a fully working form on a site with no CRM reported reached: "crm-skipped". A healthy form now reports stored by default and notified with ?notify=1.

The CRM outcome has its own field rather than being buried in stages:

crm.forwardedcrm.note says
truethe lead reached the configured CRM
falsenot exercised (needs ?notify=1), none configured, or it failed

A configured CRM that is failing is the case worth watching for — leads are stored and not arriving, which is invisible from every other surface.

Four things worth knowing about how faithful it is:

Nothing to clean up. The stored row is real — that is how the genuine storage path gets proven — but marked, and GET /v1/leads leaves it out. Pass ?includeTest=1 to see test rows, each flagged isTest: true.

Analytics, ad pixels + cookie consent

PUT /v1/site { "analytics": { "ga4MeasurementId": "G-ABCD1234",
                              "metaPixelId": "123456789012345",
                              "consentBanner"?: true,
                              "privacyPolicyUrl"?: "/privacy" | "https://…" | false,
                              "termsUrl"?: "/terms" | "https://…" | false } }
PUT /v1/site { "analytics": null }        → removes all of it
GET /v1/site                             → { analytics: { enabled, … } }

A pasted gtag or Meta pixel snippet cannot work on a Moseik site — the page CSP pins script-src 'self', and the sanitizer rejects a <script> in your HTML regardless. This setting is the route. Either field alone is a valid configuration, and a site's CSP gains only the hosts its own settings earn.

metaPixelId is the 15- or 16-digit dataset/pixel id from Events Manager, quoted as a string — unquoted it is a number too large to survive a JSON round-trip, so a bare number is refused (422 analytics.meta_pixel_id_invalid) rather than silently rounded.

What the platform does when you set it: serves a first-party /__mk/analytics.js, shows one consent banner for both providers, and fetches neither gtag.js nor fbevents.js until a visitor accepts, so a decline collects nothing (there is no <noscript> image pixel anywhere — that would fire before any decision). It adds the configured vendors' origins to that site's CSP (script/connect/img only — never form-action, never default-src) and re-renders the site, because the <script> tag lives in the cached HTML. The response reports rerenderQueued.

This field is a whole-object replace. Send every provider you want to keep. Writing { "metaPixelId": "…" } on a site that already runs GA4 is refused (422 analytics.provider_omitted) rather than quietly switching GA4 off; to remove one provider deliberately, set it to null.

A GTM container id (GTM-…) is refused (422 analytics.gtm_container_refused). A Tag Manager container loads arbitrary scripts, so accepting one would hand code execution on the site to whoever can sign into that container. Use a GA4 web stream's measurement id.

The banner links the site's privacy policy and terms. By default it points at /privacy-policy and /terms-and-conditions, and a default link renders only while a published page exists at that route, so a site that never wrote those pages shows no dead link. Override with privacyPolicyUrl / termsUrl: a site path, an https:// URL, or false to remove that link even when the default page exists. The PUT response's consentLinks reports whether each link will render and whether a page answers there. Other schemes and //host forms are refused (422 analytics.consent_link_invalid).

Style the banner via .mk-consent, .mk-consent__text, .mk-consent__actions, .mk-consent__accept, .mk-consent__decline, .mk-consent__link. Minimal token-driven defaults ship in the base layer so it is never invisible; override them freely. Add a "Cookie settings" link anywhere with window.mk.consent.reopen(), and read the choice with window.mk.consent.get().

The banner is a fixed overlay of variable height — around 63px on a desktop and 147px on a phone, since the copy wraps — and it takes the pointer event, so a control underneath it is unclickable rather than merely hidden. The platform publishes the live height on :root as --mk-consent-height (0px once dismissed, and unset on a site with no banner, so always read it as var(--mk-consent-height, 0px)). The base layer already spends it as padding-bottom on body and scroll-padding-bottom on html, which keeps page content clear of the banner and stops scrollIntoView or a Tab onto an off-screen control parking that control behind it. Read the variable yourself if your layout reserves the space somewhere else — for example scroll-margin-bottom on the steps of a form, or a footer bar of your own.

consentBanner: false suppresses the platform banner for a site that already has its own. It does not mean "track everyone": nothing loads until that site calls window.mk.consent.set("granted"). Consent becomes the site's responsibility.

Expect lower numbers. Visitors who decline are not counted, so a Moseik site reads lower than a site with no notice. That is the notice working, not a fault — worth telling the client before they compare.

Custom event tracking — what you would have used GTM for

window.gtag / window.dataLayer and window.fbq exist from the moment the page loads, whatever the consent state, so your island can track whatever it likes without checking anything:

// island — no consent check, no feature detection, no try/catch
document.querySelector(".book-viewing")?.addEventListener("click", () => {
  window.gtag("event", "generate_lead", { method: "book_viewing" });
  window.fbq("track", "Lead", { content_name: "book_viewing" });
});

Events fired before a visitor accepts are queued and sent when they do. If they decline, nothing loads and the queue is discarded — so you never need to branch on consent yourself.

Each global exists only when its id is configured. On a site with no metaPixelId, window.fbq is undefined and calling it throws — deliberately, so a mis-targeted event is loud rather than silently going nowhere. GET /v1/site reports trackingGlobals with exactly what this site has, and window.mk.consent.metaPixelId / .measurementId are the same answer client-side (null for a provider that is off).

This is the replacement for a Tag Manager container: custom events, conversions, ecommerce parameters and user properties are all ordinary gtag("event", …) calls, versioned and reviewed like the rest of your site JS instead of edited in a separate web console. GA4 enhanced measurement (scroll depth, outbound clicks, file downloads, site search) is already automatic — you do not need to wire those at all.

What this does not give you: any other ad platform's pixel (LinkedIn, TikTok, Pinterest, X). Those are separate origins and remain blocked by the CSP, and GTM — the usual way people install them — is refused. Google and Meta are the two that have been reviewed and added by name; a third takes a platform change, not a setting. Nor is there server-side conversion forwarding: Meta's Conversions API is not wired to the lead pipeline, so a lead is a browser fbq("track","Lead") from your island or nothing.

The site owner is emailed when analytics is switched on; the change is audited.

The form a component renders posts to POST /__forms/{pageId}~{formKey} (first-party — the page CSP pins form-action 'self'). You never build this URL; the renderer stamps it. What the responses mean:

The endpoint answers in two shapes, chosen by Accept. Send Accept: application/json and every outcome below comes back as {"ok":true} / {"ok":false,"error":"…"} with the same status code, and success is 200 rather than a redirect. That is how /__mk/forms.js submits the form without reloading the page. Anything else (a browser-native POST, no JavaScript) gets the redirect described here, unchanged.

ResponseMeaning
303 → ?submitted=1Accepted and stored. This is success. (200 {"ok":true} under Accept: application/json.)
400Your submitted fields don't match the form's declared schema — check data-fields, including required.
404No form with that key was found for this site. The key comes from data-form-id (defaulting to the component name).
403Anti-abuse verification refused it — usually a page served from a render older than the current anti-bot token. Not fixable by editing the page; fix it by re-rendering the site, which you can do yourself — see below. Never "fix" it by removing hidden fields.
429Rate limited: more than 5 submissions per minute from one IP.

Two behaviours worth knowing, because they are deliberately asymmetric:

Rejections are recorded and alerted to the platform team, and the fleet dashboard flags any site with published pages and no lead ever captured.

Wendell chat widget

PUT /v1/site { "wendell": true }    → every page loads the platform-served widget loader
PUT /v1/site { "wendell": false }   → removes it
GET /v1/site                        → { wendell: { enabled, usage } }

The loader is /__mk/wendell.js, served first-party, so it runs under script-src 'self'. Each change re-renders the site (rerenderQueued); writing the current value returns unchanged: true and re-renders nothing. It draws a launcher only when the URL carries ?wendell, then for the rest of that browser tab, and the launcher opens an iframe on https://wendell.bot/widget. The origin is fixed in platform code.

The host page answers only messages from that frame and that origin, and posts only to that origin:

Frame sendsHost page does
{ type: "wendell:context-request" }replies { type: "wendell:context", url, path, title } (also sent on frame load)
{ type: "wendell:reload" }reloads the page
{ type: "wendell:navigate", path: "/about" }navigates to that same-site path; anything not starting with one / is ignored
{ type: "wendell:close" }hides the panel

The host page never handles a sign-in token. Publishing re-renders in the background, so ask for a reload only after the publish call returns, and expect a short lag before the new page is served.

The owner's dashboard is a second host. A site connected through Get Wendell, or with the setting on, shows a "Make changes with Wendell" button on every page of its Moseik dashboard. It opens the same /widget frame with the same messages; on Pages the chat docks beside the site preview. The differences:

The frame's parent origin is the dashboard (https://app.moseik.ca), not the site.

Re-rendering the site to clear a 403 — you can do this yourself

A stale-token 403 needs the site re-rendered, and you don't need a human for that — publishing either shared asset re-renders every page. Two ways:

Re-render for a stale-token 403 and for nothing else. It is a site-wide operation; it is not a way to work around a page that fails the gate.

And never re-push pages because the PLATFORM shipped a runtime change. The ?v= on /__mk/components.js is a cache key, not a version selector — the worker builds that file from deployed code and ignores it, so a new visitor gets new behaviour on pages nobody re-rendered. A re-render only buys the returning visitor, whose browser holds the old bundle under a year-long immutable, and that is an operator's site-wide re-render rather than your writes.

_Corrected 2026-08-05; runtime-change note added 2026-08-16._

If you keep local copies, read this first

The platform is the source of truth. A local copy of a page is a cache, and it goes stale without telling you. You are probably not the only thing that has edited this site — another session, another agent, another model, or a human on the dashboard.

This is not a theoretical worry. One site had 23 project pages rebuilt on a newer two-column layout; an agent's local files still held the previous flat markup, and the next routine content edit from those files would have reverted the layout on all 23. It was caught by chance.

If-Match does not protect you here, and it is important to understand why. It proves you read the current _version id_ — not that your _content_ is based on it. So this sequence "works" and destroys the newer version:

PUT /v1/pages/:id/payload   (If-Match: an old id)   → 409 version.conflict
GET /v1/pages/:id                                   → grab the new id, ignore the payload
PUT /v1/pages/:id/payload   (If-Match: the new id)  → 200, live content overwritten

The 409 now says so explicitly, because that is the move a model reaches for first. When you get one, re-read the payload and apply your change to what came back.

The sync protocol

Every session, before you edit anything:

  1. GET /v1/pages — cheap, no payloads. Every page carries latestVersionId (the CAS base for its next write) and publishedVersionId (what is serving).
  2. Compare each latestVersionId against the id you recorded when you last took that local copy.
  3. Any page whose id differs has changed since you last saw it. Treat your local copy as wrong, not as a starting point.

Send latestVersionId, not publishedVersionId. They coincide only for a page with no unpublished draft; for any page that has one, the published id is a stale base and the write is refused with a real version.conflict you did nothing to earn.

If you never recorded the ids — the usual case, and the one the incident above was:

GET /v1/pages?include=payload

One call, every page's live payload plus the payloadVersionId it came from. Store that id next to each local copy and step 1 works from then on. ?ids=a,b,c narrows it to specific pages, which is also how you continue if a response comes back truncated: true (it names the leftovers in omittedPageIds — it never silently returns a short list, because a sync that _looks_ complete is exactly how a mirror goes stale).

Then, on every write: send If-Match: <payloadVersionId> — the id the content you edited actually came from. Not one you re-fetched afterwards.

...and to these docs

Your copy of the documentation goes stale the same way a page does, and the platform's contract really does move. GET /docs/versions.json:

{
  "docsVersion": "447141db0a26",
  "docs": [{ "slug": "api", "title": "…", "hash": "0f5ec0ff4da0", "bytes": 47918, "url": "/docs/api.md" }]
}

Store docsVersion. Unchanged means nothing changed anywhere — stop there. Changed means compare each doc's hash against sha256(your copy).slice(0, 12) to see which ones moved, then read the changelog for what changed rather than re-reading everything.

Every /docs route emits an ETag and honours If-None-Match, so if you'd rather not hash anything, a conditional GET returns 304 with no body when your copy is current.

The same applies to shared assets

GET /v1/stylesheet and GET /v1/script return latestVersionId and emit it as an ETag. Chrome (GET /v1/chrome) and partials (GET /v1/partials) return latestVersionId per record. All four accept If-Match and refuse a stale write with 409/422, and the shared assets additionally refuse an unguarded write that deletes most of the asset — see [What happens if you DON'T send If-Match](#what-happens-if-you-dont-send-if-match). Chrome and partials are inlined into every page that uses them, so a stale overwrite there spreads site-wide from one write.

_Added 2026-08-05._

The single-writer lease

Only one writer may edit a tenant at a time.

It lasts 300 seconds, and a successful write extends it. So a session that keeps writing keeps the pen, and a session that crashes or pauses for longer than that frees the site on its own. (Writing did not use to extend it, which meant a normal multi-page build could lose the lease at the five-minute mark, mid-build.)

A write with no live lease is 409 lease.required. That is the same status as a stale If-Match and has nothing to do with your version id — so read the code, not the status: re-acquire the lease and retry. Re-reading the payload will not help, and neither will waiting. A write while someone ELSE holds it is 409 lease.held, which names them (heldByLabel) and says when it expires.

Pages

> These endpoints take a tenant token, and a tenant token is bound to a single > site (server-derived), so you cannot edit any site other than the one your token > is for.

frozen: true means the page is beyond the site's plan allowance: it keeps serving and can be archived or deleted, but a write to it is refused page.plan_frozen. Read it before a copy pass rather than discovering it one refusal at a time.

Rehearse first with ?dryRun=1: same gate, same report, status: "would-publish" or "would-hold", no page created and no page-create budget spent.

For a programmatic (template) page add "template": "city-landing", "primaryTopic": "homes:guelph", and optional "linkFromPageId". Template pages get the SEO-6 guardrails: a per-account-age velocity cap (429 seo.velocity) and thin / near-duplicate / cannibalization checks that hold the page for review (reported in the version's programmatic-seo gate step).

Read the per-item result, not the HTTP status. A 200 means the batch was accepted; each item reports its own fate:

``json { "summary": { "requested": 3, "published": 0, "held": 2, "failed": 1, "skipped": 0 }, "results": [ { "index": 0, "route": "/about", "status": "held", "pageId": "pg_…", "versionId": "ver_…", "report": {…} }, { "index": 1, "route": "/team/", "status": "failed", "code": "page.route_trailing_slash", "detail": "…" }, { "index": 2, "route": "/contact","status": "held", "pageId": "pg_…", "versionId": "ver_…" } ] } ``

It is deliberately not atomic — one bad item would otherwise throw away 19 good creates. A failed item leaves nothing behind, so its route stays free to retry. Two items claiming the same route resolve by order: the earlier index wins, the later gets page.route_taken.

Each item costs exactly what it would have cost alone. The page-create budget is charged per item, so this saves round trips, not budget. When the budget runs out part-way the remaining items come back "status": "skipped" with "code": "rate.limited" — never silently dropped. A 4xx on the call itself means the batch was malformed (batch.pages_required, batch.empty, batch.too_large) and nothing was created.

Needs the write lease, same as a single create. ?dryRun=1 rehearses the whole batch — every item is validated and none is created. Each comes back would-publish or would-hold, and the summary counts them as wouldPublish / wouldHold.

Every PATCH mints a meta version — a snapshot of the merged title+meta in its own history, separate from payload versions, so the payload's latestVersionId never moves on a meta edit. GET /v1/pages/:id/meta-versions lists them with authorship; each row is a complete restore point, so recovering an overwritten description is a PATCH that restates it. The page read reports metaVersionId / metaLastWrittenBy / metaLastWrittenAt beside the payload's fields — payload authorship does NOT cover meta edits.

If-Match is honoured against metaVersionId (optional-but-checked — send the id your edit is based on, not the payload's). Changing a field another credential set is refused (422 write.cross_writer_fields) naming the writer, the fields and their current values; a patch touching only your own fields passes free. To change theirs, resend with X-Mk-Overwriting-Writer: <their credential id>.

meta.schema adds page-specific structured data — FAQPage, Service, Article, one object or an array of up to 8. Every page already carries WebSite, BreadcrumbList, and Organization/LocalBusiness built from PUT /v1/site contact data — so a failing LocalBusiness audit is fixed there, and a @type the shell already emits is refused (schema.shell_type). @context is filled in for you; send null to clear. Full detail in authoring.

"meta": { "noindex": true } asks search engines to drop the page and keeps it out of sitemap.xml, llms.txt and post lists; false undoes it. Search engines act on their next crawl and take days to weeks to restore a page, so use it for pages that should never rank, not to hide one for a day. Setting it on / returns a meta.noindex_home warning. canonical is refused here (422 page.meta_staff_only): a wrong one silently moves ranking to another URL, so it needs a human on the staff console. You don't need noindex for the preview host: the platform already serves X-Robots-Tag: noindex and a Disallow: / robots.txt on a *.moseik.app host whenever the site has a domain of its own.

To keep a route out of sitemap.xml without noindex, send "meta": { "sitemap": false } — on create or here. The page keeps serving and stays indexable; it just stops being advertised. That is what you want for a thank-you page, a print variant, or a landing page you only link to directly. Everything else published is in the sitemap automatically — there is nothing to opt IN to. When this call changes the flag the response carries "sitemapRebuilt": true, meaning sitemap.xml was regenerated and purged before you got the response. Setting it back to true (or omitting it) re-advertises the URL.

meta.publishedAt ("YYYY-MM-DD") makes the page an editorial post — it is what puts the page into <mk-component name="page-list"> and into /feed.xml, and what they order by. Setting it on an already-published page is the normal path. When this call changes the field the response carries "postIndexesRefreshed": true, meaning the feed was rebuilt and every page carrying a page-list was queued to re-render. Send null to retire a page from the list and the feed without unpublishing it. Setting it on a route no page-list covers returns a post.not_listed warning. See authoring.

Needs the write lease (POST /v1/lease first, else 409 lease.required) and it counts against the write budget. It needs no If-Match for the payload: title and meta are page-row fields, so there is no payload version to race.

It leaves nothing behind: no version, no publish, no audit row, no SEO fingerprint. It does not consume the write budget (it is billed as a read), needs no If-Match and no lease — so you can validate while another writer holds it — and it works while the site is frozen, which is exactly when you want to prepare a correct fix.

Use it for discovery instead of spending writes against the auto-freeze ceiling (see [Write budget](#write-budget--and-why-you-must-not-infer-it)). Because it shares the real code path rather than reimplementing it, its verdict cannot drift out of agreement with the real one.

Read warnings, not just status. A would-publish means the page is _allowed_, not that it is _right_. Alongside the SEO lint and off-site link report, the render-lint step names defects that publish clean and behave wrong — every one of them checked against the fully rendered document, so a height in your site stylesheet and a tab whose panel lives in a partial both count:

WarningWhat it means
render.map_no_height.mk-map / .mk-listings-map is on the page and no rule sizes it. The hook wraps an <iframe>, which collapses
render.listings_no_mapa filterable listing SEARCH page, or a listing DETAIL page, with no listings-map on it at all
render.reveal_never_visible.mk-reveal is hidden by CSS and no rule mentions .is-in-view, so nothing ever reveals it — invisible, not just unanimated
render.tab_without_panela data-mk-tab="x" with no data-mk-panel="x" — the tab renders, the click does nothing, nothing reports an error
render.panel_without_tabthe reverse: nothing can reveal that panel, so its content is unreachable
render.nav_without_menu_trigger.mk-nav with no [data-mk-menu], so there is nothing to bind open/close to. Fine for a flat nav of plain links

These are heuristics over your CSS, so they warn and never block. The checks that need a real browser to be certain — overflow, rendered image ratios, [hidden] with a box — live on GET /v1/pages/:id/verify instead.

On a detail template, the content checks read a bound sample. A parameterised route — /listings/:mls/:slug, /upcoming-events/:slug — has no page of its own: one template serves every row, and the row arrives at request time. So before the SEO and render lints run, the gate binds one representative row (for listings, the one with the most photos out of a sample) and measures that page. The report says which:

WarningWhat it means
audit.bound_samplenames the URL and row the checks were measured against. One row, not every row
audit.unbound_*nothing could be bound, and why — no listings provider, feed down, no rows in their window
audit.bound_render_failedthe template rendered clean empty and failed with a row bound. Your page breaks on real data

When you see an audit.unbound_*, read every content finding below it as a finding about the placeholder. The one exception is the h1: seo.h1_count is replaced by seo.h1_unbound_template, because a detail component emits the page's subject as its h1 and cannot when there is no subject. The stored page is always the unbound render, so one row's content never becomes the template's published object.

| "would-hold", … }, the real rejection when there is one, billed as a read, no lease, no If-Match`, works under a freeze, and nothing is left behind — including no singleton being created for an asset that does not exist yet.

What each one tells you:

WriteThe question it answers
/v1/stylesheetdoes this CSS pass the gate, and does it delete live rules I never read?
/v1/chrome/:partdoes this header/footer pass, given it is stitched into every page?
/v1/scriptis the script within budget?
/v1/themedo these tokens pass the schema and the WCAG contrast gate, and would this publish or hold?

The theme one is worth the habit: contrast is a hard 422, so it is the one gate where "will this pass?" was previously unanswerable without attempting a global restyle.

One limit, stated plainly: these rehearsals validate, they do not preview. Only kind === "page" renders in the pipeline, so a stylesheet or chrome rehearsal does not tell you what the site will _look_ like. For that, publish to a held page or use GET /v1/pages/:id/verify.

Send the flag to any other write — a partial, a redirect, a publish, a favicon — and you get 400 dry_run.not_supported, with a supported array naming every route that does rehearse. Nothing is written. So a refusal is safe to read as "that would have been a real write", which is the one thing you need to know. Trust that array over this list: it is generated from the routes themselves.

This is deliberately a refusal rather than silence. An unknown query parameter is normally ignored, and that is what made the earlier bug so expensive: the flag was dropped, the write happened, and the response looked like an ordinary success — one --plan run created thirty live pages, and later a rehearsed POST /v1/collections created a collection that, at the time, could not be deleted at all. A route either rehearses or says so.

Do not treat a refusal as a reason to retry without the flag. If you were rehearsing, you wanted to not write; dropping the flag writes. Take the lease and do it deliberately, or rehearse the page write the content ends up in.

render=adds to the response
classesrendered.classes — sorted, unique class names. A few hundred bytes
1 / true / htmlrendered.html — the whole rendered document
omittednothing; the response shape is unchanged

Both forms also carry rendered.bytes. On a non-page singleton you get rendered.renderable: false and a reason, because a stylesheet or chrome record renders nothing standalone.

Use classes to check a component's class contract. A component's data-* attributes and the classes it emits are two separate things, and validation only confirms the first: a dry run accepts data-columns="2" and passes every gate step whether or not .mk-form--cols-2 — the class you are about to write CSS against — is in the output. render=classes answers that directly, without spending a write:

`` PUT /v1/pages/pg_…/payload?dryRun=1&render=classes → { "dryRun": true, "status": "would-publish", "rendered": { "renderable": true, "bytes": 4821, "classes": ["mk-form", "mk-form--cols-2", "mk-form__field", …] } } ``

Do this before styling any component you haven't styled before.

_Added 2026-08-05._

Before capturing, the platform settles the page: lazy images are forced to load, the document is scrolled — vertically, and sideways inside every overflow-x scroller — so reveal-on-scroll sections and card bands become visible, content-visibility: auto subtrees are forced to render, and it waits for the real faces and for every image to decode. Check the response headers rather than trusting the pixels — a blank area looks identical whether the content is empty or merely unloaded:

headermeaning
X-Mk-Settledok if everything settled, partial if not — read this first
X-Mk-Imagesdecoded/total, e.g. 11/12. A shortfall means an image failed, not that the page lacks it
X-Mk-Fontsloaded/attempted faces, e.g. 4/4. A shortfall means text is in fallback faces — do not judge type from this capture
X-Mk-Fonts-Failedonly present on a shortfall: family weight status per errored face, e.g. Heading Sans 700 error
X-Mk-Fonts-Timeoutonly present if the faces hadn't finished loading inside the cap
X-Mk-Reveals-Forcedhow many .mk-reveal sections had to be revealed manually
X-Mk-Framesloaded/total iframes — a map region is an iframe, and X-Mk-Images does not cover it. ok requires these too
X-Mk-Boundonly on a parameterised route: which row this capture is of, or why none could be bound (see below)
X-Mk-Scroll-Yonly with ?y=: where the capture starts; less than y when the page is shorter

If X-Mk-Settled is partial, the PNG is still returned — a partial capture beats none — but treat it as unreliable evidence and retry before concluding anything about the design.

What the two counts mean precisely, because both are stricter than they look:

Both are deliberately stricter than "did loading finish", because loading finishing in failure also finishes. If a page reports partial where you expected ok, read the two counts for which axis fell short before concluding anything about the page itself.

A detail template captures one row. Screenshot /listings/:mls/:slug and you get a real listing, not an empty template — one representative row is bound first, and X-Mk-Bound names it (listing SK024909 — 34 photo(s)). When nothing can be bound — no listings provider, the feed down, a collection whose rows have all expired — the header says so instead, and the PNG is the detail component's empty placeholder.

_Both counters changed on 2026-08-05; see the changelog._

Accessibility, at 1280:

KeyWhat it reports
contrastcomputed WCAG-AA failures per element, with the measured ratio and what was required
contrastUnmeasuredelements whose colours could not be read, so their contrast was not checked — check these by eye
a11yimg_no_alt, no_accessible_name, unlabelled_control, no_focus_indicator — with the selector and a short detail

contrast covers every element with its own text, body copy included, measured against the nearest painted ancestor. Equal colours are the worst case, not an unknown one: white on white reports 1.00:1. Read contrastUnmeasured as part of the answer — an empty contrast beside a non-empty one means the page was not fully checked.

no_focus_indicator is WCAG 2.4.7: each focusable element is actually focused in the browser and its computed style compared, so outline: none with no replacement is caught. Any visible change satisfies it — outline, box-shadow, border, background, colour, underline — and a :focus or :focus-visible rule in your CSS satisfies it too. Capped low: one missing rule on a shared a selector is one mistake, not forty.

And the four render defects a screenshot cannot show you:

KeyWhat it reports
overflowhorizontal overflow at 390 / 768 / 1024 / 1440, one entry per width that overflows, each naming the offenders widest-overshoot-first. A 1280 audit cannot see any of it
imagesrendered ratio vs the ratio the CSS declares (or the file's own, when none is declared), past a 6% tolerance — reported whatever the object-fit
hiddenelements marked [hidden] that still have a box, because a class rule setting display beats the UA stylesheet
brokenImagesimages that finished loading with no pixels (complete && naturalWidth === 0)

Plus one thing a browser cannot show you — what this page looks like when someone pastes the link:

KeyWhat it reports
shareImagepresent only when the page sets no og:image, naming the image a scraper will use instead

With no og:image the choice passes to the scraper, and the usual pick is the page's first <img> — on most sites the header logo. The finding names that asset and its dimensions (fallback: { src, width, height }), because a 1280x340 transparent wordmark cropped to a social card's 1.91:1 is the real outcome. Target a 1200x630 PNG or JPEG; a WebP is not decoded by Facebook or LinkedIn and the share comes back blank.

The write gate warns about this too (seo.og_image_missing), and that was not enough on its own: one site launched with 22 of 23 routes missing a card, because the warning is identical on every page and easy to scroll past when every page emits it.

And one thing only a _rendered_ page can tell you — a connector grid that shows part of its source and nothing links to the rest:

KeyWhat it reports
connectorPagingpresent only when a /__data/<connector> response declared more rows than it returned and the document has no way onward

Because verify renders in a real browser, your island has actually run and the connector has actually been called, so this is measured rather than inferred: the response the page received, the rows that response carried, and whether the settled document has any <a rel="next"> or [data-mk-more]. It does not count the cards you drew. Each finding names the provider, returned, total and the request url.

It stays silent when the response declares no row count — there is no claim to check — and when a next-link is present, since paging then exists. Field names come from your backend, so total, totalCount, total_count, totalResults and count are all read, alongside items, results, rows, data and records for the rows. listings, places and sheet are excluded: the first has data-paging, and the others are not collections.

This is a warning, not a verdict. Showing a subset can be exactly what you meant. What it is really reporting is the part with no symptom: a client-rendered grid with no crawlable next-link keeps the unreturned rows out of the index entirely, while the page itself looks completely healthy — the cards render, nothing errors, contrast passes and the screenshot is clean. If a subset is intended, it still wants a real <a rel="next" href="?page=2"> that works with JS off.

summary carries a count per category plus overflowWidths, shareImage when that finding fired, and a one-line connectorPaging entry per provider, so a build script can branch without walking the arrays.

On a parameterised route, audit says what was measured. verify binds one representative row before rendering /listings/:mls/:slug or /<base>/:slug — the listing with the most photos out of a sample, or the first row inside its visibility window — and reports it as audit: { bound: true, route, boundTo }, with boundTo repeated in summary. When nothing could be bound, bound is false and note says why.

Read it before you read overflow. An unbound template renders the detail component's empty placeholder, and an empty gallery has nothing to overflow with — so overflow: [] on a bound page means the layout holds, and overflow: [] on an unbound one means nothing was measured. Those were indistinguishable until this field existed, and a route that overflowed by 1,378px at 1440 shipped to a live site on the strength of the second one.

Read settled before you trust any of it. The page is settled first — lazy images forced eager, reveal-on-scroll sections revealed, sideways scrollers walked, fonts awaited — exactly as the screenshot endpoint does it, and settled reports what that achieved (images: "38/40", fonts, frames, revealsForced, ok). Forty brokenImages under images: "3/40" is one settle failure, not forty page defects, and nothing else in the response can tell you which you are looking at.

Not covered: contrast of text in a fixed header stacked over a hero. The check walks _ancestor_ backgrounds, which is right for ordinary text and wrong for two elements that merely overlap. That one still needs a client-side elementsFromPoint walk.

Holding a change for approval

Most sites publish on write. When a client wants to see something before it goes live, there are two ways to hold — one per change, one for the whole site.

Per change, whenever you want it:

POST /v1/pages           { "route": "/new", "title": "New", "payload": {…}, "hold": true }
PUT  /v1/pages/:id/payload?hold=1

Both answer 202 rather than 200: accepted, versioned, not published. Nothing serves at the route until someone approves it with POST /v1/pages/:id/publish.

For the whole site, GET /v1/site → publishPolicy tells you what this site does. Anything in holds waits for a human on every write of that kind. That setting is changed by platform staff, not through this API — the point of "this client requires approval" is that the party being approved cannot switch it off.

Note the asymmetry: you can always be more cautious, never less. hold: true will hold a change on a site that would otherwise publish. There is no value that publishes a change on a site whose policy says hold.

Showing the client what is waiting

The person approving has no login, so the authenticated preview (GET /v1/pages/:id/versions/:vid/preview) is no use to them. Mint a link instead:

POST /v1/pages/:id/versions/:versionId/preview-link
→ { url: "https://theirdomain.com/__preview/…", expiresAt, versionState }

It renders that exact version on their own domain, opens with no credentials, lasts 14 days, and is no-store + noindex so it cannot be cached or crawled.

The platform does not send it. You get the URL back and decide — put it in the chat, text it, email it. That is deliberate: auto-sending would pick one channel for everybody.

A screenshot of a specific version is still available if an image is what you want: GET /v1/pages/:id/screenshot?version=ver_….

Redirects — old URLs that have no page to archive

Retiring a page you published is the archive flow (POST /v1/pages/:id/archive), which writes the 301 or 410 for you. This endpoint is for the other case: a path the site inherited from its pre-Moseik life — an old Squarespace or WordPress slug still linked from elsewhere — that 404s with nothing to archive.

GET    /v1/redirects                          → { redirects[], limit }
POST   /v1/redirects  { from, to, status? }   → 301 (default) or 302
DELETE /v1/redirects?from=/old-slug

from is always an absolute path on this site. A trailing slash is normalized away rather than rejected, because the router normalizes the request the same way and a stored /about/ would be a row it can never match.

A from ending in /* is a subtree: /lakelodge/* covers /lakelodge and every path under it, for inherited URLs nobody has a list of. It is checked only after every page, row and exact redirect has missed, so it never shadows anything live; the most specific subtree wins. /* alone is refused (the 404 page answers unknown URLs), and so is a to inside its own subtree, which would loop once that page stopped serving. GET reports each row's match as exact or subtree; delete one by the same spelling, ?from=/lakelodge/*.

to may be any of four things:

toNotes
/servicesA path on this site, resolved through the router — a collection row or listing URL counts.
/cabins#2-bedroomSame, with a fragment passed through to Location. /cabins → /cabins#x is a loop: the browser never sends the #.
/media/<id>An asset this site holds, for an inherited link to a PDF or a video. Checked against your own library.
https://booking.exampleOff-site, and only if that origin is active on this site's allowlist (GET /v1/allowlist).

It refuses these writes, each of which would damage a live site quietly:

RefusalWhy
redirect.shadows_live_route (409)A published page already serves from. The router matches redirects before pages, so this would take a working page off the site with no error anywhere.
redirect.target_missing (400)Nothing is published at to. A redirect to a 404 is worse than the 404 it replaces — it launders a broken link into a working-looking one.
redirect.is_archive_gone (409)from is a 410 written by an archive. Overwriting it resurrects a URL someone chose to retire; deleting it turns that decision into a 404.
redirect.target_not_allowlisted (400)to is off-site and its origin is not active on this site's allowlist. Add it with POST /v1/allowlist, approve it, then write the redirect.

Changing an existing mapping is refused (409 redirect.overwrite_existing), naming the current mapping, its author (createdBy — null on rows older than authorship, acknowledged as the literal "unknown"), and when it was written. Re-pointing it needs X-Mk-Overwriting-Writer: <the author the response names>. Re-asserting the identical mapping is a no-op, and a permitted overwrite still returns what it replaced. GET lists createdBy per row and marks archive-written rows with source: "archive (gone)" so you can tell them from ones you can edit.

Off-site targets go through the allowlist, and there is no way around it. An endpoint that emits arbitrary off-site 301s is an open-redirect surface, so the origin has to be one a person approved for this site — the same list, and the same approval, that lets a page reach an external origin at all. https only, and no credentials in the URL.

from takes three shapes:

Chrome (site header / footer / announcement)

Site-wide singletons stitched into every page — announcement + header above the body, footer below. Same lifecycle as a page; publishing one re-renders the site. Chrome payloads may use components, partials, and interactive hooks (e.g. a .mk-nav dropdown).

The 404 page

The body served for any URL this site does not have. Optional — with nothing published, the platform's own branded not-found page is used.

It is an ordinary payload on the ordinary lifecycle, rendered with your header and footer, so write it the way you write any page — and give the visitor a way back, which is the whole reason to author one. Publishing takes effect immediately: load any dead URL and you will see it.

Three things stay the platform's, and are not author-settable:

Reverting to a state with nothing published deletes the authored body and the built-in one comes back. Do not create a real page at /404 instead: that one is routable, indexable, and lands in your sitemap, and nothing would route dead URLs to it anyway.

Site stylesheet

The per-tenant shared CSS, versioned and published like a page. Publishing it re-renders every page.

Knowing when the re-render has reached a page

The re-render is asynchronous — the PUT returns as soon as the stylesheet is published, and the pages catch up over the next seconds to minutes. Screenshot one immediately and you photograph the previous CSS.

Every served page says what it was rendered from, in response headers:

HeaderIs
X-Mk-Stylesheet-Versionthe stylesheet version that render used
X-Mk-Page-Versionthe page version that render used
X-Mk-Rendered-Atwhen it was rendered, epoch ms

So the check is one comparison, not a heuristic:

publishedVersionId (from PUT /v1/stylesheet)  ==  X-Mk-Stylesheet-Version (on the page)

Poll the page until they match, then screenshot. Do not compare the served CSS against your local file to infer this — the platform rewrites url() in the stylesheet it inlines, so a byte comparison can never match, and every partial comparison (last-N-rules, selectors-only, skip-anything-with-url()) passes a stale page on some later edit. That approach has been tried four times and failed four times.

X-Mk-Stylesheet-Version is absent when the site has no published stylesheet, and also on a page whose stored copy predates this feature — in the second case it appears on that page's next render. Absent is not the same as empty: do not treat a missing header as "not caught up yet" and poll forever.

A per-request render — a filtered listings URL, a bound seller results page — carries no stamp, because it was not served from a stored object and has nothing to catch up to.

> ⚠️ This write replaces the WHOLE stylesheet — as does PUT /v1/script. If you > push from a stale local copy you revert everything written since you last read it. > > GET first, then send If-Match: <latestVersionId>. A stale write is then > refused with 409 version.conflict and nothing is overwritten. GET returns the > value both as the ETag header and as latestVersionId in the body; a quoted ETag > and * are both accepted, so you can pass the header straight back. > > This failure does not announce itself: the response is 200 and nothing in your own > output looks wrong. Diffing against a fresh GET before you write is the only way to > see it.

What happens if you DON'T send If-Match

Sending it is still optional — requiring it outright would break every existing caller — but "optional" no longer means unprotected, because the failure above is a _carelessness_ failure and the careless caller is exactly the one who skips the header. So an unguarded write that destroys most of the live asset is refused:

you sendresult
If-Match matching latestVersionId (or *)proceeds
If-Match that is stale409 version.conflict — nothing overwritten
no If-Match, and the write grows or editsproceeds, with a write.unguarded advisory in warnings
no If-Match, and the write deletes ≥ 40% of it422 write.unguarded_truncation — refused
no If-Match, on a site with require_if_match428 precondition.required — refused

write.unguarded_truncation carries previousBytes, submittedBytes, removedFraction and latestVersionId, so you can see exactly what it is refusing. It cannot fire on a first write (there's nothing to revert), or on an asset under 2 KB (an early site legitimately rewrites its stylesheet wholesale several times an hour).

To delete that much on purpose, send If-Match. That is the whole mechanism, not a loophole: a large deletion and an accidental revert are identical when you only look at the bytes, but a caller who sends a current If-Match has _proved it read the live copy first_ — which is the one thing the stale caller never did. The platform asks for evidence of a read instead of trying to judge your CSS.

require_if_match (tenant or global setting, off by default) turns the header into a hard requirement and answers 428 when it's absent. Turn it on for a site whose callers all send it already.

_Added 2026-08-05._

Collections

Author-managed lists whose rows outlive the page that shows them — events, offers, testimonials, activities. The client edits rows; nobody edits a page. Rendered by collection and collection-detail.

A collection's schema is the same field-list JSON a form's data-fields takes, parsed by the same validator — the form field types (text, email, tel, url, number, date, textarea, select, radio, checkbox, hidden) plus two a collection has and a form does not: image and link. A form cannot declare those two (there is no card for a visitor to click), a collection cannot declare file, and each rejection says which surface the type belongs to.

Collection-only typeValueRenders as
image/media/<asset id>, optionally ?w=<px><img class="mk-collection__image">
linka site path, a full https:// url, mailto:, tel: or sms:<a class="mk-collection__cta">

Both take an optional companion property naming another declared field whose value supplies this row's text — altField on an image, labelField on a link:

[
  { "name": "title", "label": "Title", "type": "text", "required": true },
  { "name": "photo", "label": "Photo", "type": "image", "altField": "photoAlt" },
  { "name": "photoAlt", "label": "Photo description", "type": "text" },
  { "name": "cta", "label": "Book now", "type": "link", "labelField": "ctaLabel" },
  { "name": "ctaLabel", "label": "Button text", "type": "text" }
]

The referenced field is not also rendered as its own field div — its value is already on the page, in the alt attribute or as the link's text. A misplaced altField (on anything but an image) or one naming a field the schema does not declare is rejected, not ignored. See Pictures and links on a card for the rendering rules.

An image value must name an image this site has uploaded. POST /v1/assets first and store the path it returns. The only off-site values accepted are our own Cloudflare Images account and CREA's photo CDN; any other url is refused — it would load a third party from a page that is otherwise entirely first-party — and so is a well-formed id that was never uploaded, or one belonging to a document or a video, because those render as a broken image behind a 201.

Both row writes can return warnings — the same { code, detail } shape the gate report uses, present only when there is something to say, and never a refusal. A date or time field is allowed to hold text ("Every Friday" is a thing a client types), so a value the renderer will serve verbatim when you plainly meant a date is reported rather than rejected:

WarningWhat it means
collection.date_not_a_datea date field holds an ISO timestamp (2026-06-25T19:00:00-06:00), so it renders as that literal string — no <time class="mk-date">, no parts to style
collection.time_not_a_timea time field holds a whole timestamp rather than a clock time (19:00), so it renders as that literal string — no <time class="mk-time">, no parts to style

?dryRun=1 returns them too, which is where they cost least. The "send an instant, not a bare date" rule is for visibleFrom / visibleUntil and not for a date field you display — following it there is what this warning exists to catch.

?dryRun=1 works on all six writes — would-create / would-update / would-delete, every check run, nothing written, no lease needed, billed as a read.

The delete is refused while the collection is in use, and the response names what. Four things hold it:

Still thereWhat to do first
any rowDELETE …/rows/:id each
a published page rendering itremove the component, republish
a partial or a chrome sectionsame — and chrome means every page renders it
the detail template <base>/:slugarchive that page

Rows are not cascaded on purpose: a row delete records the whole row in the audit trail, which is what makes an erroneous one recoverable, and forty rows disappearing inside one delete.collection event would trade that away. The detail template is the one a search would miss — it carries no data-collection and is bound by route, so deleting the collection out from under it leaves every row URL a 404.

The refusal is at the write because there is nowhere later to put it: a page rendering a collection that no longer exists renders an empty section, with no error and nothing in any log. Rehearse with ?dryRun=1 — every check the delete runs is a read, so the dry run reaches the same 409, and tells you what you would be giving up when it does not.

A 410 left behind by an earlier row delete stays after the collection is gone. Those URLs were published and may be linked; that does not stop being true because the collection they came from was removed.

Visibility bounds take an instant — an ISO timestamp with an offset, or epoch milliseconds — never a bare date. A bare "2026-09-30" is refused rather than guessed at: read as UTC it expires the row early for everyone west of it, which is a Saskatchewan client's event vanishing at 6pm on its last day.

Partials

Named HTML fragments referenced from pages via <mk-partial ref="…">.

Per-site JavaScript

Versioned JS served first-party at /__mk/site.js, under the strict CSP.

To see the history, or to preview a version before reverting to it, use the pageId from GET /v1/script:

GET /v1/script                              → { pageId: "pg_…", … }
GET /v1/pages/pg_…/versions                 → every version, newest first
GET /v1/pages/pg_…/versions/ver_…           → that version's raw payload
POST /v1/script/revert { toVersionId }      → roll back to it

The same shape works for /v1/stylesheet, which also returns a pageId. Preview the payload first when a human is going to be asked to confirm the revert — that is what the raw-version read is for.

This write is wholesale too, and carries the identical concurrency contract as the stylesheet — If-Match, 409 version.conflict, 422 write.unguarded_truncation, 428 precondition.required. See [What happens if you DON'T send If-Match](#what-happens-if-you-dont-send-if-match) above.

Theme

The site's design tokens — colors, fonts, type scale, spacing, radius, gradients — with the same lifecycle as a page edit. You never write CSS here; the platform compiles the tokens to :root variables + @font-face and re-renders.

Fonts

Pick vendored families in the theme (font.heading/body/mono). For a licensed font the platform doesn't vendor, bring your own:

Both writes need the write lease (409 lease.required without one) and are refused with 423 while the site is frozen — since 2026-08-16; see the changelog.

A theme change restyles every page, so it holds by default (202) unless the tenant's publish policy sets "theme": "auto". On publish the platform re-renders all pages and purges their caches — you evict nothing yourself.

External-origin allowlist

Every page is served first-party-only: its CSP blocks the browser from fetch/XHR/WebSocket-ing or form-POSTing to any third-party origin. When a site genuinely needs to reach an external origin from the browser (e.g. an upload endpoint you own on another host), that origin can be allowlisted. Approved origins are folded into connect-src and form-action for this tenant's pages only — never script-src, so an allowlisted origin can be called or posted to but can never inject script.

This is also the gate on anything the platform sends server-side on the site's behalf — a [lead webhook](#lead-forwarding--crm-and-webhook), a CRM destination. One approval per origin covers both directions, which is why approving one for a server-side webhook also widens the browser CSP for that origin. Worth knowing before you propose one: it is a slightly wider grant than the thing you asked for.

Caps: 20 active + 10 pending per site. Because the CSP is a response header (not baked into the cached HTML), an approval or removal takes effect on the next request — nothing to re-render or purge.

Who approves — and why the approve call succeeding is not permission

Approving is a human decision the platform does not enforce. :id/approve checks content:publish and nothing else: no staff key, no check that the approver isn't the proposer. Your credential almost certainly has that scope, so the call will work. That is not the same as being allowed to make it.

The human you need is the person you are working for — the site owner, the operator running you, whoever commissioned the change. Not Moseik. Nobody here is holding your work in a queue, and waiting for us is the wrong instinct: an origin can sit pending forever because everyone assumed someone else was reviewing it.

So, before you call approve:

  1. Name the exact origin to that person, and say what will be sent there.
  2. Get an explicit yes. The question is _"may this client's customer data go there?"_ — you cannot answer it on their behalf, and neither can we.
  3. Then approve. If the answer is no, DELETE /v1/allowlist/:id to withdraw it.

Two escape hatches from the pending step, neither of which you can grant yourself:

Moseik staff can also approve an origin someone chose to escalate to us. That is not the normal path.

Site + media

plan — the limits, before one refuses you

capabilities says whether each feature is on and why not. plan answers the two questions you have BEFORE building: which tier is this, and how many pages are left. Both used to be discoverable only by hitting a gate — and on a Tile site an agent planning fifteen pages found out at page eleven, after ten creates had spent its 5-per-hour budget.

FieldMeaning
restrictedRead this first. false ⇒ no plan gate applies to this site
id / labelthe tier, or null when the site has no subscription record
onPublicLadderfalse for a commercial arrangement — not a plan name to quote
pages.cap / used / remainingthe allowance, what is spent, what is left (null cap = no limit)
includesgateable capability ids this tier includes
upgrades[]tiers above this one and what each adds. Never prices

restricted: false means nothing is limited — not "limits unknown". The whole plan layer fails open: no subscription record, no page cap, no frozen pages, no capability withheld for commercial reasons, and that is the state of nearly every site. Do not read a null tier as "assume the strictest one" and refuse work you are allowed to do. If a capability is off on such a site, capabilities[] says why and the reason is never the plan.

trial, domainNeedsPlan, nextSteps — a site your human started

A site started by its owner (sign-up, or inside the MCP connection) is free for 180 days.

FieldMeaning
trial{ endsAt, daysLeft, expired }, or null when the site is on a plan or was set up by staff
domainNeedsPlantrue ⇒ connecting the site's own domain needs a plan first
nextSteps[]{ id, label, url } dashboard links to give your human: choose_plan, connect_domain

When the trial ends the site goes offline. Nothing is deleted and writes still work, and each write carries a trial.expired warning. Give your human the choose_plan link.

There are no prices here and there will not be: a figure rendered from the platform goes stale the moment somebody edits it in Stripe. Send the client to their billing tab.

Read a setting back at the path you wrote it. notifyEmail, notifyCc, notifyBcc and analytics are all top-level here, which is where PUT /v1/site takes them. The three recipients also still appear under siteConfig (same values, one source). It is null when unset rather than absent — "nobody is emailed when a lead arrives" is a state you need to be able to read.

Site config has no version stream, so its cross-writer story is the echo: siteConfig.lastWrittenBy/lastWrittenAt say who last wrote config through the API (null = nobody since authorship existed), and every PUT /v1/site response carries previousWriter — whose setting you just replaced.

Three of those answer questions agents otherwise guess at:

Builder inventory — GET /v1/listings/builders and PUT /v1/listings/scope

A site that sells its own new-construction homes configures its listings itself. MLS listings do not work this way and cannot: that market is a licensing claim and its keys belong to a brokerage, so it stays an onboarding action.

GET /v1/listings/builders?q=Fraser
  → { "builders": [ { "orgId": "org_7Kd2", "builder": "Fraser Homes", "lots": 42,
                      "cities": ["Saskatoon"], "sample": "902 Spadina Crescent E, …" } ] }

PUT /v1/listings/scope   { "orgIds": ["org_7Kd2"] }
  → { "configured": true, "builders": ["Fraser Homes"], "lots": 42,
      "province": "Saskatchewan", "rerender": "queued" }

A site already on the MLS feed is refused (listings_scope.mls_configured): one grid cannot merge both sources yet, so the row would be stored and then ignored.

A marketplace that shows every builder's lots, including builders who join later, is a staff grant on any vertical. GET /v1/site then reports listings.inventory.allOrgs: true, and PUT /v1/listings/scope is refused (listings_scope.staff_granted).

The feed budget — listings.license and POST /v1/listings/provision

The property feed rate-limits per license key, and a site that has not been provisioned shares one budget with every other Moseik site. That is worth one call at build time, because the failure it prevents is not attributable from inside your site: another brokerage's traffic surfaces here as slow or missing listings, and nothing in your pages, your CSS or your gate report points at it.

POST /v1/listings/provision      → { "provisioned": true, "alreadyProvisioned": false,
                                     "keyHint": "…a1b2" }

GET /v1/site reports the current state under listings.license:

originownBudgetWhat it means
tenanttrueThis site has its own budget. The intended state; nothing to do.
fleetfalseStill on the shared budget. Call the endpoint above.
fleet_after_decrypt_failurefalseThe site has a key and the platform cannot read it. Report this — it is a platform fault, and the site is silently back on the shared budget.
nonefalseNo license available at all on this deployment. Report it; feed reads will be rejected.

Two failures are worth knowing before you see them. 503 listings_license.provisioning_unavailable means the platform has no provisioning credential yet — the site keeps working on the shared budget, and no retry helps. 409 listings_license.exists_upstream means a key was issued for this site and the platform does not hold a copy; minting is create-only, so that one needs a human and must not be retried.

Capabilities — what this site can actually do

Capabilities are provisioned per vertical, when the site is created. A real-estate site is created with its listings scope already set; there is no enable step, no request form, and no endpoint that turns one on. That is the whole design: onboarding never contains a capability-request step, so there is nothing for you to discover.

What you do need is the ability to tell why something is off, because the failure this replaces is an agent that could not. A site once rendered thirty empty listing grids while its agent probed sixteen invented endpoints looking for a switch, and the invented explanation reached the client.

Each entry is { id, label, enabled, reason?, enabledBy, detail }. The reason codes distinguish four cases that need genuinely different responses:

reasonWhat it meansWhat to do
vertical_not_applicableThis kind of site never gets itBuild something else. Do not switch vertical to obtain it.
not_provisionedIt applies here, but nobody has set it upAsk platform staff. Do not hard-code content to fill the gap.
killedStaff switched it off for this siteLeave it. This is a decision, not a fault.
platform_not_configuredThe platform is missing a prerequisiteReport it — it is our gap, not this site's.

What the fair-housing gate is, and what it is not. It runs on realestate sites and only those, and it is a scan of page copy for discriminatory-preference language, nothing more. It does not review your form fields, your CRM, or what the business may lawfully ask an enquirer. A lead form asking marital status is not this gate's business — in Saskatchewan, spousal status governs whether a homestead disposition needs consent, so a builder has an ordinary reason to ask.

Two rules that are not obvious:

Compliance obligations appear in the list too (fairHousing), and they have no off switch by design. If one is on, expect your copy to be gated, and treat that as a copy problem to fix rather than a phrasing to route around.

Lead forwarding — CRM and webhook

When a site is connected to a destination, form submissions are forwarded there in addition to being stored here and emailed. The capability id is crm. A site may use any of these at once:

There is nothing to do in the page, for either. Build forms exactly as you would otherwise — no script, no embedded form, no fetch. A page-side integration is not merely discouraged: connect-src is 'self', so the request never leaves the browser.

You wire the webhook yourself — it is not a staff action, and it does not have to happen at launch:

GET    /v1/lead-webhook
PUT    /v1/lead-webhook   { "url": "https://…", "secret": "…", "label"?: "…" }
DELETE /v1/lead-webhook

The URL's origin must already be active on this site's [external-origin allowlist](#external-origin-allowlist) — the same gate that governs anything else a site sends data to. So the full sequence on a site that has never had one is:

POST /v1/allowlist            { "origin": "https://hooks.zapier.com", "reason": "lead delivery" }
                              → 202 pending. Now ask your operator (see below).
POST /v1/allowlist/<id>/approve
PUT  /v1/lead-webhook         { "url": "https://hooks.zapier.com/hooks/catch/123/abc",
                                "secret": "<16+ chars you also give the receiving end>" }

On a site whose publish policy is {"external-origin":"auto"} the first call lands active and the second is unnecessary. Otherwise the approve call is yours to make but not yours to decide — get the sign-off first, exactly as described under [who approves](#who-approves--and-why-the-approve-call-succeeding-is-not-permission). Once the origin is approved, every later change to the webhook is yours: repointing the path, relabelling it, rotating the secret, removing it.

PUT /v1/lead-webhook does not probe the URL. A receiver that isn't built yet, or that requires a header the platform doesn't send, leaves you with a site that reads as configured and drops every real enquiry — so build the receiving end first, then point at it, then send a test submission.

The secret is write-only. It is sealed on arrival and afterwards only a four-character hint comes back, so choose it, send it, and give the same value to whoever owns the receiving endpoint. Send { url } alone on a later call to change the destination without touching it.

Connecting the client's HubSpot

PUT    /v1/crm-credential   { "token": …, "confirmHubId"?: … }
DELETE /v1/crm-credential

The client creates a Private App in HubSpot (Settings → Integrations → Private Apps) with crm.objects.contacts.read and crm.objects.contacts.write ticked, and gives you the token. You install it — no staff member is involved.

The first call returns 428 on purpose. It carries hubId, the HubSpot account that token opens, and stores nothing. Show the client that number, ask them to confirm it is their account, and repeat the call with confirmHubId set to it. A token names a portal, so installing one chooses which HubSpot receives every lead this site collects; the confirmation is the client making that choice, and the number comes from HubSpot rather than from you.

Three refusals to expect, each 422:

CodeMeans
crm_credential.scope_too_broadthe token can reach more than contacts. Ask for a contacts-only one
crm_credential.scope_missingit cannot read or cannot write contacts, so the connector could not dedup or create
crm_credential.hub_mismatchthe confirmed account is not the one the token opens — wrong token, or the client has two

The token is write-only: nothing reads it back, here or anywhere, and only a four-character hint comes back. PUT again to rotate. DELETE disconnects — leads are still stored and emailed, and the field mapping survives, so reconnecting restores it.

Routing one form somewhere different from another

Everything above is fan-out: every form on the site goes to every destination. When different forms need different destinations — quotes to the underwriter, contact to the CRM — give each destination a name and put the name on the form.

GET    /v1/lead-webhooks                 → every destination this site can route to
PUT    /v1/lead-webhooks/<name>          { "url": …, "secret": …, "label"?: … }
DELETE /v1/lead-webhooks/<name>

A name is lowercase letters, digits and hyphens, up to 32 characters. Each destination is gated on the allowlist and seals its own signing secret, exactly like the singleton above — so two vendors cannot forge each other's posts. (A sheet destination is the exception and holds no secret at all: the middleware behind it is the platform's own.) Up to 10 named destinations per site.

Then name it on the form:

<mk-component name="form" data-form-id="auto-quote" data-lead-destination="underwriter">
<mk-component name="form" data-form-id="contact"    data-lead-destination="crm,team-inbox">

Routing to an individual agent — MoxiWorks Engage

Every destination above delivers to a site. provider: "engage" delivers to a person: each lead reaches one agent's own MoxiWorks Engage dashboard.

PUT /v1/lead-webhooks/moxi {
  "provider": "engage",
  "defaultAgentId": "kent.braaten@century21.ca",  optional — who gets a lead that names nobody
  "agentField": "agentId",                        optional — which submitted field decides (default)
  "agentFromRouting": "email",                    optional — take the agent from lead routing instead
  "source": "brokerage-hub"                       optional — shown to the agent as the lead's origin
}

Nothing to obtain first. The platform holds the Engage key for Century 21 Canada and Coldwell Banker Canada, the endpoint is fixed in platform code, and neither needs an allowlist entry. apiKey is only for a site on a different Engage account. Rehearse the call with ?dryRun=1: every check runs and nothing is stored, which matters because Engage has no test mode.

The agent id is the agent's email address. Engage maps it to that agent's own dashboard, so there is no Moxi id to look up.

Which agent gets the lead. The form's agentField value wins; defaultAgentId catches anything that omits it. On a single-agent site, set the default and no form needs to carry anything.

On a brokerage site, set agentFromRouting: "email". Engage then follows leadRouting, which follows the agent whose page the visit reached (see Authoring), and sends that agent's roster email. Name the destination with data-lead-destination on consumer forms only. An agent with no email in the roster is not routable, so their leads go to the inbox and never reach Engage; GET /v1/roster counts.routable says how many can. agentFromRouting: true sends the routing KEY instead, which is right only where those keys are Moxi ids, never on a CREA-keyed roster.

On a site without routing, put the agent's id on their own page:

<mk-component name="form" data-form-id="enquiry" data-lead-destination="moxi"
  data-fields='[
    {"name":"agentId","type":"hidden","value":"kent.braaten@century21.ca"},
    {"name":"name","label":"Your name","type":"text","required":true},
    {"name":"email","label":"Email","type":"email","required":true},
    {"name":"message","label":"Message","type":"textarea"}
  ]'></mk-component>

With neither, nothing is sent. A submission with no agent is recorded as a failed delivery naming what to fix — never guessed at, and never sent to "some agent". Agent ids are matched exactly upstream because a brokerage has several people with the same surname, so a near-miss would file a real lead in the wrong person's CRM with no visible error.

Newsletter signups — Mailchimp

provider: "mailchimp" adds the person to one of the client's Mailchimp audiences. Build the signup as an ordinary form; a Mailchimp embed or script cannot run anyway, because connect-src is 'self'.

PUT /v1/lead-webhooks/newsletter {
  "provider": "mailchimp",
  "apiKey": "…-us21",         required on first set; Account → Extras → API keys
  "audienceId": "a1b2c3d4e5",  Audience → Settings → Audience name and defaults
  "status": "pending",         optional — "pending" (default) or "subscribed"
  "fieldMap": { "how-did-you-hear": "SOURCE" },  optional
  "dc": "us21"                 optional — taken from the API key's suffix
}

No URL: the host is <dc>.api.mailchimp.com, where dc is the suffix of the API key. It is resolved and stored when you configure the destination rather than at the first lead, so the [allowlist](#external-origin-allowlist) origin is knowable up front — and a dc that disagrees with the key is refused there rather than authenticating against nothing.

Route it deliberately. Unlike a webhook, this destination must be named on a form:

<mk-component name="form" data-form-id="newsletter" data-lead-destination="newsletter">

A destination that received every form would put contact-form enquirers on a mailing list they never joined. That is the client's sending reputation and their CASL exposure, so PUT /v1/lead-webhook (the site-wide singleton every form falls back to) refuses this provider.

Event enquiries — Tripleseat

provider: "tripleseat" sends an enquiry to a venue's Tripleseat booking system. Build the enquiry form normally; their own embed cannot run, because connect-src is 'self'.

PUT /v1/lead-webhooks/events {
  "provider": "tripleseat",
  "apiKey": "…",                   the venue's lead-form public key; required on first set
  "fieldMap": { "how-many": "guest_count" },  optional
  "source": "Weddings enquiry"     optional — prefixed to additional_information
}

There is no url — a call carrying one is refused. Leads go to Tripleseat's own endpoint, and the venue is identified by the public key, which comes from Settings → Lead Forms in their account and decides which lead form the enquiry lands on. Allowlist https://api.tripleseat.com before the first call. Something that sits in FRONT of Tripleseat — a relay, an automation — is provider: "webhook" instead.

Like Mailchimp and Engage it must be named on a form — the site-wide singleton refuses it, because a booking system should receive the event forms and not the contact form:

<mk-component name="form" data-form-id="weddings" data-lead-destination="events">
Tripleseat fieldField names recognised
first_name / last_namefirstName / lastName
email_addressemail
phone_numberphone
company_namecompany, organization
event_nameeventName
event_dateeventDate, date
start_time / end_timestartTime / endTime (use type: "time")
guest_countguestCount, guests, attendees
email_opt_inemailOptIn
gdpr_consent_grantedgdprConsent

Spreadsheets — Google Sheets

provider: "sheet" appends one row per submission to a client's Google Sheet. It is the way out to everything nobody has written a provider for: a sheet is one automation hop from HoneyBook, Zapier, Make or an Apps Script trigger.

PUT /v1/lead-webhooks/enquiries {
  "provider": "sheet",
  "sheetId": "1bzGsnd9…",           or paste the whole spreadsheet URL
  "tabName": "Overview",            the tab's exact visible name
  "columns": {                      optional — field name → column header, in order
    "firstName": "First Name",
    "referral":  "How They Heard About Us"
  },
  "dateFormat": "us",               optional — "iso" (default) or "us" (12-05-2026)
  "timestampColumn": "Timestamp",   optional — stamped in UTC
  "testMode": "skip",               optional — "skip" (default) or "send"
  "label": "Enquiry log"            optional
}

Like Mailchimp, Engage and Tripleseat it must be named on a form:

<mk-component name="form" data-form-id="contact" data-lead-destination="enquiries">

Two things affect whether a submission lands in HubSpot (neither applies to the webhook, which receives the submission as posted):

Ordering is deliberate: the lead is stored before it is forwarded, and the visitor is redirected either way, so an outage at either destination cannot make a client's contact form fail. Each lead carries the outcome — crm_status is synced, failed, or pending, and null when the site forwards nowhere. On a site with more than one destination crm_status is the aggregate (synced only when every destination took the lead), and a deliveries[] array reports each one separately:

{
  "crm": { "status": "failed", "attempts": 1, "error": "webhook: status 500 …" },
  "deliveries": [
    { "provider": "hubspot", "status": "synced", "ref": "701", "attempts": 1 },
    { "provider": "webhook", "status": "failed", "error": "status 500", "attempts": 1 }
  ]
}

POST /v1/forms/:formKey/test reports a crm-* stage, so you can prove delivery end to end instead of assuming it.

What the webhook sends, for a client's developer wiring up the receiving end:

POST <configured url>
Content-Type: application/json
X-Moseik-Timestamp: 1755302400
X-Moseik-Signature: v1=<base64url HMAC-SHA256 of "<timestamp>.<raw body>">
{
  "id": "lead_01K…",
  "site": "example.com",
  "submittedAt": "2026-08-16T18:00:00.000Z",
  "page": "/contact",
  "form": "quote",
  "consent": true,
  "test": false,
  "assignedTo": { "key": "1520210", "email": "jane@brokerage.ca", "reason": "attributed" },
  "attribution": { "utm_source": "google", "utm_medium": "cpc", "landingPath": "/" },
  "data": { "name": "Dana Reid", "email": "dana@example.com", "message": "…", "photos": "roof.jpg" },
  "files": [
    {
      "field": "photos",
      "name": "roof.jpg",
      "url": "https://example.com/__files/…",
      "type": "image/jpeg",
      "size": 812345,
      "keptUntil": "2027-08-16"
    }
  ]
}

attribution is how the visitor arrived, or null (always present); its keys are listed under GET /v1/leads.

files is [] on a form with no file field. Each url downloads the file until keptUntil and returns 410 after, so fetch it if you need it longer. A file field's entry in data holds its filenames.

assignedTo is who leadRouting (see Authoring) gave this lead to, and is null on a site that does not run routing — always present, like test. key is your own recipient id (an agent's CREA id, a branch code), email the address the notification went to, and reason is "attributed" where the visitor's session named that person or "rotation" where the round-robin did. Read it rather than the hidden field your form carries: the two differ whenever the rotation chose, an override changed the address, or the named recipient is no longer active. On a redelivery email can be null, where that person has since left the roster; key still identifies them.

Changes the site owner confirms — GET /v1/pending-changes

On a site whose own domain is live, these writes do not apply. They answer 202 with status: "pending-owner-confirmation" and a pendingChange, and the site owner is emailed a link to confirm or decline:

Until the owner clicks, leads keep going where they went before. A 202 is not a failure: do not retry it. Tell the person you are working for to look for the email.

GET /v1/pending-changes lists recent requests; GET /v1/pending-changes/<id> reports one as pending, applied, declined, expired, superseded or failed. A held page version that the owner confirms is published only if no later edit has landed on that page; otherwise it reports failed, and the change must be requested again.

The brokerage's agent roster — GET /v1/roster

On a brokerage site, the people leads are routed _to_. This is what tells you which routing key belongs to which person, which is the one thing you cannot get anywhere else: GET /v1/site reports recipient counts, and a lead's assignee only names somebody who has already received one.

GET /v1/roster
{
  "configured": true,
  "routingKeyField": "creaId",     // the field whose value leadRouting resolves on
  "counts": { "total": 84, "routable": 43, "withBio": 9, "withHeadshot": 84 },
  "partial": false,                // true → the list is NOT the whole brokerage
  "duplicateCreaIds": [],          // keys two records share; fix them in Showroom
  "skipped": 0,                    // records that did not parse; non-zero is worth reporting
  "withoutCreaId": 2,              // agents with no CREA id: no page, no leads; set one in Showroom
  "agents": [
    {
      "creaId": "1520210",         // the key leadRouting resolves on
      "displayName": "Jane Doe",
      "title": "Associate Broker",
      "email": "jane@brokerage.ca",
      "phone": "+13065550100",
      "headshotUrl": "https://…",
      "profileSlug": "jane-doe",   // already page-ready
      "bio": "…",                  // absent for most agents today
      "communitiesServed": ["Nutana"],
      "socials": { "website": "https://…", "evrylistReferralUrl": "https://…" },
      "routable": true             // false → can have a page, cannot receive a lead
    }
  ]
}

This is not <mk-component name="agent-roster">. That component renders a team grid from the DDF Member feed, which carries no email address for anybody, ever, and no bio. This route reads the brokerage's own roster system, which holds the email, the bio, the headshot and the routing key. They are layers, not alternatives; data-source="roster" points the component at the collection this route fills.

Build agent pages from the roster collection, not from this. The same records are synced into a platform-managed collection every 15 minutes, so one /agents/:slug template serves every agent and stays current. Its rows and schema refuse writes (409 collection.managed) and GET /v1/collections reports it with managedBy: "roster"; set only its name and detailBasePath. The key roster is reserved on every site.

routable is not the same as publishable. An agent with no email address still gets a page, and is skipped by the reconcile, so their leads fall back to the inbox. Check it before promising a client that attribution works for their whole team — on a real roster it is often half of them.

configured: false means this site has no roster connected, which is most sites and is a state rather than a failure. On a Showroom brokerage, connect it yourself (below). An empty agents array with configured: true means the opposite thing — a roster that really is empty.

Setting a brokerage up start to finish — the order matters — is one procedure in Authoring.

This is a live read of the upstream, so an outage is a 502 here rather than an empty list. That is deliberate: authoring against a silently empty roster is how 84 agent pages become none. A 409 roster_connector.needs_reconnect is different: Showroom refused the stored secret, almost always because the brokerage made a new one. Agent pages keep their last known details until you reconnect.

Connecting the roster — PUT /v1/roster-connector

The client copies two values from Showroom → Admin → Settings → Developer → Your website: the Account ID (acct_…) and the Secret (64 characters, hidden until they press Show).

PUT /v1/roster-connector { "brokerageAccountId": "acct_…", "secret": "…" }
→ 428 { "code": "roster_connector.confirm_brokerage",
        "brokerageName": "Century 21 Fusion", "agents": 84, "routable": 43 }

PUT /v1/roster-connector { "brokerageAccountId": "acct_…", "secret": "…",
                           "confirmBrokerage": "Century 21 Fusion" }
→ 200 { "configured": true, "brokerageName": "Century 21 Fusion",
        "counts": { "total": 84, "routable": 43 }, "secretHint": "…3f9a" }

Nothing is stored until the second call. Show the client the name from the 428 and ask them to confirm it is their brokerage; don't just echo it back yourself. The secret is write-only. On a live site the second call answers 202 and waits for the owner's emailed confirmation.

422 roster_connector.rejected_by_showroom means the two values don't belong together: a typo, two brokerages, or a secret that has since been replaced. A 503 means Showroom could not be reached, and resending is safe. To rotate, send the same call with the new secret. DELETE /v1/roster-connector disconnects.

test is true when the submission came from POST /v1/forms/:formKey/test?notify=1 rather than from a visitor, and it is always present — so branch on it directly rather than reading absence as false. If your endpoint writes into a live booking or CRM system, this is the field that lets you prove the wiring without creating a record your staff will mistake for a real enquiry.

Verify by recomputing the HMAC over "<timestamp>.<body>" with the shared secret and rejecting a timestamp that is not recent — that is what makes a captured request non-replayable. id is stable per lead, so it doubles as an idempotency key. A 2xx is success; a 4xx is recorded as a permanent failure and not retried; a 429 or 5xx is retried up to three times with backoff.

notifyCc and notifyBcc take the same shapes and add a visible or a silent copy of every lead notification. A cc appears in the headers and every recipient can see it; a bcc does not, which is what an archive mailbox wants. A form may override them with data-notify-cc / data-notify-bcc.

The recipient block overrides as a unit. A form declaring ANY of the three attributes supplies all three and inherits none of the site-wide ones — otherwise adding data-notify-email to one form would keep sending its leads to a site-wide bcc the form's author never named.

Before launch, none of it reaches the client. A site with no live custom domain has every lead email notification sent to a build inbox instead, so our own test submissions do not land on them. On a site its owner started themselves, the build inbox is that site's dashboard admins. It ends by itself when the domain starts serving — configure the real address from the start and change nothing on launch day. GET /v1/site and GET /v1/forms report it at buildRouting ({ active, inbox?, detail }). A domain is serving once its certificate has issued; a root verified by TXT while DNS still points at the old host does not count. POST /v1/forms/<key>/test?notify=1 names where the email went: notify.sentTo, notify.buildRouted, and notify.intended when it was redirected. Leads are stored either way. A cc or bcc is dropped rather than redirected, and a routed lead is redirected too.

Setting either copy changes the send shape. With no cc and no bcc, each address on notifyEmail gets its own message, so a wrong address costs only that person their lead. With either set, everyone is emailed on ONE message — which is what makes a cc visible and stops a bcc receiving one copy per recipient — and a single bad address on it can bounce the enquiry for all of them. Say so when you configure one.

Sending more than one field. The recipient fields may travel with contact or with analytics in one call, and both are applied. contact and analytics together are refused with 422 site.one_at_a_time — each re-renders every page and emails the owner. A key this endpoint does not write is refused too (422 site.unknown_field, or 422 site.field_not_writable for a read-only one like features) rather than ignored. Nothing is written on any of those refusals; when you sent more than one field, the problem body lists the others under notApplied.

Until 2026-09-02 a body naming two fields applied the first one the endpoint recognised and silently dropped the rest under a 200 — most damagingly { notifyEmail, contact }, the natural onboarding write, which stored the contact record and left lead notification unset. If you split that into two calls to work around it, you no longer need to.

contact — the business details search engines read. The same endpoint takes the site's name, phone, email, address and opening hours:

PUT /v1/site { "contact": { "phone": "+1 306 555 0100",
                            "email": "hello@example.ca",
                            "hours": "Mon–Fri 9–5, Sat 10–2",
                            "name":  "Jordan Ellis Realty",
                            "address": { "street": "12 Main St", "city": "Regina",
                                         "region": "SK", "postalCode": "S4P 1A1",
                                         "country"?: "CA" } } }
PUT /v1/site { "contact": { "hours": "Mon–Fri 8–6" } }   → changes hours, nothing else
PUT /v1/site { "contact": { "hours": null } }             → clears one field
PUT /v1/site { "contact": null }                          → clears the whole record

This record is the only source for the Organization/LocalBusiness structured data in the page shell. It does not read your page copy, so contact details that exist only as words on a page are invisible to search engines. A site with neither a phone nor an address emits plain Organization; one with a phone and no address emits a LocalBusiness with no location, so fill in the address and hours together with the phone.

GET /v1/site reports the stored record under contact, together with exactly what the shell will emit and what is missing. Older sites often carry a phone and no address until someone writes one. The owner can also change it themselves under Site details.

A lead from a form with a declared schema also carries fieldLabels — { <field name>: <the question that field asked> }, captured at submit. Use it for any heading a person reads, falling back to the field name: a contact-form declares no schema and its names (name, email, message) already read as the question, while a form generated from another product's template has field names that are that product's question ids, and a report headed 6f71d3f7-fba3-494f-9aaa-dba492b91fff is unreadable. It is a snapshot, not a lookup — rewording a question does not relabel the leads already collected under the old wording, so two leads on one field may legitimately disagree. Absent, never {}, when the form declared no schema.

A lead also carries attribution when the visitor's session recorded how they arrived: any of utm_source, utm_medium, utm_campaign, utm_content, utm_term, gclid, fbclid, msclkid, referrer and landingPath, each at most 512 characters. It comes from the session's first page view with a UTM parameter, a click id or an external referrer, not from the form page, and is captured automatically, so don't copy query parameters into hidden fields. Click ids are kept only once the visitor has accepted the consent banner, so a site without one never records them. Absent when nothing was captured, including on every lead before 2026-09-24.

A routed lead carries assignee: { key, name, email, at, reason }: who leadRouting gave it to, with reason "attributed" (the visit named them) or "rotation".

A site can add its own parameters with PUT /v1/site { "landingParams": ["agent"] } (up to 4 names; null clears). ?agent=123 on any page is then kept for the session as attribution.params.agent, and the lead's data gains agent: "123" unless the form sent that field itself, so leadRouting and every destination can use it. The first value in a session wins. GET /v1/site reports them as landingParams.

A lead with uploaded files carries files: [{ id, field, name, type, size, keptUntil }]. There is no link here; the email, the webhook and the dashboard carry it.

Delivery is retried three times inside the submitting request and then stops forever, so a destination down for minutes (a deploy, an expired token, a rate-limit window) lost every lead that arrived meanwhile. This is how you get one back; you cannot rebuild the payload yourself, because the signing secret and the API key are write-only.

``json { "leadId": "ld_…", "attempted": [{ "destination": "quotes", "provider": "webhook:quotes", "status": "synced", "ref": "…" }], "skipped": [{ "destination": "hubspot", "provider": "hubspot", "reason": "already delivered…" }], "crmStatus": "failed" } ``

Safe to call twice. lead_deliveries is unique per (lead, destination), so the row is updated rather than duplicated, and HubSpot, Engage and Mailchimp deduplicate on their own side — a redelivery of something that had actually succeeded updates a contact instead of creating a second one.

Three things are skipped rather than attempted, each reported in skipped with its reason, so a call that does nothing tells you why: a destination already delivered, one whose failure was recorded as terminal (a wrong agentId fails identically forever), and one with no delivery on record for that lead. force attempts all three — use it after you have actually fixed the destination, not to get past the message.

crmStatus is recomputed across every delivery row for the lead, not just the ones this call attempted, so recovering one destination does not mark a lead delivered while another is still failed.

You usually will not have to call it. A failed delivery recorded as retryable is re-attempted automatically on a widening backoff — 30 minutes, then 1, 2, 4 and 8 hours — and stops 24 hours after the first failure. A lead that reaches the end is left failed, is never retried again, and raises a lead-delivery-abandoned alert naming the lead id. That alert is the one to act on: fix the destination, then call this with {"force":true}.

The sweep only ever touches deliveries recorded as retryable. A terminal failure, and any delivery from before this shipped (whose verdict was never recorded), are left for a human — this endpoint attempts those, an unattended job does not.

Uploads need no lease — an upload mints a new immutable asset and cannot collide with another writer — but a frozen site refuses them with 423, and every upload is audited (since 2026-08-16).

What you can upload:

ClassAcceptedSize limit
imagePNG, JPEG, WebP, AVIF, GIF, SVG25 MB
documentPDF, DOCX25 MB
videoMP4, WebM50 MB

Enforced identically on upload and import. Three things about how it is enforced:

A rejected upload is never a reason to send the client anywhere. There is no client dashboard and no client-facing media library — you upload on the client's behalf through this API. If you cannot make a file work, ask whoever briefed you for a different file; do not tell the client to upload it themselves, because there is nowhere for them to do it.

Images are optimized on serve, by default, on every request — resized to a 1920px cap and re-encoded to AVIF or WebP when the browser advertises one, then cached. This applies to a CSS background-image: url(/media/<id>) exactly as it does to an <img>, so upload the largest version you have and never make a small copy by hand. ?w=<px> overrides the width (snapping to 320/640/960/1280/1920/2560) and is worth setting for anything displayed much smaller than full-bleed, since the default cannot see your CSS box. Nothing is ever upscaled. <img> also gets a per-viewport srcset written for it at render time, which beats the serve-time default.

Video is served from /media/<id> with HTTP range support, so <video> seeking/streaming works; there is no custom player, the browser's native <video> element is used. See the authoring guide for the hero background-loop and controllable-player patterns.

Integration requests — work another Proof product asked for

Another Proof product (Hivoma, and Wendell next) can queue a request against this site once the client has connected it from their dashboard. The queue is the only place that request exists — nobody emails you about it, so poll it.

GET  /v1/integration-requests?status=pending
GET  /v1/integration-requests/<id>/fields
POST /v1/integration-requests/<id>/status

Each item's payload carries intakeTemplateId, destinationName for the form's data-lead-destination, and optionally suggestedRoute, placement and label. The template id is an identifier, not a field list — ask for the fields:

GET /v1/integration-requests/<id>/fields
→ { fields: [...], dataFields: "[…]", warnings: [...] }

dataFields is ready to paste onto <mk-component name="form" data-fields="…">. The platform fetches the product's published field list and maps it to Moseik field types for you, so you need no credential for the product and no copy of the mapping.

Two things it does that reading the product's own API would not. The identity fields (__email__ and friends) are published separately from the customer's chosen questions, and are folded in for you — a form built without __email__ passes the gate, publishes, renders, and then fails on _every_ delivery, permanently, with nothing reporting it. And a question this platform has no control for — a time range or a date range, each being one label that answers with two values — refuses the whole template naming the question (integration.template_unbuildable), rather than quietly shipping a shorter form than the customer designed. Report that reason as your failed detail; the customer fixes it on the product's side by asking two questions.

The product can read which question types build before it asks, so a refusal here is rare.

warnings names anything that arrived richer than it renders, such as help text under a question. It is not a failure, and it is worth repeating back. The route is a hint: choosing the page and writing the copy is the judgement being asked for, which is why the product cannot write the page itself.

Claim before you start. {"status":"claimed"} is what makes the queue safe to poll — without it a second agent picks up the same item and the client gets two copies of the form. A claim on an already-claimed item is refused with integration.status_conflict.

Then report the outcome:

BodyMeaning
{"status":"done","route":"/auto-insurance","formKey":"auto-quote"}Landed. route is required.
{"status":"failed","detail":"no page to put it on"}Gave up. detail is required.

The customer clicked a button in another product and is shown whatever you send back, so a failure with no detail is refused rather than turned into a support ticket. Reporting needs no lease, so releasing yours first does not strand it.

Two things to expect while doing the work: POST /v1/pages is capped at 5/hour, so a request implying several new pages has to degrade rather than fail partway; and a page a human authored refuses a write that removes their lines (write.cross_writer_overwrite) — re-read and merge, never force. Rehearse with ?dryRun=1 first, and prove the form registered with GET /v1/forms before reporting done.

Local area data

Reference data for a neighborhood or community page, and for deciding which areas an agent should focus on. All GET, all content:read, none of it touches the site — so none of it is affected by a write freeze or the lease.

RequestReturns
GET /v1/areas?name=Cathedral&lat=50.4452&lng=-104.6189{ areas[], total, notes[] } — boundary candidates
GET /v1/areas/:boundaryId{ area, partOf[], demographics, lifestyles[], market, sold, coverage, … }
GET /v1/areas/rank?lat=&lng=&layer=neighborhood{ ranked[], skipped[], method, notes[] }

Optional: layer (neighborhood, micro-neighborhood, city, postal-code, area-level-1, area-level-2, school-attendance-area), limit, and on a profile lifestyleLimit and soldInterval (ISO 8601, default the trailing twelve months).

Resolve a place with /v1/areas first — boundary ids are opaque, and results come back alphabetically rather than by relevance, so the first row is not the best match. Pass lat/lng to disambiguate.

coverage is the field to read first. The vendor answers 200 with zeros for data it does not hold, so an absence and a finding look identical without it. Canadian sold data is board by board — Calgary and Victoria yes, Toronto, Vancouver, Regina and Saskatoon no — and schools are absent; a sold count of 0 is a coverage gap, and notes[] says so. The semantics, the display obligations that ride on attributions, and what the ranking does and does not mean are in authoring.

503 service_unavailable naming LIVEBY_API_KEY means the deployment is not configured for this at all — a platform secret, not something a site can turn on.

These endpoints are for deciding and for prose. To put the figures ON a page, use the components — neighborhood-stats, community-map and market-trends, which fetch and render server-side so the numbers are in the HTML a crawler sees. Read them in authoring. Use /v1/areas to resolve the boundary id they take, and /v1/areas/rank to decide which neighbourhoods deserve a page at all.

There is no trend endpoint here: month-by-month sold figures are available only through the market-trends component, which also applies the coverage test that decides whether a trend is worth drawing at all. /__data/areas/<id>/map.png is the same-origin map image the community-map component emits — it is not an endpoint to call directly.

Domain readiness — is the site actually reachable?

A finished site is not a live site. A client's own domain only serves once the platform has provisioned a Cloudflare custom hostname for it; without that the domain returns 522 no matter how correct the client's DNS is, and nothing about the page content reveals it. GET /v1/site is how you check:

{
  "domainsReady": false,
  "hostnames": [
    {
      "hostname": "acme.moseik.app",
      "isPrimary": false,
      "status": "active",
      "ready": true,
      "blocking": [],
      "detail": "Served by the platform zone route — no custom hostname needed."
    },
    {
      "hostname": "acme.ca",
      "isPrimary": true,
      "status": "failed",
      "ready": false,
      "blocking": ["custom_hostname_missing"],
      "detail": "No Cloudflare custom hostname exists for this domain, so the edge cannot route it — requests WILL fail with 522 even when DNS is correct."
    }
  ]
}

Never tell a client their site is live while domainsReady is false. Report the offending detail and escalate to Moseik — a runtime agent can see the blocker but cannot clear it. Don't retry, and don't advise the client to change their DNS: their DNS is usually already right.

Why the site you're building might be noindex

Two cases, neither of them a page-level setting and neither of them yours to fix:

  1. **The site has a custom domain and you're looking at the *.moseik.app preview host.** The canonical points at the real domain, so indexing the preview would be duplicate content.
  2. The site was [created as a copy](#sites-created-as-a-copy) of another one and doesn't have a domain yet. A copy search engines can reach competes with the site it was copied from. This clears itself the moment the copy gets its own domain.

In both cases it's a response header and a robots.txt, not page meta, so editing meta.noindex will not change it and will just spend a write. If a site needs to be indexable, the answer is a domain, not markup.

status — the reconciled lifecycle

status is authoritative. It is reconciled against Cloudflare every 15 minutes by the platform's health sweep, and on every admin domain call, so you can trust it without a live check:

statusmeaning
activeServing.
pendingProvisioned; Cloudflare is still validating. Self-resolves — no action needed.
failedWill not serve and will not self-resolve. Needs the platform team.

The practical difference: pending means wait, failed means escalate.

blocking — why a hostname is not ready

valuemeaning
custom_hostname_missingNo Cloudflare-for-SaaS custom hostname. Hard blocker — will not self-resolve; Moseik has to fix it.
custom_hostname_pendingProvisioned; Cloudflare is still validating. Resolves itself once the client's CNAME resolves.
custom_hostname_failedCloudflare reported the hostname as failed. Moseik has to recreate it.
provisioning_unconfiguredThis environment cannot set up domains at all. Platform-team fix.

A *.moseik.app preview host is always ready — it is served by the platform's own zone route and needs no certificate of its own. So domainsReady can be false while the preview URL works perfectly; that is the normal state of a site whose custom domain hasn't been connected yet, and the preview URL is what you should share in the meantime.

A new site's preview address is random words (eager-beaver.moseik.app). The owner can change it from their dashboard, and you can with PUT /v1/site/address { "label": "harbour-bakery" } (content:publish, inside your lease). It returns { from, to, changed, url } and re-renders the site. Read the address from GET /v1/site each time rather than keeping a copy; an old address redirects to the current one for 30 days, and no other site can take it until then.

Favicon

The image served at /favicon.ico on the site's own hostname. It points at a media asset you've already uploaded, so there's nothing new to store — just pick one.

"/favicon.ico", "mime": "…" }, or { "assetId": null }` when none is set.

Every rendered page already carries a stable <link rel="icon" href="/favicon.ico">, so changing the favicon re-renders nothing — only the bytes behind that URL change. Upload the icon via POST /v1/media first (a square PNG or SVG works well), then point the favicon at the returned asset id.

Edit classes: publish vs. held

When you write, the platform classifies the change and decides whether it auto-publishes or is held for a human to review.

Most writes publish immediately. A hold exists only where a human is genuinely the right judge of the thing — see the rule below.

classwhat it isoutcome
copyvisible text changed, structure didn'tpublishes
structurethe tag/attribute skeleton or the CSS changedpublishes
page-createa new pagepublishes — unless the programmatic-SEO gate flags it (see below)
themedesign tokenspublishes
sensitive-fieldsa protected detail changed — NAP/contact (phone, email, address)publishes, and emails the site owner a one-click undo
regulated-copycopy that tripped a compliance rule (e.g. fair-housing language)held
external-origina request to allowlist a third-party originheld — you promote it yourself, [with a person's yes](#external-origin-allowlist)

The rule these follow

A hold is only worth having if the person it routes to can actually adjudicate it. So:

Two consequences worth knowing as a caller:

A held version waits in the staff review queue; approving publishes it, rejecting returns a note you can read back from the version's validation report.

_Retuned 2026-08-05 — page-create and theme used to be held, and page-create was 100% of the review queue at a 0.8% rejection rate._

A minimal edit loop

POST /v1/auth/token           → token
POST /v1/lease                → hold the writer lease
GET  /v1/pages                → find the page id
GET  /v1/pages/:id            → current payload + latestVersionId
PUT  /v1/pages/:id/payload   (If-Match: latestVersionId)   → published or held
GET  /v1/pages/:id/screenshot → eyeball the result

Work in small, validated steps. If a write is rejected, the code (and the version's validation report) names exactly what to fix.