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
| HTML | schema.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
namethat isn't in schema.json doesn't cause an error, but it isn't saved either - An empty
requiredfield returnsinvalid_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"andautocomplete="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.
| Situation | Message 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 connection | Could not send. Please check your connection |
| Anything else | Could 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) Soon | Full |
Outside the application period (not_open / closed) Soon | Outside 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
multipleor 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".
| Notification | What you set |
|---|---|
| Email to the business | Recipient email addresses (more than one allowed) |
| Slack | An 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 Soon | Connect 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:
- The form's "Thank-you email" setting is on in the dashboard
- 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:
| Variable | Value |
|---|---|
{{name}}, {{email}}, and so on | The 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_idset tonull). 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
- 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 asForm Key(the variable below) equalscontact - Variables — Type "Data Layer Variable". Create
form(named e.g.Form Key) and, for deduplication,sitekit_submission_id(named e.g.Submission ID) - Tags — Use trigger 1 as the firing trigger of the tags you want to send (examples below)
Example: a GA4 key event
| Setting | Value |
|---|---|
| Tag type | Google Analytics: GA4 Event |
| Measurement ID | G-XXXXXXX (same as your GA4 configuration tag) |
| Event name | generate_lead (GA4's recommended event name; any name works) |
| Event parameters | form_key = {{Form Key}} |
| Firing trigger | The 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)
| Setting | Value |
|---|---|
| Tag type | Google Ads Conversion Tracking |
| Conversion ID / label | Shown 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 trigger | The 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.
| Template | What it asks |
|---|---|
contact | Enquiry (name, email, phone, message) |
survey Soon | Survey: a 1–5 rating, NPS, multiple choice and a comment. Set up as a survey with no personal fields (see "Stats and CSV") |
application Soon | Application with a capacity per date and a waiting list, confirmed by email (see "Capacity" and "Duplicate answers") |
brochure | Brochure request (company, contact, which documents) |
job | Job 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.
op | Refers to | Shown when |
|---|---|---|
eq / neq | select, radio | the value is / is not value |
in | select, radio, checkbox_group | any chosen value is in value (an array) |
not_empty | select, radio, checkbox_group | anything is chosen |
checked | checkbox (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.
dedupe | Same person means | Limits |
|---|---|---|
none (default) | not stopped (only a retried request is) | a second answer is a second row |
email_verify Soon | the same email address (Gmail aliases count as one). Only answers confirmed through the emailed link count | another address is another person |
browser Soon | the 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 |
phone | the same phone number (digits only, +81 read as 0) | the number's owner is not verified (a hint, not proof) |
- Accepting answers with
email_verifyandbrowseris in preparation Soon (noneandphonework 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 (untilconfirm_untilfor 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.
| Key | Meaning |
|---|---|
limit | Places (1–10,000). Required |
waitlist | Waiting list places (0–10,000, default 0) |
by_field / limits | A capacity per choice (e.g. per date). by_field names a required select / radio; limits sets a different number per choice |
opens_at / closes_at | When applications open and close (ISO 8601 with a zone, e.g. 2026-11-01T10:00:00+09:00) |
confirm_until | Deadline for the email confirmation (with email_verify) |
cancel_until | Until when an applicant may cancel |
event_at / remind_before_hours | The 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, 409not_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 cancelor the MCP toolcancel_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>, MCPget_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: falsekeeps the answer's id out of measurement (dataLayer andsitekit: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.