Skip to content
Siteon
Contents Forms

Docs

Forms

Forms that only need data-sitekit-form. Messages, notifications, auto-replies, conversion tracking (GA4, Google Ads, Meta), test submissions, and traffic source recording. Coming soon are templates, hosted form pages, conditional fields, duplicate prevention, capacity with a waiting list, stats and CSV.

Siteon is infrastructure for AI-built websites: it adds hosting, a CMS your client can edit, contact forms, analytics and control from AI agents (CLI and MCP) around static HTML built with AI or by hand, so it is ready to ship.

The minimal form

Write only the fields. You don't need JavaScript for submission, hidden fields, or error display (do include the script tag).

<form data-sitekit-form="contact">
  <label>Name <input name="name" required autocomplete="name"></label>
  <label>Email <input type="email" name="email" required autocomplete="email"></label>
  <label>Message <textarea name="message"></textarea></label>

  <!-- Bot protection. Place it where people can't see it (required) -->
  <input type="text" name="_hp" tabindex="-1" autocomplete="off"
         style="position:absolute;left:-9999px" aria-hidden="true">

  <button type="submit">Send</button>
</form>

<div data-sitekit-success role="status" aria-live="polite" hidden>Thank you for your inquiry.</div>
<div data-sitekit-error role="status" aria-live="polite" hidden></div>

Matching names

HTMLschema.json
contact in data-sitekit-form="contact"forms[].key
An input's name="email"fields[].key of that form
  • A form key that isn't in schema.json returns not_found
  • A name that isn't in schema.json doesn't cause an error, but it isn't saved either
  • An empty required field returns invalid_request (per-field constraints such as length are in schema.json types)

Always include the honeypot (_hp)

