Custom HTML forms
A custom HTML form is a response-only form whose body is HTML supplied by an AI agent or by you, instead of the blocks the builder lays out. It is the custom_html section of the form's config: html is the markup and fields is the field-registration contract. The MCP tool upsert_form takes the two as top-level arguments (custom_html, custom_fields) and is the practical way to write one; a REST create or update can carry the same section inside a raw config map. There is no dashboard editor for this form type today, so the write surfaces are the only way in, and a form with no html in its custom_html section renders through the block renderer as usual. The dashboard's forms list still offers Edit on a custom form and opens the block editor, but that editor cannot change or remove the body: its save carries the custom_html section forward untouched, and the blocks it writes back are not rendered while the section carries html.
A custom form is a response form
The custom_html section and the form's mode are two statements of one fact, so you only have to write one of them. A form that carries an enabled section is a response-only form — it collects answers, it never takes payment — and the write surfaces store mode: "response" beside the section for you. That holds for upsert_form's custom_html and custom_fields arguments and for a raw config map on a REST create or update alike, so a raw config whose custom_html has a body and no mode key is stored as a response form rather than as a payment form: the buyer gets your markup, not the builder or payment layout. Writing mode: "payment" next to an enabled section does not win — the section is the form's body, and the payment renderer cannot paint it, so the stored mode becomes response.
The reverse direction is unaffected: a config with no section, or with an empty html, is stored exactly as you sent it, so setting mode is the only thing that makes an ordinary form a response form. A form already stored with an enabled section and no mode key (written before this rule, or cloned from an older template) is still treated, rendered and counted as a response form — the section alone decides.
What happens to the HTML
The markup is stored as you sent it and sanitized again on every render — the stored body is never trusted verbatim. The sanitizer keeps the structural and form elements a form needs and removes everything that could execute or reach the network:
- Scripts and embedded content are dropped with their content. A script, iframe or object element does not survive the sanitizer, so nothing in the body can run code or load a second document.
- Remote resources are removed. Only relative URLs, data: URLs and mailto: survive, so a stylesheet, font or image hosted elsewhere cannot be loaded — an https image is stripped and the element is kept without its src. Inline data: images do work.
- Inline styles are kept for a safe allowlist — colour, background colour, typography, spacing, borders and layout. A url() inside a style attribute (background-image, for example) is removed. A style element is a different case: it is allowed through and its CSS is kept as written, so the property that blocks a remote fetch is the frame's own policy (images limited to data:), not the removal of the rule.
- The form tag itself is stripped. The controls survive, and the page wraps the frame's content in a form at load, so Enter, the required attribute, pattern and the browser's own type checks still apply to the buyer's answers.
The sanitized body is then written into a sandboxed iframe that has no allow-scripts flag, with its own policy: no default source, inline styles only, images limited to data:, and form submissions only to this form. The outer page owns the submit button and the hidden anti-spam fields; a submit inside the frame is forwarded to the same route. The merchant markup is therefore inert — it cannot run script, navigate the page or address another origin.
Your own custom_js still runs here. The inert part is the HTML body, not the form's theme. A custom_js set on the form through set_form_theme (or the theme editor) is served on the outer page, outside the sandbox, in its own script block that carries the same per-response nonce as the page's policy — the same JS a block-rendered form serves. The frame is unchanged: it still has no allow-scripts and its own nonce-free policy, so the JS runs on the page rather than inside the body, and it cannot make the body execute. It runs under that page's policy, which is stricter than a block-rendered page's: third-party fetch/XHR/beacons are blocked (only same-origin requests are allowed), and eval and new Function are refused — so a self-contained script works and one that loads code at runtime does not. A value that tries to close its own script block is cut off at the attempt; to keep a literal end tag inside a string, write it as <\/script.
Register the fields
custom_fields is an array with one object per answer, and each object has five properties:
"custom_fields": [
{ "field_id": "nama", "type": "text", "label": "Nama", "required": true },
{ "field_id": "emel", "type": "email", "label": "E-mel", "required": true },
{ "field_id": "nota", "type": "textarea", "label": "Nota", "required": false,
"validation": "optional - what the answer should look like" }
]- field_id is the name. Every control in the markup must carry that exact value in its name attribute — that is how a submitted value is matched to the contract. Each id must be non-empty and unique; a save with an empty id, a duplicate id or a type outside the vocabulary is refused and says which one (field_id wajib, field_id duplikat, jenis medan tidak sah).
- label is what an error names. The server's validation message quotes the label; with no label it quotes the field_id instead.
- required is enforced twice — by the browser through the control's attributes, and by the server on the submitted answers, so a direct POST that omits the field is refused with 400 rather than stored.
- validation is a hint, kept for whoever maintains the form. Nothing on the page or in the server compiles it into a rule, so it cannot refuse an answer the live page accepted.
A required field also has to exist in the markup. The save is refused when a required field_id has no control the page can read — an input, select or textarea carrying that name, or a button carrying a non-empty value — because the buyer's page can only submit controls it can find, and a contract with no control behind it would make every submit fail. The refusal names the field (medan wajib tiada dalam HTML, then the missing name attributes) and comes back from the form and template write paths as well as from upsert_form, create_template and update_template. Optional fields need no control at all.
On submit the answers are filtered to the contract: a control whose name is not registered is dropped before the submission is stored, so an unregistered name never becomes a field of its own. The buyer's identity is written outside that filter. It is recorded in the same stored response as full_name, email and phone. A control the markup NAMES — one carrying name="full_name", name="email" or name="phone" — answers for that field whatever its input type, so naming the control is how you place your name field anywhere in the body. A body that declares none of them falls back to the first input[type=text], input[type=email] and input[type=tel] the markup carries — registered or not. So a text control that is not registered still reaches storage: its value is stored as full_name, the field the responses list shows as the buyer. The anti-spam fields the page itself emits are consumed and left out too.
Field types
The type vocabulary is the browser's input types plus three shapes for longer or structured answers:
- text, email, tel, number, date, time, url, password — single-line answers. JomForm does not draw the control: your markup carries an input of the matching type, and the type name is what the contract records for it.
- textarea — a multi-line answer.
- select, radio, checkbox — the choice controls. A select stores the chosen option's value, a radio stores the checked control's value, and a checkbox stores 1 when it is ticked and an empty string when it is not.
- hidden — a value the markup supplies, never shown to the buyer.
- consent — a tick box for agreeing to something, such as a privacy notice.
Server-side enforcement is deliberately narrow: required for every type, and a format check for email, number and url only. tel, password, date, time and the choice types are required-only — the server has no second copy of what a valid date or a matching password means for your markup, so it checks presence, not shape. url is re-checked with the same rule the builder's website field uses (see the form builder guide), so a scheme that rule refuses is answered 400 even where the browser would have accepted the string.
Submit, storage and what the buyer sees
The buyer's answers are POSTed to the form's own order route as a client block and a fields map. The registered answers are stored as a response — no sale, no payment, no card step — and the form's confirmation (a message or a redirect) is resolved from those answers, exactly as for any response form. The stored answers appear in Sales → responses, with a detail drawer holding every field value, and agents read the same data through list_form_submissions.
Clearing a custom form
Write an empty html and the custom form is removed: with upsert_form that is an empty custom_html string, and through a raw config map it is a custom_html section with no body. The form keeps its published URL and renders through the block builder again. Its mode is left exactly as it was — an emptied custom form that was response-only stays response-only, so set mode yourself if you want those blocks to take payment.
Where to read next
- MCP tool reference — upsert_form's full argument list, and every other tool an agent can call.
- The form builder — the block builder a custom form replaces on the published page.
- Responses (response-only forms) — where the stored answers appear, and the list_form_submissions tool that reads them.