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 markdownWidgets 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.
- 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.
- 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. - 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.
- 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.
- 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’sresetevent 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:
- Define the form first — Widgets → Forms, turn it on, New form, add the fields — and copy the HTML the dashboard generates.
- Paste that HTML into the same chat, with a prompt like the sample below.
- 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.
| 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:
{
"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:
{ "ok": true }
and on failure, the status says which kind of failure it was:
{ "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. |
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_nextredirect, which is not what afetchwants. - Send form data, not JSON.
new FormData(form)(multipart) or URL-encoded (new URLSearchParams(new FormData(form)), or jQuery’sserialize()). A body withContent-Type: application/jsonis 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 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:
$ 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 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:
$ 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.