documentation menu

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.

read as markdown

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 has its own. Waiting lists, email signups and the visitor badge are on the 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 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 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 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:

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

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

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 as usual. A form that submits through fetch instead of a plain post needs the JSON response described below.

Control fields

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

NameWhat it does
_gotchaA 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.
_nextWhere 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.
altchaThe 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:

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

Inside properties, each field may use:

KeywordApplies toNotes
typeallOne of string, number, integer, boolean.
titleallThe label the dashboard shows for the field.
formatstringsemail or uri.
enumstringsThe allowed values — a dropdown. 1 to 100 options.
minLength / maxLengthstringsmaxLength cannot exceed 5000.
minimum / maximumnumbersInclusive bounds.
patternstringsGo RE2 syntax. No look-arounds and no backreferences — RE2 does not have them.
x-one9x-widgetstrings"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:

{ "ok": true }

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

{ "error": "…", "fields": { "email": "must be an email address" } }
StatusBodyMeaning
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.
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:

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

$('#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.

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

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

Limits

Forms per site20
Fields per submission50
Request body64 KiB
Characters per value5,000
Submissions a monthYour Forms plan’s, across all your sites — 250 on Forms Free. Over it they are kept but locked
Submissions per site500 per rolling 24 hours, or a tenth of your Forms plan’s month if that is more
Posts per visitor30 per hour, per visitor IP, counted across every site on the platform
Submissions keptAll 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:

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.

EndpointWhat 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}/formsBody {"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 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:

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