# Widgets

Drop-in features for a static site — forms and comments. Define the fields, paste the generated HTML, read submissions in the dashboard. No backend to run.

Widgets are the parts of a site that normally need a backend: a contact form, a
waiting list, a comment thread. Pages serves them from your own domain, so you
keep a static site and still collect what a static site cannot.

Every widget lives under the site's **Widgets** tab in the dashboard, each with
its own switch and its own page. **This page is about forms**;
[comments](/docs/comments) has its own. Waiting lists, email signups and the
visitor badge are on the [roadmap](/docs/roadmap).

A static site has no backend to post a form to. Pages gives every site one:
define a form in the dashboard, paste the HTML it generates, and submissions
land in the dashboard. Nothing is added to your build, and no third-party
endpoint sees your visitors.

Every plan includes a Forms plan: **Forms Free, 250 submissions a month**, on
Free and Starter; Forms Plus (2,500) on Pro; Forms Pro (25,000) on Business. A
bigger one is an [upgrade](/pricing) that ends and renews with your plan. The
count is submissions a month across all your sites, and **over it nothing is
lost**: submissions are still accepted and kept, but **locked** — the dashboard
and the API hide what they say until you upgrade or the month turns. Your
visitors never see the limit.

Forms are **off until you turn them on.** A site that has never switched them on
refuses every post, which is the right default for the large majority of
sites that will never have one.

## Setting one up

In the dashboard, open the site, go to **Widgets** and open **Forms**.

1. **Turn on "Accept form submissions".** Off by default. While it is off every
   post is refused with a 404. Turning it back off later keeps your forms and
   the submissions you already have — it only stops new ones. It does **not**
   change your site: the forms on your pages still render and the captcha widget
   still works, so a visitor fills one in and the submit fails. Take the form
   down or show a notice.
2. **Define a form.** "New form" — give it a name: lowercase letters, digits,
   `_` and `-`, up to 64 characters. A post to a name you have not defined is
   refused, so a typo fails loudly rather than collecting into nowhere. A site
   can have up to 20 forms.
