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.

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 (background-image, for example) is removed, so CSS cannot trigger a remote request.
  • 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.

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, only registered ids are recorded: a control whose name is not registered is dropped before the submission is stored, so an extra input in the markup can never write into the response. 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.

Where to read next