The name="_hp" input is a bot trap that people never see. Submissions with this field filled in are not saved. To avoid tipping off bots, they look the same as a success on screen.

  • Add tabindex="-1" and autocomplete="off", and place it off-screen (so browser autofill doesn't fill it in)
  • Don't list it in schema.json

Success and error messages

Place [data-sitekit-success] and [data-sitekit-error] inside the form, or directly under the same parent element as the form.

  • On success, the form gets hidden. Put the success message outside the form (inside, it would be hidden along with the form)
  • Add role="status" aria-live="polite". Without them, screen readers won't announce the message shown after submission
  • On error, [data-sitekit-error] gets one of the messages below

The script currently shows these messages in Japanese. The table gives their meaning.

SituationMessage shown
Input error (invalid_request)Please check your input
Too many submissions in a short time (rate_limited)Please try again later
Turnstile check failed (turnstile_failed)Verification failed. Please try again
No connectionCould not send. Please check your connection
Anything elseCould not send. Please try again later

Forms with duplicate-answer protection or a capacity add these.

Situation (error)Message shown
Already answered (already_submitted)Already answered
Full (full) SoonFull
Outside the application period (not_open / closed) SoonOutside the application period

On input errors, the affected fields get aria-invalid="true". Style them with your site's CSS.

[aria-invalid="true"] { border-color: #c00; }

Redirecting to another page after submission

<form data-sitekit-form="contact" data-success-url="/thanks/">

Only pages on the same origin are allowed. To redirect to another origin, allow it explicitly.

<form data-sitekit-form="reserve"
      data-success-url="https://booking.example.com/done"
      data-success-external="1">

A different scheme or port counts as a different origin. javascript: and data: never redirect, even with data-success-external.

Preventing double submissions

Repeated clicks and resubmission by pressing Enter in a field are blocked automatically. While sending, the submit button is disabled and the <form> gets aria-busy="true". This also works for submit buttons placed outside the form with form="form-id".

File attachments

Add <input type="file" name="photo" accept="image/jpeg,image/png,image/webp,application/pdf"> to receive photos and PDFs. When you add a "File" field to the form in the dashboard, the embed code includes it too.

  • Images (JPEG, PNG, WebP) and PDF only. Up to 10 MB per file, and 3 files / 20 MB in total per submission. Files count toward the site's photo storage
  • One file per field (with multiple or several inputs sharing a name, the submission is stopped with a message)
  • Download received files from the submission's details in the dashboard (each link expires after 15 minutes). Notification emails don't carry the file — they say a file is attached and link to the dashboard. Files aren't sent to webhooks, CSV exports or spreadsheets either
  • If an upload fails (too large, wrong type, and so on), the submission is stopped and the reason is shown. Files are never silently dropped
  • Booking-page forms can't have file fields for now
  • If you copied the embed script onto your own site, replace it with the current version. With an old copy, forms that have a file field can't be submitted

Fields with multiple values

For checkbox groups, <select multiple>, and similar, when several inputs share the same name, only the last value is sent. For questions that need several answers, use separate keys.

<!-- NG: only one topic arrives -->
<input type="checkbox" name="topic" value="a">
<input type="checkbox" name="topic" value="b">

<!-- OK -->
<input type="checkbox" name="topic_a" value="1">
<input type="checkbox" name="topic_b" value="1">

Checkbox groups (checkbox_group)

Checkboxes sharing one name can send every checked value. In the schema, give the field type: "checkbox_group" and list the choices in options.

<label><input type="checkbox" name="topic" value="price"> Price</label>
<label><input type="checkbox" name="topic" value="hours"> Opening hours</label>

For any other field type, a repeated name keeps only the last value.

Turnstile

If the form has the cf-turnstile-response input created by the Cloudflare Turnstile widget, it is sent along automatically. No extra markup is needed on your site.

Notifications

An inquiry is final once it is saved. Notifications are sent afterwards, and if any notification fails, the inquiry is not lost. Each notification's result appears in the inquiry's details in the dashboard, and failed ones can be resent.

Notification destinations are not set in schema.json. The site owner changes them in the dashboard under "Settings → Notifications → each form".

NotificationWhat you set
Email to the businessRecipient email addresses (more than one allowed)
SlackAn Incoming Webhook URL (create an app in Slack, choose the channel to post to, and issue the URL)
Webhook (paid plans)A destination https URL. Register it in the dashboard under "Settings → Advanced → Webhooks" to send signed JSON to Zapier, Make, a CRM and more (Webhooks)
Logging to a Google spreadsheet SoonConnect with your own Google account. Rarely, the same answer lands in two rows; the ID in the first column tells them apart
  • Each notification can be turned on or off
  • A Slack Webhook URL is a key: anyone who has it can post to that channel. The dashboard shows it masked, and the API never returns it. If you lose the URL, issue a new one in Slack
  • Only the owner can change these settings (staff can only view them)

Thank-you emails (auto-reply) and template variables

A thank-you email is sent to the customer when:

  1. The form's "Thank-you email" setting is on in the dashboard
  2. The form has a type: "email" field, and it has a value

Write the subject and body in the settings screen (with a preview). Available variables:

VariableValue
{{name}}, {{email}}, and so onThe value of a form field (the field key as is)
{{site_name}}Site name
{{form_label}}Form name (forms[].label)
{{submitted_at}}Date and time of submission
Dear {{name}},

Thank you for contacting {{site_name}}.
We will review your message and get back to you.

Received: {{submitted_at}}

Tracking submissions in GTM

Only when a submission succeeds (once, after it has been accepted and saved), the following is pushed to dataLayer. Nothing is pushed when the button is pressed or when sending fails.

{
  event: "sitekit_form_submit",     // completion event name (can be changed per form)
  form: "contact",                  // form key (the value of data-sitekit-form)
  sitekit_form: "contact",          // same value as form
  sitekit_site: "hanaya",           // site ID (data-site)
  sitekit_submission_id: "…"        // submission ID, for deduplicating conversions
}
  • Field values (name, email address and so on) are never included. Ad and analytics tags send what they receive to third parties, so no personal data is put here
  • You can change the event name in the dashboard under "Settings → Notifications → each form" (Completion event name). The default is sitekit_form_submit. The name arrives in the submission response, so no site redeploy is needed. If you rename it, update your GTM trigger too (triggers using the old name silently stop firing)
  • Submissions caught by the honeypot also look successful on screen, so this event is still pushed for them (with sitekit_submission_id set to null). Count actual inquiries in the dashboard list

Without GTM (the sitekit:submit event)

A DOM event sitekit:submit, with the same values in detail, is dispatched on document. Use it if you write your GA4 or Meta tags yourself.

<script>
  document.addEventListener("sitekit:submit", (e) => {
    const d = e.detail; // { event, form, sitekit_form, sitekit_site, sitekit_submission_id }
    gtag("event", "generate_lead", { form_key: d.sitekit_form, transaction_id: d.sitekit_submission_id ?? undefined });
    fbq("track", "Lead", { content_name: d.sitekit_form }, { eventID: d.sitekit_submission_id ?? undefined });
  });
</script>

Setting up GTM

  1. Trigger — Type "Custom Event", event name sitekit_form_submit (or the name you set in the dashboard). To tell forms apart, choose "Some Custom Events" with a condition such as Form Key (the variable below) equals contact
  2. Variables — Type "Data Layer Variable". Create form (named e.g. Form Key) and, for deduplication, sitekit_submission_id (named e.g. Submission ID)
  3. Tags — Use trigger 1 as the firing trigger of the tags you want to send (examples below)

Example: a GA4 key event

SettingValue
Tag typeGoogle Analytics: GA4 Event
Measurement IDG-XXXXXXX (same as your GA4 configuration tag)
Event namegenerate_lead (GA4's recommended event name; any name works)
Event parametersform_key = {{Form Key}}
Firing triggerThe trigger above

In GA4's admin under "Events", mark generate_lead as a key event to count it as a conversion. With form_key, you can tell in GA4 which form the inquiry came from.

Example: a Google Ads conversion (transaction ID)

SettingValue
Tag typeGoogle Ads Conversion Tracking
Conversion ID / labelShown when you create a "conversion action" in Google Ads (category such as "Submit lead form")
Transaction ID{{Submission ID}} (keeps the same submission from being counted twice)
Firing triggerThe trigger above

Add a "Conversion Linker" tag (firing on all pages) to the same container if there isn't one. To deduplicate with the Meta pixel, pass the same submission ID as eventID (see the sitekit:submit example above). The script records gclid separately as part of the traffic source, so for offline conversion imports you can read it from the dashboard's CSV or a webhook.

Checking it works

Open your site in GTM's preview (Tag Assistant) and actually send the form. If the completion event appears in the event list on the left and the "Data Layer" tab shows form and sitekit_submission_id, you're set.

Test submissions

The most reliable test is to submit from a published page (a development domain is fine). You can also post to the API directly.

curl -s -X POST -H 'content-type: application/json' \
  -d '{"name":"Test","email":"[email protected]","message":"This is a test submission"}' \
  https://cdn.siteon.dev/v1/sites/hanaya/forms/contact
# => {"ok":true,"id":"...","gtm_event":"sitekit_form_submit"}

Test submissions are saved as real inquiries, and notifications are sent. They also count toward your limits. Delete them from the dashboard afterwards. You can also send one with siteon forms test in the CLI (send_test in MCP) (CLI, MCP, and API keys).

Recording traffic sources (UTM)

The traffic source remembered by the script (utm_* / gclid / fbclid from the first and the latest visit, the landing page, and the referrer) is attached automatically to every submission and saved with the inquiry. Nothing is needed on your site (script tag).

Start from a template

Common forms come ready-made, fields and settings included: the admin's "Forms → Add from a template", siteon forms create --template <name> or the MCP tool create_form all make the same thing.

TemplateWhat it asks
contactEnquiry (name, email, phone, message)
survey SoonSurvey: a 1–5 rating, NPS, multiple choice and a comment. Set up as a survey with no personal fields (see "Stats and CSV")
application SoonApplication with a capacity per date and a waiting list, confirmed by email (see "Capacity" and "Duplicate answers")
brochureBrochure request (company, contact, which documents)
jobJob application (contact, position, motivation)

Everything a template sets can be edited afterwards like any form.

survey uses same-browser duplicate protection and application uses a capacity and email confirmation, so forms made from these two do not accept answers until those features open Soon (a survey form accepts answers once you set dedupe to none or phone).

Hosted form page (no website needed)

Each form can get a page hosted by Siteon (admin "Forms → Publish", siteon forms publish <form>).

  • The URL is https://<shared domain>/f/<page id>, with a QR code (SVG) and an iframe snippet for other sites
  • No deploy needed. Pages of Free sites show a small "Made with Siteon" badge
  • Submissions are protected by Cloudflare Turnstile and work without JavaScript
  • Unpublishing stops the page and the intake (confirmation and cancel links of answers already in keep working)
  • A form asking for payment details (card number, PIN …) cannot be saved on a Free site and is sent to an automatic check on a paid one (do not collect card details in a form)

Conditional fields (show_if)

A field shown only for some answers carries show_if in the schema. It may refer to a select, radio, checkbox_group or checkbox of the same form, at most three levels deep.

opRefers toShown when
eq / neqselect, radiothe value is / is not value
inselect, radio, checkbox_groupany chosen value is in value (an array)
not_emptyselect, radio, checkbox_groupanything is chosen
checkedcheckbox (no value), checkbox_group (one value)it is checked
{ "key": "detail", "label": "Details", "type": "textarea", "show_if": { "field": "topic", "op": "eq", "value": "other" } }

A hidden field may be left empty even if required, and a value sent for it is not stored (the server decides). The hosted page hides it by itself. On your own site, wrap the field like this and the script hides it (a hidden field is not sent and its required does not block the form):

<div data-sitekit-field="detail" data-sitekit-show-if='{"field":"topic","op":"eq","value":"other"}'>
  <label>Details <textarea name="detail" required></textarea></label>
</div>

The form HTML and the AI prompt from the admin's "Embed" page include this wrapper.

Duplicate answers (dedupe)

Choose per form how a second answer from the same person is stopped.

dedupeSame person meansLimits
none (default)not stopped (only a retried request is)a second answer is a second row
email_verify Soonthe same email address (Gmail aliases count as one). Only answers confirmed through the emailed link countanother address is another person
browser Soonthe same browser (a cookie on the hosted page, a value the script keeps in localStorage on your site)another browser, a private window or clearing site data gets around it; a browser refusing storage is not stopped
phonethe same phone number (digits only, +81 read as 0)the number's owner is not verified (a hint, not proof)
  • Accepting answers with email_verify and browser is in preparation Soon (none and phone work today)
  • A second answer gets 409 already_submitted (the script shows "already answered")
  • email_verify: the visitor receives a confirmation email and confirms within 24 hours (until confirm_until for a form with capacity). Sending again before confirming re-sends the email (at most 3 an hour). An expired answer does not count, and the same address may apply again
  • The values compared are stored only as keyed hashes; the raw cookie value and the like are never kept

Capacity and a waiting list (capacity) Soon

An application form (kind: "application") can have a capacity.

KeyMeaning
limitPlaces (1–10,000). Required
waitlistWaiting list places (0–10,000, default 0)
by_field / limitsA capacity per choice (e.g. per date). by_field names a required select / radio; limits sets a different number per choice
opens_at / closes_atWhen applications open and close (ISO 8601 with a zone, e.g. 2026-11-01T10:00:00+09:00)
confirm_untilDeadline for the email confirmation (with email_verify)
cancel_untilUntil when an applicant may cancel
event_at / remind_before_hoursThe event's date and how many hours before it a reminder is sent (1–168)
  • A free place confirms the application; when full it joins the waiting list; when that is full too, 409 full. Outside the period, 409 not_open / closed
  • Simultaneous submissions never exceed the capacity
  • The answer is {"ok": true, "state": "confirmed"} or {"ok": true, "state": "waitlisted", "ahead": 2} (people waiting ahead). On the waiting list or awaiting confirmation, the script stays on the page and says so in the success panel instead of moving to the thank-you page (messages are Japanese for now)
  • Applicants always receive a receipt (state, receipt number and a cancel link), whatever the auto-reply setting. The link cancels until cancel_until
  • When a confirmed place is cancelled, the head of the waiting list is confirmed in the same step and emailed
  • The shop sees each application's state in the admin's "Submissions" (confirmed, waiting (#N), awaiting confirmation …) and can cancel it there ("Cancel this application"), with siteon submissions cancel or the MCP tool cancel_submission
  • The account owner is told once when a slot fills up. The hosted page can show the places left with page.show_remaining (not for a per-choice capacity)

Stats and CSV

  • Choices (select, radio, checkbox_group), ratings (rating, 1–5 by default) and NPS (nps, 0–10) are counted as answers arrive: admin "Forms → Stats & CSV", siteon forms stats <form>, MCP get_form_stats. NPS = % promoters (9–10) − % detractors (0–6)
  • CSV for the site owner only (admin, siteon forms export <form>): UTF-8 with a BOM, opens in Excel as is. Columns: id, time, status, assignee, application state, slot, version, each field, note count. Up to 50,000 rows at a time (split by period). Each export is logged
  • Deleting an answer does not take it out of the stats (the CSV lists only answers that remain)
  • A survey with no personal fields (no_personal_fields: true) cannot have email or phone fields, keeps no browser details or traffic source, and stores times to the hour. Whatever the answer text itself says is kept
  • emit_submission_id: false keeps the answer's id out of measurement (dataLayer and sitekit:submit)

Submission limits

Each plan has a monthly limit on form submissions (bookings count as form submissions). What happens when you reach the limit is described in Plans and limits.

This page is also available as Markdown. For AI agents, pass llms.txt (contents) or llms-full.txt (full text).