3. **Add the fields.** Use the builder — Text, Long text, Email, URL, Number,
   Checkbox, Dropdown, each optionally required — or write the
   [JSON Schema](#defining-fields-as-json-schema) directly. The two are the same
   thing; the builder writes the schema for you.
4. **Leave "Require a captcha" on** unless you have a reason not to. It is on by
   default. It raises the cost of automated submissions; the [limits](#limits)
   below are what actually bounds how much anyone can send.
5. **Copy the generated HTML into your site and deploy.** The dashboard writes
   the markup for the fields you defined, including the captcha widget.

That is the whole setup. There is no key to configure, no origin to allow-list,
and nothing to install — the endpoint is already part of your site.

The Forms page itself is only the switch and the list. **Each form has its own
page** — click one to see its submissions as a table, a column per field in the
order you defined them, with "Load more" for older ones. A field you removed
from the form later keeps its column, marked *(removed)*, so an old submission
still reads correctly instead of quietly losing a value.

## The HTML

A form posts to a path on your own site:

```html
<form action="/_one9x/forms/contact" method="POST">
  <label>Your email <input type="email" name="email" required /></label>
  <label>Message <textarea name="message" required></textarea></label>
  <button type="submit">Send</button>
</form>
```

With the captcha on, the widget goes inside the form and the module goes
anywhere on the page:

```html
<script type="module" src="/_one9x/altcha.js"></script>

<form action="/_one9x/forms/contact" method="POST">
  <label>Your email <input type="email" name="email" required /></label>
  <label>Message <textarea name="message" required></textarea></label>
  <altcha-widget auto="onsubmit" challenge="/_one9x/captcha/challenge"></altcha-widget>
  <button type="submit">Send</button>
</form>
```

The captcha is [ALTCHA](https://altcha.org), a proof-of-work check: the
visitor's browser does a small amount of arithmetic instead of identifying a
crosswalk. It is served from your own domain, so no third party is contacted and
there is nothing to disclose in a privacy policy that was not already true.

`auto="onsubmit"` is what makes it invisible. The widget shows nothing at all
until Send is pressed; then it holds the submit, fetches and solves a challenge
— well under a second — and sends the form itself. There is no box to tick and,
because the challenge is fetched at Send, nothing sitting around waiting to
expire.

The widget also works on its own, without a One9x form, if you want the same
check in front of something else.

### What the captcha does and does not do

The challenge is valid for 10 minutes after it is fetched. With
`auto="onsubmit"` it is fetched as the form is sent, so in the normal flow
nothing gets near that; a stale or missing one is the `400` with
`fields.altcha`.

Two things are worth knowing rather than guessing at:

- **`form.reset()` re-arms the widget.** It listens for the form's `reset`
  event and clears its solved state, so the next send solves afresh. That only
  works when the `<altcha-widget>` is **inside** the form being reset.
- **The server does not track spent challenges.** A payload replayed inside its
  ten minutes is accepted again. The captcha is a cost, not an identity check,
  and the [rate limits](#limits) are the protection that does not depend on the
  client.

## With an AI tool

If ChatGPT, Claude, Lovable, Bolt or v0 built your site, it probably already has
a contact form — one that posts nowhere, or tells you to "connect it to a
database or email service". You don't need either. Let the tool wire it to your
site's form service instead:

1. **Define the form first** — **Widgets → Forms**, turn it on, **New form**, add the
   fields — and copy the HTML the dashboard generates.
2. **Paste that HTML into the same chat**, with a prompt like the sample below.
3. **Upload the new version** and make it live. Submit the form once yourself
   and check it appears under **Widgets → Forms**.

Starting from scratch instead? [The One9x prompt](/docs/ai-tools/prompt) has the
tool build the form this way from the start and list its fields for you.

A sample prompt — adjust the wording to your site:

```text
Replace the contact form on my site with the form below. Keep it where the
current form is and style it to match the rest of the page, but do not rename,
add or remove any name="" fields, and keep the _gotcha input, the altcha-widget
element and the altcha.js script tag exactly as they are. Do not add any
JavaScript that submits the form — it posts on its own.

[paste the HTML from the dashboard here]
```

The field names are the contract: a post with a field the dashboard does not
know is refused, so if the tool "improves" a name — `phone` to `phoneNumber` —
submissions start failing. Copying the generated HTML, rather than letting the
tool invent the form, is what keeps the two in step.

If your site is a React or Vite project, the same prompt works: the tool puts
the form in a component, and you [build it](/docs/frameworks) as usual. A form
that submits through `fetch` instead of a plain post needs the JSON response
described [below](#what-the-visitor-sees).

## Control fields

Three field names are reserved. None of them is ever stored with the submission.

| Name | What it does |
| --- | --- |
| `_gotcha` | A honeypot, already in the HTML the dashboard generates as `<input name="_gotcha" style="display:none" tabindex="-1" autocomplete="off">`. A person never sees it; a bot fills it in, and that submission is silently dropped — the response looks like success, because telling a bot it failed only teaches it to try again. Leave it alone. If you hand-write the form, include that exact field, keep it hidden, and never pre-fill it. |
| `_next` | Where to send the visitor after a successful post. Must be a path on the same site, like `/thanks`. Anything else — another host, a full URL — is ignored and the default success page is shown instead. |
| `altcha` | The captcha's proof, added by the widget. You never write this yourself. |

## Defining fields as JSON Schema

The builder covers the common cases; the schema editor is there when you want
something it does not express, like a `pattern`. The dialect is a **strict
subset** of JSON Schema: anything not listed here is refused when you save,
rather than accepted and quietly ignored.

The top level is always exactly this shape:

```json
{
  "type": "object",
  "properties": { },
  "required": [],
  "additionalProperties": false
}
```

Inside `properties`, each field may use:

| Keyword | Applies to | Notes |
| --- | --- | --- |
| `type` | all | One of `string`, `number`, `integer`, `boolean`. |
| `title` | all | The label the dashboard shows for the field. |
| `format` | strings | `email` or `uri`. |
| `enum` | strings | The allowed values — a dropdown. 1 to 100 options. |
| `minLength` / `maxLength` | strings | `maxLength` cannot exceed 5000. |
| `minimum` / `maximum` | numbers | Inclusive bounds. |
| `pattern` | strings | Go RE2 syntax. No look-arounds and no backreferences — RE2 does not have them. |
| `x-one9x-widget` | strings | `"textarea"`. A display hint for the dashboard; it changes nothing about validation. |

Fields are **flat**. There are no arrays, no multi-select, no nested objects and
no `$ref`. If you need several values for one thing, use several fields.

## What gets accepted

A submission is validated against the schema before anything is stored, and a
failure stores nothing at all — there is no partial save to clean up later.

A post is refused if it contains a field you did not define, a value of the
wrong type, a missing required field, or the same field twice.

Values are stored **typed**: a number comes back as a number and a checkbox as
`true` or `false`, not as the strings the browser happened to send. A checked
checkbox is `true`; an unchecked one is absent. Marking a checkbox `required`
therefore means *it must be checked*, which is exactly what you want for a
consent box and a surprise if you expected "must be present".

## What the visitor sees

**A plain form post** gets an HTML response: `200` and a success page reading
"Thanks — your submission was received.", or a `303` redirect to `_next` when
that is a same-site path. On a validation failure it gets a page listing each
field that was wrong.

**A `fetch` that sends `Accept: application/json`** gets JSON instead, so you can
keep the visitor on the page — `200` with:

```json
{ "ok": true }
```

and on failure, the status says which kind of failure it was:

```json
{ "error": "…", "fields": { "email": "must be an email address" } }
```

| Status | Body | Meaning |
| --- | --- | --- |
| `400` | `"some fields were not accepted"` | `fields` maps each bad field name to its message. |
| `400` | `"the verification is missing or has expired — complete it again"` | `fields.altcha`. The challenge expired or was not sent — see [what the captcha does](#what-the-captcha-does-and-does-not-do). |
| `404` | `"no such form"` | Forms are off for this site, or there is no form by that name. |
| `429` | `"this form is not accepting submissions right now"` | The site's daily cap. The per-visitor limit also answers `429`, with a `Retry-After` header. |

## Submitting without leaving the page

The plain form above is the one that should stay in your HTML: it works with no
JavaScript, and it is the version that keeps working when a script fails to
load. Everything here is progressive enhancement on top of it — same form, same
endpoint, same fields.

Three things make it a JSON exchange rather than a page navigation:

- **Send `Accept: application/json`.** Without that header you get the HTML
  thank-you page or the `_next` redirect, which is not what a `fetch` wants.
- **Send form data, not JSON.** `new FormData(form)` (multipart) or URL-encoded
  (`new URLSearchParams(new FormData(form))`, or jQuery's `serialize()`). A body
  with `Content-Type: application/json` is **not** accepted.
- **Post from the site itself.** The endpoint is same-origin only — there are no
  CORS headers, and a cross-origin preflight gets a `404`.

`FormData` is doing quiet work here: it picks up the `_gotcha` honeypot and the
`altcha` field the captcha widget writes once solved, so neither needs handling.
That only holds if the `<altcha-widget>` is **inside** the `<form>`. `_next` is
ignored for a JSON request — the page decides what to show.

One line deserves explaining before you read it, because it looks removable and
is not. With `auto="onsubmit"` the **first** Send belongs to the widget: it
cancels that submit, solves a challenge, and re-fires the event. So the handler
runs twice, and the first time there is no solved payload yet. Posting then
would send the form without a captcha and collect a `400`.

The guard returns early on that first pass — crucially **before**
`event.preventDefault()`, so the widget is still free to cancel the submit
itself. On the second pass the `altcha` field is filled, the guard falls
through, and exactly one request goes out. It works whichever listener happens
to be registered first.

Nothing else is needed, and that is the part worth trusting rather than
improving on. While it solves, the widget shows *"Verifying…"* and marks its own
checkbox unchecked and required, so the browser holds any further press until it
finishes — pressing Send three times in a row still sends exactly one. If the
challenge cannot be fetched at all, because the visitor is offline or the portal
is unreachable, the widget says *"Verification failed. Try again later."*,
sends nothing, and the next Send retries.

Two consequences for the example: the `catch` branch never runs for a captcha
failure, because no request was made — the visitor sees the widget's own message
instead. And a handler that tries to add its own "Checking…" line or call the
widget's reset on error can deadlock the page; the guard as written handles every
one of these cases, so leave it alone.

Put together, that is one listener on the form you already have:

```html
<form id="contact" action="/_one9x/forms/contact" method="POST">
  <input name="email" type="email" required>
  <textarea name="message" required></textarea>
  <input name="_gotcha" style="display:none" tabindex="-1" autocomplete="off">
  <altcha-widget auto="onsubmit" challenge="/_one9x/captcha/challenge"></altcha-widget>
  <button>Send</button>
  <p id="status" role="status"></p>
</form>
<script type="module" src="/_one9x/altcha.js"></script>
<script>
  const form = document.getElementById('contact');
  const status = document.getElementById('status');
  form.addEventListener('submit', async (event) => {
    // The first Send belongs to the widget: let it through so it can solve.
    if (form.querySelector('altcha-widget') && !new FormData(form).get('altcha')) return;
    event.preventDefault();
    status.textContent = 'Sending…';
    try {
      const res = await fetch(form.action, {
        method: 'POST',
        body: new FormData(form),
        headers: { Accept: 'application/json' },
      });
      const body = await res.json();
      if (res.ok) {
        form.reset();
        status.textContent = 'Thanks — we got your message.';
      } else if (body.fields) {
        status.textContent = Object.entries(body.fields)
          .map(([field, msg]) => `${field} ${msg}`)
          .join('; ');
      } else {
        status.textContent = body.error;
      }
    } catch {
      status.textContent = 'Could not send — check your connection and try again.';
    }
  });
</script>
```

The same thing with jQuery, using `serialize()` for a URL-encoded body:

```js
$('#contact').on('submit', function (e) {
  if (this.querySelector('altcha-widget') && !new FormData(this).get('altcha')) return;
  e.preventDefault();
  $.ajax({
    url: this.action,
    method: 'POST',
    data: $(this).serialize(),
    headers: { Accept: 'application/json' },
  })
    .done(() => {
      this.reset();
      $('#status').text('Thanks — we got your message.');
    })
    .fail((xhr) => {
      const b = xhr.responseJSON || {};
      $('#status').text(
        b.fields
          ? Object.entries(b.fields).map(([f, m]) => f + ' ' + m).join('; ')
          : b.error || 'Could not send.'
      );
    });
});
```

Two details worth keeping when you adapt these. The `role="status"` on the
message element is what makes the result reach a screen reader — without it the
text changes silently. And the `catch` branch matters as much as the error
branch: a POST that never arrives leaves the button looking ready and the
visitor with no idea whether they sent anything. (It covers the POST only — a
captcha that fails never gets that far.)

The `form.reset()` on success is also what re-arms the captcha, so a second Send
from the same page view solves afresh — see
[what the captcha does](#what-the-captcha-does-and-does-not-do).

The `fields` object is keyed by field name, so mapping each message onto the
matching input is usually better than the single line above:

```js
for (const [name, message] of Object.entries(body.fields)) {
  form.elements[name]?.setCustomValidity(message);
}
form.reportValidity();
```

## Limits

| | |
| --- | --- |
| Forms per site | 20 |
| Fields per submission | 50 |
| Request body | 64 KiB |
| Characters per value | 5,000 |
| Submissions a month | Your Forms plan's, across all your sites — 250 on Forms Free. Over it they are kept but locked |
| Submissions per site | 500 per rolling 24 hours, or a tenth of your Forms plan's month if that is more |
| Posts per visitor | 30 per hour, per visitor IP, counted across every site on the platform |
| Submissions kept | All of this month's; from before it, the newest 1,000 per site (or your Forms plan's month, if that is more) |

The two caps do different jobs, and it is worth not confusing them. The 30 per
hour is a limit on **one visitor** — it is platform-wide per IP, not per site or
per account, and a normal person never reaches it. The 500 per 24 hours is the
limit on **your site's volume** — a flood guard, not your plan. Your plan is the
monthly count, and going over it locks submissions rather than refusing them.

A suspended site accepts nothing.

## Automate with the API

Everything the Forms page does is an HTTP API, and it is the same one the
dashboard itself calls. Use it to define forms from a script, or to pull
submissions into something that is not a browser tab.

Authenticate with an API token from the dashboard's Tokens page:

```console title="list a site's forms"
$ curl -H "Authorization: Bearer one9x_tok_..." https://portal.one9x.com/api/pages/sites/42/forms
```

`{id}` is the numeric site id throughout.

| Endpoint | What it does |
| --- | --- |
| `GET /api/pages/sites/{id}/forms` | `{"enabled": bool, "forms": [{"name", "schema", "captcha", "updated_at"}], "usage": {"used", "quota", "plan", "resets_at", "locked"}}` — `usage` is the account's month |
| `PUT /api/pages/sites/{id}/forms` | Body `{"enabled": true}` — the site switch |
| `PUT /api/pages/sites/{id}/forms/{name}` | Body `{"schema": {…}, "captcha": true}` — create or replace. `captcha` defaults to `true` when omitted |
| `DELETE /api/pages/sites/{id}/forms/{name}` | `204`. Its submissions are kept, but the dashboard has no page to show them on any more — the API is the only way back to them |
| `GET /api/pages/sites/{id}/submissions` | `{"submissions": [{"id", "form", "data", "created_at", "locked"}], "next_cursor", "has_more", "usage"}`. A submission over this month's quota has `"locked": true` and `"data": null` until you upgrade or the month turns |
| `DELETE /api/pages/sites/{id}/submissions/{sid}` | `204` |

Defining a form returns `400` with the reason if the schema uses anything the
[subset](#defining-fields-as-json-schema) does not allow, and `409` once the site
has 20 forms.

### Reading submissions

`GET …/submissions` takes `form`, `order`, `limit` and `cursor`. `order` is
`desc` (newest first) by default; `limit` is 1–200, default 50; `form` narrows to
one form. A bad cursor, limit, order or form name is a `400`.

Pass a response's `next_cursor` back as `cursor` to get the next page. The cursor
is opaque and tied to the order it was issued for — sending it with the other
order is a `400`.

**To sync only what is new**, page with `order=asc`, keep the last `next_cursor`,
and send it again later:

```console title="poll for new submissions"
$ curl -H "Authorization: Bearer one9x_tok_..." "https://portal.one9x.com/api/pages/sites/42/submissions?order=asc&cursor=$SAVED"
```

`next_cursor` always comes back, and on an empty page it is the cursor you sent,
so a poller never loses its place. Paging never skips or repeats a row, even
while old submissions are being pruned underneath it.

The pruning is the thing to design around: this month is kept whole, but from
before it only the newest 1,000 per site (or your Forms plan's month, if that is
more) are kept, so poll at least monthly. A locked submission comes back with `"data": null` — poll it
again after an upgrade or the 1st and it has its data.

### API rate limits

These are **per account**, and separate from the per-visitor limits on posting a
form. Each endpoint has its own allowance.

| | |
| --- | --- |
| Reads (either `GET`) | 120 per minute, each |
| Writes (`PUT`, `DELETE`) | 30 per minute, each |

Over the limit is a `429` with a `Retry-After` header in seconds. Honour it and
back off rather than retrying immediately.

## `/_one9x/` is reserved

Everything under `/_one9x/` is answered by One9x rather than by your files — on
the platform subdomain, on preview URLs and on your custom domains alike. That
is what makes the form endpoint work on whatever host your site is being served
from, with no configuration.

The practical consequence is that files in a **top-level** `_one9x/` folder are
left out of every deploy. The deploy itself succeeds: the drop zone says so in
its summary ("N dotfiles or `_one9x/` paths left out") and the CLI drops them
silently, the same way it treats dotfiles. Rename the folder if your generator
produces one.

Only the top level is reserved. A `_one9x` folder further down — say
`docs/_one9x/x.html` — deploys and serves normally.

The one case that fails loudly is a client that builds its own manifest rather
than using the CLI: that gets a `400` naming the path, because a tool that
constructed the manifest deliberately should be told, not quietly edited.

## What this is not

- **There are no email notifications.** Submissions are read and deleted on the
  form's own page in the dashboard. If you need a message when one arrives, poll
  the [API](#automate-with-the-api).
- **File uploads are not stored.** A form with a file input will post, but the
  file is discarded.
- **It is not a database.** This month's submissions are kept whole; from before
  it, the newest 1,000 per site (or your Forms plan's month, if that is more).
  This is a contact form, not durable storage.