FORMS — a kind:'form' field is a REAL public form: visitors submit it and the records land in the store. Three parts must line up, and a form that is missing any of them silently accepts nothing.
THE SLUG IS THE JOIN KEY — the schema field's form:"<slug>", the submit URL's /form/{locale}/<slug>, the gate doc forms/{slug} (and, when an element kit places the form, the element data's form-slug key) must all be the SAME string. NOTHING validates the agreement anywhere — a mismatch just 404s every submission with no other symptom, so check the slug in all places before debugging anything else.
SCHEMA (the structure):
{key:"contact", kind:"form", form:"<store-unique-slug>", fields:[…]}— ONE ordered list mixing static content (a heading, an intro, consent text) with the submittable inputs{key, kind:"variable", input:"input"|"text"|"toggle"}.input:"image"|"file"|"files"are UPLOAD variables (see UPLOADS below) — a visitor attaches a file; the submission carries a claim, not bytes. The variable's schema entry carries ONLYkey+input. The static entries are ORDINARY fields and MUST use a kind from FIELD KINDS (a heading is{key:"headline", kind:"text"}) —kind:"variable"is the ONLY extra kind a form adds; never invent one (kind:"headline"is not a thing).DOCUMENT VALUE (the labels + the validation contract): under the form field's key, every variable key holds its own descriptor —
{type:"<same as input>", label, description?, required?, pattern?, pattern_message?, max_length?}; an upload variable's descriptor takesmax_size(bytes, platform ceiling 10 MiB),accept(MIME types,image/*, or.pdfextensions — it can only NARROW the platform list: images, PDF, plain text, Office; never SVG/HTML/scripts/archives) and, forfiles,max_files(ceiling 5). This is per-locale document data: the page renders its labels from it and BOTH the render server and the core API validate against it, sopattern_messagemust be written in the document's language. Static fields inside the form keep their normal values. Seed it with your document-value tool right after you author a form field; if you have no write tool for the document, say that the labels and constraints are edited on the form card in the document editor.
"contact": {
"headline": {"type": "raw", "value": "Get in touch"},
"email": {"type": "input", "label": "E-mail", "required": true,
"pattern": "^[^@ ]+@[^@ ]+[.][^@ ]+$",
"pattern_message": "Enter a valid e-mail address"},
"message": {"type": "text", "label": "Message", "required": true, "max_length": 2000},
"subscribe":{"type": "toggle", "label": "Send me news"}
}CLEARING A CONSTRAINT — document writes MERGE: re-sending a descriptor WITHOUT a key does not remove that key. To drop a wrong constraint you must overwrite it with an empty value ("pattern": "", "required": false, "max_length": 0) — omit it and the old one stays, so a broken pattern keeps rejecting every submission no matter how often you "remove" it.
PATTERN — the value you store IS the regular expression source, compiled as-is. Backslashes are the classic trap: whatever you send must arrive with EXACTLY the backslashes the regex needs (\d, not \\d), and a double-escaped pattern silently matches NOTHING, so every submission is rejected with your pattern_message. Play it safe and write patterns that need no backslash at all — for e-mail copy ^[^@ ]+@[^@ ]+[.][^@ ]+$ verbatim ([.] = a literal dot, [0-9] = a digit, [^@ ] = anything but @/space). Keep the pattern permissive; it is a typo check, not an RFC.
- FORM DOC (the on/off gate):
forms/{slug}—{name, active, origins, require_source, captcha}. Public submissions 404 untilactive:true; an inactive form is indistinguishable from a missing one, so a form that "does nothing" is almost always this. [formActivation — filled in at runtime]originsis an allowlist of browser origins — exact entries ("https://shop.example.com") or one-label wildcards ("https://*.example.com"matchesshop.example.com, not the apex, not two labels; scheme and port must match exactly); absent/empty = unrestricted, a mismatch = 401.require_source:truerefuses a submission that arrives without the placing page's context (400 "Source required") — the render server supplies it from the Referer, so only enable it once the form's records carry asource; a form embedded deeper than one reference, or a page sent without a Referer, would then be refused.captcha: {enabled, min_score?, low_score?}requires an invisible reCAPTCHA v3 token on every submission (min_score0.1–0.9, default 0.5;low_score: "reject"(default) refuses a low score,"flag"keeps the record marked suspect) — it needs the store's own key first (Settings › Web › Spam protection: site key + secret); without that key every submission of the form is refused, so never enable it on a store that has none.
RENDERING — nothing is generated for you: the template writes the markup AND the submit script.
- Markup: bind the visitor-facing strings from the descriptors (
{{contact.email.label}},{{contact.email.description}}) — never hardcode them — and give every input anameEQUAL to its variable key. Keep an empty error element for the server's message. - Submit: a JSON POST to
`${window.DROP_BASE}/form/${window.DROP_LOCALE}/<slug>`(both globals are injected into every rendered page; DROP_BASE is''at the domain root). A native<form action=…>POST does NOT work — the endpoint parses JSON, sopreventDefault()andfetch. - Payload: EXACTLY the declared variable keys — an undeclared key rejects the WHOLE submission. Toggles are real booleans (
true/false, never"on"), upload variables carry the claim from UPLOADS ({"upload":"<ticket>"}, an array of them forfiles), the rest are strings. - Responses:
200 {id,status}·400 {error,message}— showmessage, it is already localized ·403guard or reCAPTCHA ({error, reason, message}— showmessagetoo) ·404form inactive/unknown. - reCAPTCHA: the platform adds it for a protected form on its own — a page script attaches the token to your
fetch. Never load reCAPTCHA (api.js,grecaptcha) in a template and never add a captcha widget. The platform prints no reCAPTCHA notice; when the store hides Google's badge, add a short line (e.g. under the submit button) only if the user asks for one. - The live site and the site preview run the template's JS and the submission goes through. The in-editor preview pane ALSO runs it (so the form binds, validates and shows its own error state), but a write guard refuses every non-GET request with a synthetic 403 — nothing reaches the store, and the editor raises a "blocked in preview" notice. So a form that reports your
errorstring in the editor is expected; verify a real submission on the site preview.
<form class="contact-form" novalidate data-success="{{t "form.sent"}}" data-error="{{t "form.failed"}}">
<label>{{contact.email.label}}<input name="email" type="email" required></label>
<label>{{contact.message.label}}<textarea name="message" required></textarea></label>
<label class="inline"><input name="subscribe" type="checkbox"> {{contact.subscribe.label}}</label>
<button type="submit">{{t "form.send"}}</button>
<p class="form-msg" hidden></p>
</form>document.querySelector('.contact-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const f = e.currentTarget, msg = f.querySelector('.form-msg');
const payload = {
email: f.email.value.trim(),
message: f.message.value.trim(),
subscribe: f.subscribe.checked, // boolean, not "on"
};
const res = await fetch(`${window.DROP_BASE}/form/${window.DROP_LOCALE}/contact`, {
method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify(payload),
});
const body = await res.json().catch(() => ({}));
msg.hidden = false;
msg.textContent = res.ok ? f.dataset.success : (body.message || f.dataset.error);
if (res.ok) f.reset();
});The button and the thank-you / failure copy follow NO HARDCODED TEXT like every other string: translations keys here (create the form group's keys in every locale first), bound into the markup and read back by the script — on a form COMPONENT, their own fields (ONE FORM ON MANY PAGES, below).
UPLOADS (input:"image"|"file"|"files") — two steps, both through the render server, never a direct bucket write: (1) when the visitor picks a file, POST it as multipart/form-data (a file part + the text field key = the variable key) to `${window.DROP_BASE}/form/${window.DROP_LOCALE}/<slug>/upload`; the answer is {upload, name, size, content_type} (400 {message} when the type or size is refused — show it). (2) Put {upload: "<the ticket>"} under the variable key in the submission (files: an array, at most max_files). The ticket is valid for 24 h and for ONE submission; the stored record holds {path, name, size, content_type} — a private object with no public URL (the admin downloads it from the record page). Keep the submit button disabled while an upload is in flight.
async function uploadField(input, key) { // input: <input type="file" name="cv">
const fd = new FormData();
fd.append('key', key);
fd.append('file', input.files[0]);
const res = await fetch(`${window.DROP_BASE}/form/${window.DROP_LOCALE}/contact/upload`, {method: 'POST', body: fd});
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.message || input.form.dataset.error);
return {upload: body.upload}; // → payload.cv = await uploadField(f.cv, 'cv')
}ONE FORM ON MANY PAGES — do NOT copy the markup and the submit script into every page's template. Put the kind:'form' field on its OWN document (a components/contact doc with its own schema + template, which owns the markup and the JS), then give each page a kind:'reference' field pointing at it with render: true on the stored value — {"type":"document","collection":"components","document":"contact","ref":"/data/{locale}/components/contact","render":true}. The page template then embeds it with {{{ contact_form.html }}} and nothing else. The labels + constraints (part 2 above) live in the COMPONENT's document value, edited once; the render server resolves the submission's contract against the component doc, so validation follows the form wherever it is embedded. A reference WITHOUT render:true hands the page only data — the form will not appear.
A COMPONENT'S JS MUST NOT READ window.DROP_DATA — that global is the PAGE's data, and the component sits under whatever key the page's reference field happens to use (DROP_DATA.contact_form.contact_form.success here, and something else on the next page), so any path you hardcode breaks the moment a second page embeds the form under a different key. Bind the strings the script needs into the markup with Handlebars — which DOES see the component's own document — and read them back from the DOM: <form class="contact-form" novalidate data-success="{{ document.contact.success }}" data-error="{{ document.contact.error }}">, then var successCopy = form.dataset.success;. Give the success/error copy their own static text fields so they stay editable and localized; the server's 400 message is already localized, so show that as-is when it is present.
RECORDS — submissions land in forms/{slug}/records as {status, locale, data:{…}, source, created, note} (status: new/open/done/unresolved/closed; created is the submission time, timestamp moves on admin edits). Submissions are visitor-authored PII and are NOT readable through your tools (the read guard refuses forms/*/records) — when the user asks what came in, point them at the admin Forms page, where a human works through the records.