Forms
Forms collect submissions from your public website — contact forms, applications, quote requests — with validation on the server and an automation hook on every submission.
Defining a form
Create one under Forms with a name and fields. Each field has a name, a label, a type, and optionally required and (for choice types) options:
| Type | Behaviour on submit |
|---|---|
| text (default) | Stored as text |
email | Must be a valid address; lowercased |
number | Must be numeric |
date | Must parse as a date |
select, radio | Must be one of the declared options |
checkbox | Booleans — or, with options, a list of allowed values |
A form can be switched inactive at any time; an inactive form refuses submissions without being deleted.
The public submission endpoint
This is the one deliberately unauthenticated write in a project — it's what your site posts to:
POST /api/projects/{project}/forms/{form}/submissions
Content-Type: application/json
{ "email": "visitor@example.com", "message": "Hi there" }What protects it:
- Only declared fields are stored — arbitrary keys are dropped, nested objects rejected, so nothing unexpected enters your database.
- Every value is validated against its declared type, with a
422and per-field messages on failure. - Rate-limited per IP per form.
- Still workspace-scoped: the form must belong to a project of the workspace the request's domain resolves to.
Each stored submission records the data plus the submitter's IP and user-agent, and increments the form's submission counter.
await fetch(`${API}/forms/${FORM_ID}/submissions`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(Object.fromEntries(new FormData(formEl))),
});Reading submissions
The Submissions view (and GET on the same route, authenticated with a session or an API key holding forms.read) lists submissions newest-first with pagination.
Doing something with them
The point of a form is rarely storage — attach an event with the form submission trigger. The submitted fields become the flow's trigger payload, so {{email}} and {{message}} are available to templates: auto-reply to the visitor, notify your team, file a record, call a webhook, or all four. A failing automation never rejects the visitor's submission — it lands in the event's execution log instead.