# 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.

URL: https://siteon.dev/docs/forms

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](https://siteon.dev/docs/script)).

```html
<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 `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](https://siteon.dev/docs/schema#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.

| 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`) (coming soon) | Full |
| Outside the application period (`not_open` / `closed`) (coming soon) | Outside the application period |

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

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

## Redirecting to another page after submission

```html
<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.

```html
<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.

```html
<!-- 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`.

```html
<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](https://siteon.dev/docs/webhooks)) |
| Logging to a Google spreadsheet (coming 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:

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:

| 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 |

```text
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.

```js
{
  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.

```html
<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

| 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](https://siteon.dev/docs/webhooks).

### 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.

```bash
curl -s -X POST -H 'content-type: application/json' \
  -d '{"name":"Test","email":"test@example.com","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](https://siteon.dev/docs/cli-mcp)).

## 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](https://siteon.dev/docs/script#recording-traffic-sources-utm)).

## 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` (coming 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` (coming 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 (coming 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 |

```json
{ "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):

```html
<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` (coming soon) | the same email address (Gmail aliases count as one). **Only answers confirmed through the emailed link count** | another address is another person |
| `browser` (coming 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_verify` and `browser` is in preparation (coming 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`) (coming 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, 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](https://siteon.dev/docs/plans#when-you-reach-a-limit).
