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
POST /v1/auth/tokenwith{ "clientId": "...", "clientSecret": "..." }→{ "token": "..." }. Short-lived, scoped to one tenant.- 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.
- Limits: 3 drafts per address per day (
429 draft.ip_limit), up to 10 pages per draft, and the usual write and publish ceilings. - Private: the preview host is unguessable and noindex. Forms refuse submissions until the site is claimed.
- Claim: the human opens
claimUrl, signs up or signs in, and confirms. The site moves into their account, the 180-day trial starts, and your credential keeps working for as long as you hold it. It shows under AI assistants, where they can disconnect it. Tell your human that to keep editing in any later conversation they connect you over MCP from AI assistants → Connect. - Expiry: unclaimed after 72 hours, the draft is deleted.
GET /v1/site→draftsays whether it is claimed and when it expires.
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.
- Once per asset. After your own first version lands, the ordinary rules apply.
duplicateonly. A revert or another credential's work is never exempt.- It ends when the domain goes live. From then on a blind replacement is refused with
422 write.cross_writer_overwritenamingduplicate. Merge onto a fresh read, or resend withX-Mk-Overwriting-Writer: duplicate. The duplication baseline is a version, soPOST /v1/pages/:id/revertputs it back.
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:
POST /v1/leasebriefs you on acquisition. Its response carriessinceYourLastWrite—nullif this credential has never written here, otherwise `{ yourLastActivityAt, otherWrites, otherWriters, latest: { by, at, action } | null,
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.
- Every line-shaped write response echoes
replaced— the{ versionId, createdBy, createdAt }of the version it superseded (nullon a first write), on the success body and on theversion.conflict/write.destructive_overwrite/write.unguarded_truncationrefusals.
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:
- Editing their line is not removing it. A line you wrapped, re-indented, split in two, gave an attribute to, or otherwise rewrote still counts as present — the platform looks for it among the lines your write ADDS. A write whose every drop is an edit passes free with no header, and
editedForeignLinesreports how many the rule looked at and did not count against you. - A merged copy passes free. If your submission contains the other writer's lines, nothing is refused — being second is not the offence, destroying unseen work is.
- There is no threshold. Removing 2 of someone else's lines is refused exactly like removing 200. (The 20-line tolerance below applies only to your OWN lines.)
If-Matchdoes not waive it — a fresh, correctIf-Matcharound stale content is precisely the incident this exists for.
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:
409— concurrency. Somebody else moved. You do something (re-read the asset, wait for a lease) and the same write then succeeds.422— content. The bytes you sent are the problem: the write would destroy live lines that appear nowhere in it. No retry, no fresh version id and no amount of waiting clears it. Merge the content, or acknowledge the loss by name.
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.
code | Status | What is actually wrong | What to do |
|---|---|---|---|
version.conflict | 409 | your base moved — or your If-Match is missing or * (the detail says which) | re-read the PAYLOAD, apply your change to it, resend |
lease.required | 409 | you hold no live lease; if you took one, it expired | POST /v1/lease again and retry. Your version id was never the issue |
lease.held | 409 | another credential has the pen (heldByLabel) | wait for expiresAt, or say who is editing. Don't spin |
page.archived | 409 | the page is retired behind a redirect or tombstone | create a page at the freed route instead |
write.cross_writer_overwrite | 422 | your payload removes lines another writer added | merge the lines the response names, resend |
write.destructive_overwrite | 422 | your payload drops >20 live lines that appear nowhere in it | re-read, merge, resend |
write.unguarded_truncation | 422 | no If-Match, and the write deletes ≥ 40% of a shared asset | re-read, confirm you meant it, resend with If-Match |
write.cross_writer_fields | 422 | your PATCH changes title/meta fields another writer set | leave 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
GET /v1/manifest— the capability surface: the page-payload shape + placeholder syntax, the headless components (+ theirdata-*), interactive hook classes, reuse mechanisms (stylesheet/partials/chrome), fonts, embeds, budgets, and the theme font/scale ranges. Read it to learn what's available; read this doc for how to use it.
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:
- While no custom domain is serving, the site counts as still being built and the rate limits are suspended entirely —
usedstops advancing. - They come back the moment a domain starts serving, automatically. No call ends build mode, and the trigger is usually a DNS cut-over someone else performed — so a local write tally is wrong in both directions: too cautious during the build, then over the limit on go-live day.
- A write freeze is honored in build mode regardless. Build mode never unfreezes a site.
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 response | Meaning |
|---|---|
X-Mk-Build-Mode: active / ended | the posture right now, on every write — including a rejected one |
warnings[].code = limits.build_mode_ended | build mode ended since your previous write. Fires once per credential, and names the new caps |
warnings[].code = limits.freeze_approaching | you are past ~75% of the freeze ceiling; the detail carries how many writes are left |
warnings[].code = trial.expired | the write is saved, but the site's free trial has ended and nobody can see it until a plan is chosen |
X-Mk-Limits-Warning | the 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.
GET /v1/forms— which forms the handler will actually accept. Each entry gives theformKey, the component (block), whether it is page-scoped or site-wide, anddeclaredIn(a route,chrome:footer, orpartial:<ref>). Each also carriesnotify— the EFFECTIVEto/cc/bccfor that form, withfromsaying whether they came from the form's own attributes or the site-wide block — plus anotifyWarningwhentois empty. "Stored but emails nobody" is a separate failure from "never registered", and the page shows neither. Readnotifyrather than combining the form and site values yourself: the block overrides as a unit, so a form naming its own recipient inherits neither site-wide copy.
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/:formKey/test— a synthetic submission through the real submit handler. No browser, no manual step. Needs the write lease and is charged as a write, because it stores a row.
`` 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):
reached | What it means |
|---|---|
not-registered | the handler cannot find the form in any published payload — publish what declares it |
registered | a spam check or field validation refused it; error is what a visitor would see |
spam-checks | it passed the spam checks but was not stored |
stored | delivery works |
notified | the 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.forwarded | crm.note says |
|---|---|
true | the lead reached the configured CRM |
false | not 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:
- It is the real handler in a test mode, not a parallel implementation — same registration lookup, honeypot, signed token, nonce, Turnstile, field validation and storage.
- The hidden fields come from the page visitors are served, read out of the cached object (
fidelity.hiddenFieldsFrom). This is what makes a pass meaningful: the anti-bot token is an HMAC with no clock, so any fresh render produces a valid one, while the real failure is pages cached from _before_ the token existed. With no cached object you getfresh-renderand awarningsaying the test could not rule that out. - The honeypot is sent present-and-empty, as a browser sends it. A filled honeypot returns the identical success a real submission does, by design.
mk_fnis minted server-side, because a browser mints it per load and there is nothing on the page to copy. Stated infidelity.nonce; it is the one field that is not what a visitor sends.
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.
GET /v1/leads— the captured submissions.?page=<pageId>,?limit=,?offset=, and?includeTest=1as above.
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.
GET /v1/leads— submissions captured for this site, newest first. An empty array on a site whose form has been live for a while is a bug, not a quiet week.
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.
| Response | Meaning |
|---|---|
303 → ?submitted=1 | Accepted and stored. This is success. (200 {"ok":true} under Accept: application/json.) |
400 | Your submitted fields don't match the form's declared schema — check data-fields, including required. |
404 | No form with that key was found for this site. The key comes from data-form-id (defaulting to the component name). |
403 | Anti-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. |
429 | Rate limited: more than 5 submissions per minute from one IP. |
Two behaviours worth knowing, because they are deliberately asymmetric:
- The honeypot returns _success_. A submission with the hidden
websitefield filled gets303 ?submitted=1and stores nothing — bots must not learn they were caught. So a?submitted=1you produced by filling every field, including hidden ones, proves nothing; fill only the real fields. The JSON shape is identical for the same reason: stored and discarded both return exactly{"ok":true}. - A form declared in chrome or in a partial works. Its action carries the id of whichever page it rendered on, and the platform searches the page, then chrome, then partials.
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 sends | Host 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 URL carries
?mk=<token>, an HS256 JWT signed with the provisioning secret and minted for each load:site,sub(Moseik user id),email,name,pm(holdspages.manage),cus(Stripe customer, when the site has one),iat,exp(five minutes). wendell:contextalso carrieshost: "moseik-dashboard",site(the site's address), andpageId,versionandrouteof the page on Pages (null elsewhere).urlis that page, or the home page.wendell:reloadrefreshes the preview rather than the dashboard, andwendell:navigatepoints the preview at the path.{ type: "wendell:point" }lets the person click the part of the page they mean. The next click comes back aswendell:pointed{ pageId, version, route, target, kind, text, src }:targetis the element'sdata-mk-editnumber in that version, or null withkind: "other"for a part that is not text or an image.{ error: "unavailable" }means pointing is not possible on that page;wendell:point-cancelstops waiting.
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:
PUT /v1/stylesheet— publishing the shared stylesheet [re-renders every page](#site-stylesheet). Any real CSS change auto-publishes under the default policy (a stylesheet edit classifies asstructure), so this is the simplest lever. SendIf-Matchso you can't revert live CSS while doing it.POST /v1/theme/publish— [re-renders all pages and purges their caches](#theme). Needs a pending theme draft, andthemeholds for human approval by default, so this route can land you in the review queue.
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:
GET /v1/pages— cheap, no payloads. Every page carrieslatestVersionId(the CAS base for its next write) andpublishedVersionId(what is serving).- Compare each
latestVersionIdagainst the id you recorded when you last took that local copy. - 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.
POST /v1/lease— acquire the lease. Do this before editing. The response carriesexpiresAtplussinceYourLastWrite(what other credentials changed while you were away, and which assets they touched).GET /v1/lease— who holds it:{ held, youHoldIt, holder, holderLabel, expiresAt, expiresInSeconds, ttlSeconds }. Ask this before a long build instead of finding out from a refused write.POST /v1/lease/renew— keep it alive during a long session.DELETE /v1/lease— release it when you finish. Until it expires, the next writer is refused.
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.
GET /v1/pages— list pages: id, kind, route, title, status,latestVersionId,publishedVersionId,frozen,template,updatedAt. Add?include=payloadfor every page's live payload in one call (plus thepayloadVersionIdit came from), and?ids=a,b,cto narrow. See [If you keep local copies](#if-you-keep-local-copies-read-this-first).
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.
GET /v1/pages/:id— a page plus its currentpayload({ html, css? }) andlatestVersionId.POST /v1/pages— create a page:{ "route": "/about", "title": "About", "payload": { "html": "…", "css"?: "…" }, "meta"?: {…} }. New routes only (409on a taken route). The first version is classifiedpage-create, which publishes immediately on the default policy. A site whose staff have setpage-createto hold still holds —GET /v1/site→publishPolicy.holdsis the trustworthy answer for the site you are on. Send"hold": trueto hold a single create deliberately, which works on any policy.
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).
POST /v1/pages/batch— create up to 20 pages in one call. Body is{ "pages": [ …the same object you would POST to /v1/pages… ] }.
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.
PATCH /v1/pages/:id— fix the title or meta of an existing page. Body is a partial:{ "title"?: "…", "meta"?: { "description"?: "…", "ogImage"?: {…}, "sitemap"?: false, "schema"?: {…}, "publishedAt"?: "2026-08-16" } }. An absent key is left alone andmetamerges over the stored value, so you can correct a title without restating meta. Applies immediately (no review hold) and re-renders the live page; the response'srerenderedsays whether the live object was refreshed (falseon a draft, which picks the change up when it publishes).
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.
PUT /v1/pages/:id/payload— replace the page payload. The body is the payload object itself —{ "html": "…", "css"?: "…" }. SendIf-Match: <latestVersionId>; a stale match is409 version.conflict— and see [what a refusal's status means](#a-refusals-status-tells-you-whether-retrying-can-ever-work), because five other codes share that status. The response says whether the change published (200,{ "status": "published", "versionId": … }) or was held (202,{ "status": "held", "versionId": …, "report": {…} }).PUT /v1/pages/:id/payload?dryRun=1— rehearse a write without making one. Runs the identical path — sanitize, placeholder resolution, budgets, gate, render, SEO lint — and returns the identicalreport, then stops at the mutation boundary. Response is200with{ "dryRun": true, "status": "would-publish" | "would-hold", "editClass", "report", "warnings"? }; a payload that would be rejected returns the same422and the samereportas the real call.
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:
| Warning | What 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_map | a 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_panel | a data-mk-tab="x" with no data-mk-panel="x" — the tab renders, the click does nothing, nothing reports an error |
render.panel_without_tab | the 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:
| Warning | What it means |
|---|---|
audit.bound_sample | names 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_failed | the 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.
?dryRun=1also works on every site-wide write —PUT /v1/stylesheet,PUT /v1/chrome/:section,PUT /v1/scriptandPUT /v1/theme. These each re-render every page on the site, so rehearsal matters _more_ here than on a single page, not less. Same contract throughout:200with `{ "dryRun": true, "status": "would-publish"
| "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:
| Write | The question it answers |
|---|---|
/v1/stylesheet | does this CSS pass the gate, and does it delete live rules I never read? |
/v1/chrome/:part | does this header/footer pass, given it is stitched into every page? |
/v1/script | is the script within budget? |
/v1/theme | do 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.
- All six collection writes rehearse too —
POST /v1/collections,PATCH /v1/collections/:key,DELETE /v1/collections/:key, and the three row verbs. Worth the habit on the two destructive ones: aDELETErehearsal runs the same in-use checks the real call does (they are all reads), so it answers "would this be refused, and what am I giving up" without giving anything up.
- Everywhere else,
?dryRun=1is refused — it is never ignored. Rehearsal is implemented per route, and the routes that have it are exactly the ones listed above: the page verbs (POST /v1/pages,POST /v1/pages/batch,PATCH /v1/pages/:id,PUT /v1/pages/:id/payload), the site-wide singletons (PUT /v1/stylesheet,PUT /v1/chrome/:part,PUT /v1/script,PUT /v1/theme,PUT /v1/notfound), and the six collection writes.
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.
PUT /v1/pages/:id/payload?dryRun=1&render=classes— and see what it emitted. Addrender=to a dry run to get the rendered output back:
render= | adds to the response |
|---|---|
classes | rendered.classes — sorted, unique class names. A few hundred bytes |
1 / true / html | rendered.html — the whole rendered document |
| omitted | nothing; 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._
POST /v1/pages/:id/publish— publish/approve a version; body{ "versionId"?: "ver_…" }(defaults to the latest held/draft). The response names whose version went live (createdBy/createdAt), and when you omittedversionIdand the latest draft turns out to be another credential's, it carries acautionsaying so.POST /v1/pages/:id/revert— revert to a prior version; body{ "toVersionId": "ver_…" }. Appends a fresh version copying that payload and publishes it (linear history — nothing is lost).POST /v1/pages/:id/archive— request that a page be taken permanently out of service. This call only files the request (202,{ "status": "archive_pending" }) — the page keeps serving until confirmed. State an explicit disposition for the old URL, because it must never 404: either{ "disposition": "redirect", "redirectTo": "/replacement" }(a301) or{ "disposition": "gone" }(a410). There is no default. The response echoes anyinboundLinks(published pages linking to this one), warnings, and anapprovalfield ("ai"or"staff") telling you how it gets confirmed for this site.GET /v1/pages/:idshows a pending request asarchivePending.POST /v1/pages/:id/archive/confirm— the explicit second step (default approval mode"ai"). Body must re-state the route:{ "route": "/old-page" }(a mismatch is422). On successstatusbecomesarchived, therouteis freed for reuse, the old route is recorded asarchivedRoute, and the site owner is emailed an FYI with a one-click Restore link (valid 30 days). Version history is retained. Sites configured forapproval: "staff"return403 archive.requires_staffhere.DELETE /v1/pages/:id/archive— cancel a pending archive request.POST /v1/pages/:id/unarchive— request that an archived page return to its old route. Two-stage like archival: this files the request (202,{ "status": "unarchive_pending", "targetRoute": "/old" }) and a human confirms. On confirm the page returns to its route and the version that was live when it was archived is republished (a page that never served comes back as a draft). The route may have been reused — the response flagsrouteConflict: trueand confirmation is blocked (409) while another page occupies it.GET /v1/pages/:idshows a pending request asunarchivePending.DELETE /v1/pages/:id/unarchive— cancel a pending un-archive request.GET /v1/pages/:id/versions— version history.GET /v1/pages/:id/versions/:vid— a version plus its validation report (including a reviewer note if it was rejected).GET /v1/pages/:id/versions/:vid/preview— render a draft version (noindex).GET /v1/pages/:id/screenshot— a PNG of the page via Browser Rendering; query?version=ver_…for a specific draft,?w=/?h=for the viewport,?fullPage=falseto clip,?y=to capture one viewport scrolled to that offset. See cloning.?fullPage=falseis required when judging aposition: fixedoverlay (an open lightbox, the consent banner, a sticky header): a full-page capture re-lays-out fixed elements against the whole document, so an overlay that is really covering the viewport renders misplaced or undimmed.
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:
| header | meaning |
|---|---|
X-Mk-Settled | ok if everything settled, partial if not — read this first |
X-Mk-Images | decoded/total, e.g. 11/12. A shortfall means an image failed, not that the page lacks it |
X-Mk-Fonts | loaded/attempted faces, e.g. 4/4. A shortfall means text is in fallback faces — do not judge type from this capture |
X-Mk-Fonts-Failed | only present on a shortfall: family weight status per errored face, e.g. Heading Sans 700 error |
X-Mk-Fonts-Timeout | only present if the faces hadn't finished loading inside the cap |
X-Mk-Reveals-Forced | how many .mk-reveal sections had to be revealed manually |
X-Mk-Frames | loaded/total iframes — a map region is an iframe, and X-Mk-Images does not cover it. ok requires these too |
X-Mk-Bound | only on a parameterised route: which row this capture is of, or why none could be bound (see below) |
X-Mk-Scroll-Y | only 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:
X-Mk-Imagescounts images that DECODED, not images that loaded. An image that has loaded but not rasterised paints as nothing, so decoding is the only signal that means "this is in the PNG".X-Mk-Fontscounts faces that reachedloaded, out of the faces the page actually asked for. A face that is declared but unused by any text on the page is excluded rather than counted against you, so4/4on a site with six uploaded families is correct and not a shortfall.
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._
GET /v1/pages/:id/verify— the same real browser as the screenshot, returning findings instead of a picture. Not run on publish; call it when you want a read (?version=ver_…audits a held draft before anyone sees it). Costs no write and needs no lease.
Accessibility, at 1280:
| Key | What it reports |
|---|---|
contrast | computed WCAG-AA failures per element, with the measured ratio and what was required |
contrastUnmeasured | elements whose colours could not be read, so their contrast was not checked — check these by eye |
a11y | img_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:
| Key | What it reports |
|---|---|
overflow | horizontal 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 |
images | rendered 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 |
hidden | elements marked [hidden] that still have a box, because a class rule setting display beats the UA stylesheet |
brokenImages | images 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:
| Key | What it reports |
|---|---|
shareImage | present 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:
| Key | What it reports |
|---|---|
connectorPaging | present 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:
to | Notes |
|---|---|
/services | A path on this site, resolved through the router — a collection row or listing URL counts. |
/cabins#2-bedroom | Same, 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.example | Off-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:
| Refusal | Why |
|---|---|
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:
- an exact path,
/old-slug; - a subtree,
/lakelodge/*, covering that path and everything under it, asked only after every live route misses; - a path with a query,
/listing-details?id=AB1&blueprint=BP1, which fires when a request carries all of those params, in any order and alongside others. It may sit on a live page (/?p=12), which still serves without them. When several match, the one with more params wins. Stored with its params sorted, which is the spelling toDELETE.
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).
GET /v1/chrome→{ chrome: { header, footer, announcement } }(each null or{ pageId, status, publishedVersionId, latestVersionId, payload }).PUT /v1/chrome/:kind— upsert (kind=header|footer|announcement); body is a page payload{ html, css? }.POST /v1/chrome/:kind/revert— body{ toVersionId }.
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.
GET /v1/notfound→{ pageId, status, publishedVersionId, latestVersionId, payload }(payloadis null when none has been authored).PUT /v1/notfound— upsert; body is a page payload{ html, css? }. Supports?dryRun=1andIf-Match, like the other singletons.POST /v1/notfound/revert— body{ toVersionId }.
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:
- The response is always HTTP
404. You cannot make a not-found page answer200. A soft 404 looks fine in a browser and quietly wastes the site's crawl budget. noindexis forced. One indexable not-found page collects every dead link on the site into a single search result.- The canonical is
/404. The body is cached once and served for every unmatched URL, so it cannot describe the request that reached it.
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.
GET /v1/stylesheet→{ css, publishedVersionId, latestVersionId }, plusETag: "<latestVersionId>".PUT /v1/stylesheet— body{ "css": "…" }.POST /v1/stylesheet/revert— body{ "toVersionId": "ver_…" }.
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:
| Header | Is |
|---|---|
X-Mk-Stylesheet-Version | the stylesheet version that render used |
X-Mk-Page-Version | the page version that render used |
X-Mk-Rendered-At | when 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 send | result |
|---|---|
If-Match matching latestVersionId (or *) | proceeds |
If-Match that is stale | 409 version.conflict — nothing overwritten |
no If-Match, and the write grows or edits | proceeds, with a write.unguarded advisory in warnings |
no If-Match, and the write deletes ≥ 40% of it | 422 write.unguarded_truncation — refused |
no If-Match, on a site with require_if_match | 428 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 type | Value | Renders as |
|---|---|---|
image | /media/<asset id>, optionally ?w=<px> | <img class="mk-collection__image"> |
link | a 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.
GET /v1/collections—{ collections: [{ key, name, schema, detailBasePath, updatedAt }], limit }. No row count, so askGET /v1/collections/:key/rowsif you need one. Limit 24 per site.POST /v1/collections— body{ key, name, schema, detailBasePath? }. Thekeyis a lowercase slug and cannot be renamed — it is what markup names. A typo is fixed by deleting the collection and creating it again; renaming would silently break every page carrying the olddata-collection.PATCH /v1/collections/:key—{ name?, schema?, detailBasePath? }. Send only what changes. A schema change that would drop a field still in use on rows is refused with the count; confirm withdropFields.GET /v1/collections/:key/rows— every row, including ones outside their visibility window, each carrying a computedvisible. A client has to be able to see an expired event in order to fix its date.POST /v1/collections/:key/rows—{ slug, data, visibleFrom?, visibleUntil?, featured?, sortOrder? }. Limit 2000 rows.PATCH /v1/collections/:key/rows/:id— partial. Changingslugchanges the row's public URL and leaves a301behind.
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:
| Warning | What it means |
|---|---|
collection.date_not_a_date | a 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_time | a 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.
DELETE /v1/collections/:key/rows/:id— the row's URL then answers410 Gone, not404, because it was published and may be linked.
DELETE /v1/collections/:key— remove the collection itself, freeing itskey, its slot and itsdetailBasePath. Refused409collection.in_usewhile anything still depends on it (see below).
?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 there | What to do first |
|---|---|
| any row | DELETE …/rows/:id each |
| a published page rendering it | remove the component, republish |
| a partial or a chrome section | same — and chrome means every page renders it |
the detail template <base>/:slug | archive 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="…">.
GET /v1/partials— list refs + status.PUT /v1/partials/:ref— upsert; body{ "html": "…" }.POST /v1/partials/:ref/revert— body{ "toVersionId": "ver_…" }.DELETE /v1/partials/:ref— unpublish;409partial.in_useif a published page still references it.
Per-site JavaScript
Versioned JS served first-party at /__mk/site.js, under the strict CSP.
GET /v1/script→{ js, pageId, publishedVersionId, latestVersionId }, plusETag: "<latestVersionId>".PUT /v1/script— body{ "js": "…" }(≤ 512 KB).POST /v1/script/revert— body{ "toVersionId": "ver_…" }.
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.
GET /v1/theme— the live tokens plusthemeVersionId, andcreatedBy/createdAt.PUT /v1/theme— the body is the tokens object itself. Validated for schema (e.g.font.scalein1.067–1.5, hex colors, vendored font families) and WCAG contrast on the required text/background pairs; a failure is422(theme.invalid/theme.contrast) and the live theme is untouched.font.monois an optional monospace slot exposed asvar(--font-mono).POST /v1/theme/publish— approve a held theme; body{ "themeVersionId": "thv_…" }. The response names whose tokens went live (createdBy/createdAt) and whose live theme they displaced (replaced). Publishing another credential's draft is allowed — naming the id is the proof of intent. A publishingPUT(auto-publish tenants) carries the samereplacedecho.
Fonts
Pick vendored families in the theme (font.heading/body/mono). For a licensed font the platform doesn't vendor, bring your own:
POST /v1/fonts— multipartfile(woff2) +family+ optionalweight(100–900) +style(normal|italic). Hosted first-party, injected as an@font-faceon every page; referencefont-family: "<family>"in your CSS.GET /v1/fonts— list{ id, family, weight, style, url }.DELETE /v1/fonts/:id— remove a face.
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.
GET /v1/allowlist—{ active, pending, limits }. Each entry is{ id, origin, status, reason, addedBy, approvedBy, … }.POST /v1/allowlist— body{ "origin": "https://uploads.example.com", "reason": "…" }. The origin is normalized to a bare https origin (scheme + host + optional port; path/query dropped). Non-https, credentials, wildcards, IP/internal hosts, and platform (*.moseik.app) origins are rejected (422). Your proposal is held (202,status:"pending") — it is NOT in any page's CSP until it is approved. Always include a clearreason. The exception is a first-party product origin (todayhttps://api.hivoma.app), approved once for the whole fleet: that returns201withstatus:"active"andfirstParty:true, and is in the CSP immediately. Whether the site may use that product is a separate consent, not this list.POST /v1/allowlist/:id/approve— promote a pending origin toactive. Needscontent:publish. Read the next section before you call it.DELETE /v1/allowlist/:id— remove an entry. Withdrawing apendingproposal needs onlycontent:edit; removing anactiveorigin needscontent:publish.
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:
- Name the exact origin to that person, and say what will be sent there.
- 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.
- Then approve. If the answer is no,
DELETE /v1/allowlist/:idto withdraw it.
Two escape hatches from the pending step, neither of which you can grant yourself:
- a first-party product origin, approved once fleet-wide (above);
- a site whose publish policy is
{"external-origin":"auto"}, which lands proposalsactiveon POST. That is set on the staff-only policy surface, so it is a decision someone made about this site in advance — read it frompublishPolicyonGET /v1/siterather than asking for it per origin.
Moseik staff can also approve an origin someone chose to escalate to us. That is not the normal path.
Site + media
GET /v1/site— site config: NAP contact, locale, features,faviconAssetId,notifyEmail,buildRouting(whether leads reach the client yet), plus domain readiness,plan,capabilities,listings,analytics,publishPolicyandcsp(all read-only, see below).
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.
| Field | Meaning |
|---|---|
restricted | Read this first. false ⇒ no plan gate applies to this site |
id / label | the tier, or null when the site has no subscription record |
onPublicLadder | false for a commercial arrangement — not a plan name to quote |
pages.cap / used / remaining | the allowance, what is spent, what is left (null cap = no limit) |
includes | gateable 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.
| Field | Meaning |
|---|---|
trial | { endsAt, daysLeft, expired }, or null when the site is on a plan or was set up by staff |
domainNeedsPlan | true ⇒ 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:
capabilities— every platform capability this site could have, on or off, with a reason code and who can turn it on. See below.csp.effective— this site's actual Content-Security-Policy header, produced by the same function that serves it. Read it before concluding the platform blocks something: it is the difference between "this is forbidden" and "this site has not enabled it".csp.additionssays what this site added to the baseline and how.publishPolicy— whether this site publishes on write or holds for approval, which classes hold, and what the platform default is. Read it to explain why a write you just made is not live. Changed by platform staff, not through this API.
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" }
- Search returns no lots, deliberately — names, counts, cities and one sample address. It reads across every builder on the feed, which is the only way to answer "which org is this company"; returning inventory would make it a cross-tenant read.
- Choose on the count and the sample. An org id is opaque, so those are the only fields that distinguish the right company from a plausible one.
- The write is probed first. A scope that returns no published lots is refused (
listings_scope.no_inventory) rather than stored, so a mistyped id cannot become a configuration behind an empty grid. - The response names the builder. That line is the check — if it is not whose homes belong on the site, call again with the right org. Nothing else has to be undone.
provinceis derived from the lots and decides only where a map opens; send one only if told it could not be. The site re-renders automatically.
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" }
- Call it once, on any site with listings. It is idempotent — a second call is a no-op that reports the existing state without touching upstream — so it is safe in a build script and safe to retry after a timeout.
- You never receive the key. The platform mints it with its own credential and stores it sealed; the response carries a four-character hint and nothing else. There is no endpoint that reads one back.
- Nothing else changes. No page edit, no re-render, no new markup — the next feed read simply authenticates as this site.
GET /v1/site reports the current state under listings.license:
origin | ownBudget | What it means |
|---|---|---|
tenant | true | This site has its own budget. The intended state; nothing to do. |
fleet | false | Still on the shared budget. Call the endpoint above. |
fleet_after_decrypt_failure | false | The 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. |
none | false | No 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:
reason | What it means | What to do |
|---|---|---|
vertical_not_applicable | This kind of site never gets it | Build something else. Do not switch vertical to obtain it. |
not_provisioned | It applies here, but nobody has set it up | Ask platform staff. Do not hard-code content to fill the gap. |
killed | Staff switched it off for this site | Leave it. This is a decision, not a fault. |
platform_not_configured | The platform is missing a prerequisite | Report 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:
- Never change the vertical to obtain a capability. On a real-estate site the vertical also switches on the fair-housing compliance gate, and trading that away for a listings grid is the worse outcome. A home builder belongs on
construction, which requires no DDF market. - A capability that is off is never a reason to fake it. Hard-coded listing markup, a pasted third-party map, sample data "until the feed is connected" — each of those ships to a real client and outlives the reason it was written.
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:
- HubSpot — submissions become contacts.
- A signed outbound webhook — the submission is POSTed as JSON to a URL the client owns. This is how every other CRM is reached (Follow Up Boss, kvCORE, Salesforce, GoHighLevel…) and how Zapier/Make automations are fed. "Their CRM is not HubSpot" is never a reason to build something in the page.
- [MoxiWorks Engage](#routing-to-an-individual-agent--moxiworks-engage) — the lead reaches one agent's own dashboard rather than the site's.
- [Mailchimp](#newsletter-signups--mailchimp) — the person joins a newsletter audience. Named on the form only, never site-wide.
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:
| Code | Means |
|---|---|
crm_credential.scope_too_broad | the token can reach more than contacts. Ask for a contacts-only one |
crm_credential.scope_missing | it cannot read or cannot write contacts, so the connector could not dedup or create |
crm_credential.hub_mismatch | the 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">
- The name is all that appears in the markup. No URL and no secret is ever in a page, so a client editing their own page cannot leak a secret or repoint their enquiries.
- A form that names nothing still goes to every destination, so nothing changes for a site built before this existed.
hubspotis a routable name, so a site with a CRM connector can send one form there and another elsewhere. Configuring it is still a staff action, butGET /v1/lead-webhookslists it so you can route to it.defaultnames the singletonPUT /v1/lead-webhookdestination.- An unknown name is refused at write time (
gate.form_lead_destination, naming what this site actually has) rather than falling back to the default — a form routed at a destination that does not exist would submit, render and store perfectly while forwarding nowhere. - Deleting a destination a live form still names does not lose the enquiry. Each submission records a
faileddelivery saying which destination is gone, so it appears indeliveries[].GET /v1/formsfinds the forms that need editing. - A connected product is already a destination, with nothing to install. When the site has connected a first-party product from its dashboard, that product's name is routable immediately —
data-lead-destination="hivoma"passes the gate and its leads deliver, with noPUT /v1/lead-webhooks/hivomaand no allowlist entry. The receiver and the signing secret belong to the platform, not to the site, so there is nothing per-site to configure and nothing for you to install. An emptyGET /v1/lead-webhookson such a site is not a gap. Connecting is the client's decision and is made in their dashboard; no API can grant it. - A Proof product's reply can reach the visitor. On a
formwhose destination is a connected Proof product (EvryList, whose reply to a sign-up carriesreferral_url),data-response-fields="referral_url"(up to 4 names) makes the submit wait up to 6 seconds for the receiver's JSON reply and hand those fields to the page. Mark where they go inside yourdata-mk-form-successelement withdata-mk-response-field="referral_url". The value is set as text, and on an<a>anhttps://value also becomes itshref. A slow or failed receiver shows the confirmation without them; the lead is stored either way. Any other receiver's reply is ignored.
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.
- Do not build the id from a person's name. About 1 in 13 does not follow
first.last@century21.ca— multi-word surnames, teams and brokerage accounts all break the pattern, and 0.5% are opaque short codes. Store the id you were given. - Every submitted field travels, including ones Engage has no column for — those are appended to the contact note, so a custom question needs no configuration anywhere.
- Consent is sent only when a box was ticked. Submitting a form is implied consent under CASL and that is Engage's default, so a form with no consent checkbox sends nothing rather than recording a refusal it cannot prove.
deliveries[]carries the MoxiWorks contact id on success, and Engage's own reason on failure — almost always an agent id it cannot reach.
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.
pendingis the default, and it means double opt-in — Mailchimp emails the person and they join when they confirm."subscribed"adds them straight to the list with no confirmation step.- The routing is what protects the audience, not a consent check. Only forms that name this destination reach Mailchimp at all. The platform's consent flag records that a form's checkbox was ticked, not what it said — and that checkbox is required and ticked on nearly every Moseik form — so it cannot tell newsletter consent from "I agree to be contacted". A form using
subscribedmust setdata-consent-labelto wording that names the newsletter. A form built withdata-consent="false"has no checkbox at all and is always downgraded topending. - Only
email,FNAME,LNAMEandPHONEare sent. Mailchimp 400s the whole signup on a merge tag the audience does not have — it does not ignore it — so the convention covers only the merge fields a new audience is created with. Anything else needs afieldMapentry naming a tag that already exists in that audience. Unmapped fields are dropped from the forward and still saved in full on the lead. - A previously-unsubscribed address is never re-added. Mailchimp does not permit it over the API for anyone, only the person themselves can rejoin from a Mailchimp-hosted form. That lead records a
faileddelivery saying exactly that, is never retried, and is not a broken connection. - Signing up twice is one subscriber. The write is an idempotent upsert keyed on the address, and an existing member only ever has their merge fields refreshed — their subscription status is never changed by us.
deliveries[]carriessubscribedorpending— the member's status after the write, which is the part you cannot work out from the address.POST /v1/forms/<formKey>/testreally does add that address. There is no test audience to route into, so verify with your own address.
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's lead has twelve fixed fields, and everything else a form asks is folded into
additional_informationasLabel: valueclauses, in the order the form asked them, labelled from the field's own name (guestCountreads as "Guest Count"). Name a field the obvious way and it maps itself:
| Tripleseat field | Field names recognised |
|---|---|
first_name / last_name | firstName / lastName |
email_address | email |
phone_number | phone |
company_name | company, organization |
event_name | eventName |
event_date | eventDate, date |
start_time / end_time | startTime / endTime (use type: "time") |
guest_count | guestCount, guests, attendees |
email_opt_in | emailOptIn |
gdpr_consent_granted | gdprConsent |
gdpr_consent_grantedcomes from the visitor's consent tick when the form has nogdprConsentfield of its own. A form that asks the question itself wins, including when it answers no.- Anything the venue sorts or filters on must be one of the twelve. A field left to the fold arrives as prose inside a notes box — delivered, and useless for the thing they wanted it for.
fieldMapmoves one into a real column, and its value must name one of the twelve or the call is refused. - A date is converted to
MM/DD/YYYY, which is the only format their API reads. A value that is not an ISO date passes through as written, so a form asking "roughly when?" as free text still reaches the coordinator. - A submission with no email address is refused, not sent. Tripleseat accepts a lead without one, which leaves a coordinator an enquiry they cannot answer and looks exactly like a real one.
POST /v1/forms/<formKey>/test?notify=1is marked —additional_informationopens withTEST SUBMISSION, so the venue's team can tell a rehearsal from a booking.- The delivery records Tripleseat's own lead id, which is what matches an enquiry in
GET /v1/leadsto a booking in their system.
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">
- The sheet has to be shared, as an Editor. Share → General access → Anyone with the link, role Editor. Writing a row needs Editor; Viewer is not enough, and fails silently — see the next two bullets. This is the client's own action on their own file, and the only setup there is.
- No credential. This is the only destination that installs none — the middleware is the platform's own, on the platform's own key. The allowlist entry is
https://docs.google.com, which is where the data actually ends up.secretInstalledreadsnullrather thanfalseon this one, meaning "does not apply" — nothing is missing. - The call opens the sheet before storing anything, and refuses a wrong id, a wrong tab name, or a sheet nobody shared. This matters more than it sounds: the append endpoint answers success for a spreadsheet that does not exist, so a destination that was never verified reports every lead as delivered and writes none of them, with no signal anywhere. A
202-looking success is not evidence; the verifiedPUTis. - But that check is a READ, and it cannot prove we can write. A sheet shared as Viewer opens fine, passes the
PUT, and then accepts no rows — and the append endpoint still reports success. Nothing on this platform can detect that, so the one way to prove the whole path is to append a row and look at the sheet: settestMode: "send", runPOST /v1/forms/<formKey>/test?notify=1, open the sheet, then set it back. Do that once on every new sheet destination. - Keys are matched against the column headers. A field named
guestCountfills a column headed "Guest Count". Anything the sheet spells differently needs acolumnsentry —columnsalso fixes the order, and every declared column is written even when a branching form did not ask that question, so rows stay aligned. - A field with no column still lands, under a header of its own. Nothing is dropped quietly; a sheet gaining a column is something a person can see.
dateFormat: "us"for a sheet you are migrating. A page script that wrote12-05-2026leaves a column this platform would otherwise continue in ISO, and a mixed-format column reads as working until something downstream parses it.- A
?notify=1rehearsal appends nothing by default. These sheets are usually read by an automation, so a test row becomes a real task for the client's team. The delivery is still recorded onGET /v1/leads, astest submission — not appended. To prove the append itself, settestMode: "send"for one call and delete the row afterwards. - To read a sheet rather than write one, that is a different thing entirely — see the
/__data/proxy. One is a lead destination; the other renders rows on a page.
Two things affect whether a submission lands in HubSpot (neither applies to the webhook, which receives the submission as posted):
- An email field is required. The CRM identifies a contact by email; without one there is nothing to create or deduplicate against, and the forward is recorded as failed. Every other field is optional.
- Use ordinary field names.
email,name(orfirstname/lastname),phone,company,messageand their common variants are mapped automatically. A field that matches nothing is dropped from the forward — it is still saved in full on the lead, so staff can add an explicit mapping later with no data lost.
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:
PUT /v1/lead-webhookand/v1/lead-webhooks/<name>PUT /v1/crm-credential(the call that installs, afterconfirmHubId)PUT /v1/sitewithnotifyEmail,notifyCc,notifyBccorleadRouting— the request's other fields still apply- approving an allowlist origin, or adding one where the site's policy is
auto - a page, chrome or partial write that changes a form's
data-notify-email,-cc,-bccordata-lead-destination— that version is held until confirmed
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.
- The email goes to the current notify address, so a new address is confirmed at the old one. A live site with no notify email refuses with
409 owner.unreachable. - The link lasts 72 hours and works once. A second request for the same thing replaces the first.
- Deleting a destination is not held. Before go-live nothing is held.
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.
PUT /v1/site— update site settings. Body{ "notifyEmail": "leads@…" }sets the site-wide lead-notification recipient (nullclears it). A form can override the recipient per-form viadata-notify-email. Several addresses may be named, comma-separated (up to five) — each gets their own copy, so they do not see each other and one wrong address cannot cost the others their lead. Beyond five, point it at a distribution list on the client's own mail provider. The response echoes{ "notifyEmail": … }at the top level, andGET /v1/sitereports it at that same top-level key — verify a write there. Needs the write lease, like every other write.
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.
- Fields merge. Send only what changed.
nullclears one field.addressreplaces wholesale, becausePostalAddressneeds every part. An unrecognised key is refused rather than ignored — the field names are the platform's, not schema.org's (phonebecomestelephone,hoursbecomesopeningHours). - It re-renders the site. The JSON-LD lives in the cached HTML, so the response reports
rerenderQueued; the change is stored immediately and live once the re-render lands. - It emails the site owner, naming the fields that changed. A wrong phone number is lost business and they are the only party who can tell.
- Whether a visitor sees the change depends on your markup. A page that prints these fields with
<mk-component name="site-field" data-field="phone">is corrected by this write on the re-render above, with no page write of your own. A page with the number typed into its HTML is not — that text is page content, and nothing checks that the two agree. See site-field. contact.emailis the business's public address, published in structured data.notifyEmailis where enquiries are routed. Different fields, usually different values.
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.
GET /v1/leads— captured form submissions, newest first.?page=<pageId>filters to one page;?limit=(≤200) &?offset=paginate. Each lead:{ id, formInstanceId, formKey, pageId, data, consent, createdAt }.
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.
POST /v1/leads/:id/redeliver— re-attempt a lead that failed to reach its destination. Body is optional: none (or{}) re-attempts every destination that failed,{"destination":"quotes"}just that one,{"force":true}overrides the refusals below. Needscontent:editand no lease — it writes no content and stores no new lead.
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.
POST /v1/media/import—{ "url": "https://…", "alt": "…" }. Mirrors the asset first-party (no third-party origin is ever served) and returns it; reference it as<img src="/media/<id>">or<video src="/media/<id>">.POST /v1/media— direct multipart upload (file,alt).GET /v1/media— library list/search;GET /v1/media/:id— asset metadata.
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:
| Class | Accepted | Size limit |
|---|---|---|
| image | PNG, JPEG, WebP, AVIF, GIF, SVG | 25 MB |
| document | PDF, DOCX | 25 MB |
| video | MP4, WebM | 50 MB |
Enforced identically on upload and import. Three things about how it is enforced:
- Acceptance is decided by the file's actual contents, not its extension or filename. A
.pngthat is really something else is rejected, and renaming it changes nothing. - A rejection is per-file, never a missing capability. If an upload is refused, uploads still work — that file did not qualify. The error names the accepted list and the endpoint to retry on.
- SVG has a second gate. It must also pass the SVG sanitizer, so "SVG is accepted" and "this SVG was accepted" are different statements. A refused SVG names what in the file caused it; other SVGs are unaffected.
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:
| Body | Meaning |
|---|---|
{"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.
| Request | Returns |
|---|---|
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:
- **The site has a custom domain and you're looking at the
*.moseik.apppreview host.** The canonical points at the real domain, so indexing the preview would be duplicate content. - 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:
status | meaning |
|---|---|
active | Serving. |
pending | Provisioned; Cloudflare is still validating. Self-resolves — no action needed. |
failed | Will 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
| value | meaning |
|---|---|
custom_hostname_missing | No Cloudflare-for-SaaS custom hostname. Hard blocker — will not self-resolve; Moseik has to fix it. |
custom_hostname_pending | Provisioned; Cloudflare is still validating. Resolves itself once the client's CNAME resolves. |
custom_hostname_failed | Cloudflare reported the hostname as failed. Moseik has to recreate it. |
provisioning_unconfigured | This 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.
- 3 to 40 letters, numbers and single hyphens:
422 domains.address_invalid.api,draft-…and other reserved names:422 domains.address_reserved. - In use, or held for another site:
409 domains.address_taken. A site can take back a name it gave up. - Three renames a day per site:
429 domains.address_rate_limited.
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.
GET /v1/favicon— the current favicon: `{ "assetId": "ast_…", "url":
"/favicon.ico", "mime": "…" }, or { "assetId": null }` when none is set.
PUT /v1/favicon— body{ "assetId": "ast_…" }to set it (the asset must be an image), or{ "assetId": null }to clear it. Needs the write lease and honors the freeze (423), like every write — since 2026-08-16.
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.
| class | what it is | outcome |
|---|---|---|
| copy | visible text changed, structure didn't | publishes |
| structure | the tag/attribute skeleton or the CSS changed | publishes |
| page-create | a new page | publishes — unless the programmatic-SEO gate flags it (see below) |
| theme | design tokens | publishes |
| sensitive-fields | a protected detail changed — NAP/contact (phone, email, address) | publishes, and emails the site owner a one-click undo |
| regulated-copy | copy that tripped a compliance rule (e.g. fair-housing language) | held |
| external-origin | a request to allowlist a third-party origin | held — 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:
- A question about a platform rule — is this copy legal? — reaches platform staff on the review queue, and they are the only ones who can answer it. Held.
- A question about who this client trusts — may their customer data go to this origin? — is held too, but the person who answers it is the one running you, not us. You promote it yourself once they say yes. See [the allowlist](#external-origin-allowlist).
- A question about the client's own business — is this the right phone number? — does not. Nobody at the platform knows, so the change ships and the owner is emailed a one-click undo instead.
- A question nobody needs to answer — reversible, immediately visible, no outside observer — is pure friction. Publishes.
Two consequences worth knowing as a caller:
- A
sensitive-fieldswrite returns200withownerNotified: true | false, and onfalseanownerNotifyBlockedstring saying why — usually nonotify_emailon the site, or the mail service being down. It publishes either way. TreatownerNotified: falseas something to fix (setnotify_email), not something to retry. A site with nonotify_emailis also silently not getting its lead notifications, since those fall back to the same field. - A new page can still be held even though
page-createpublishes: the programmatic-SEO gate overrides the policy for a template page that is thin, a near-duplicate, or cannibalizing an existing route.
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.