Intake Forms
An intake form exposes a chosen set of fields on a single table and creates a record when someone submits it. Forms come in three visibility tiers — public, internal, and restricted — that control who can discover and submit them. A public form is the unauthenticated, token-based endpoint described below; internal and restricted forms require a signed-in caller and submit through an authenticated route.
The defining property of an intake form is form-as-grant authorization: a caller can open a form and create the target record without holding create permission on the target table. The form itself authorizes the create. This lets a low-privilege requester (or an anonymous visitor) file a record into a table they could never write to directly.
Visibility tiers
Section titled “Visibility tiers”A form’s visibility column (default public) selects who may submit it and through which route:
| Tier | Who can submit | Route | Token? |
|---|---|---|---|
public | Anyone, unauthenticated | POST /api/f/{token} | Yes — minted on create |
internal | Any authenticated user with read access to the form’s application | POST /api/forms/{id}/submit | No |
restricted | Authenticated user holding the intake_form grant for that specific form | POST /api/forms/{id}/submit | No |
Only public forms mint a /f token. Internal and restricted forms have no token, so they are unreachable via the unauthenticated route — the authenticated route fetches a form by its id. Legacy forms with no explicit visibility behave as public.
System tables (users, roles, permissions, audit logs, etc.) can never be the target of an intake form, on any tier.
Public tier
Section titled “Public tier”Public forms collect data from unauthenticated, external users — contact forms, registration, surveys, public data collection. Records are created by a no-role form-intake principal authorized solely by the form’s grant.
Creating a public form
Section titled “Creating a public form”POST /api/apps/system/tables/submissions/records{ "name": "contact_form", "display_name": "Contact us", "table_id": "<table-uuid>", "application": "<app-name>", "visibility": "public", "active": true, "fields": [ { "field_name": "name", "required": true }, { "field_name": "email", "required": true }, { "field_name": "message" } ]}This creates a public endpoint at /api/f/{token}. A generated token, not the form name, is what external callers use.
Reading the form schema
Section titled “Reading the form schema”A front end can fetch the form’s public schema (display name, fields, sections, honeypot field name, and CAPTCHA info) to render it:
GET /api/f/{token}Submitting data
Section titled “Submitting data”The submit endpoint accepts three content types:
JSON (SPAs / API consumers):
POST /api/f/{token}Content-Type: application/json
{ "name": "Jane Smith", "email": "jane@example.com", "message": "Interested in your product"}Returns HTTP 201 with a JSON response containing success, message, submission_id, and redirect_url (if configured).
URL-encoded form (plain HTML):
<form action="https://api.oikapi.com/api/f/{token}" method="POST"> <input name="name" required> <input name="email" type="email" required> <textarea name="message"></textarea> <button type="submit">Send</button></form>For URL-encoded form posts, the endpoint returns a 303 redirect to the configured redirect_url. If no redirect URL is set, the endpoint returns HTTP 200 with a plain-text success message instead.
Multipart form (file uploads):
Submit multipart/form-data to attach files. Uploaded files are stored and their IDs are written to the matching record fields. The form can restrict accepted file types and maximum file size.
Bot protection
Section titled “Bot protection”Honeypot
Section titled “Honeypot”Each public form includes a honeypot field (its name is exposed in the schema). If a bot fills it, the submission is silently accepted-and-discarded — the caller sees a success response but no record is created.
CAPTCHA
Section titled “CAPTCHA”Public forms can require a CAPTCHA (Cloudflare Turnstile). When enabled, the schema response includes the provider and site key, and submissions must include a valid captcha_token.
Rate limiting
Section titled “Rate limiting”Each public form enforces a configurable per-minute rate limit to prevent abuse.
Per-form allowed origins control which domains may submit data from the browser.
Internal and restricted tiers
Section titled “Internal and restricted tiers”Internal and restricted forms are submitted by signed-in users through a single authenticated route:
POST /api/forms/{id}/submitContent-Type: application/json
{ "summary": "Laptop won't boot", "priority": "high"}The form is addressed by its id (not a token). The request body carries the field values; the response shape matches the public route — HTTP 201 with success, message, submission_id, and redirect_url (if configured). Honeypot handling applies; CAPTCHA does not (it is a public-form concern).
Authorization depends on the form’s tier:
- public — any authenticated user may submit (a public form is a superset).
- internal — the caller must have read access to the form’s application.
- restricted — the caller must hold the
intake_formgrant for that specific form, keyed on the form’s name. Because the grant references the stable form name (not the install-assigned id), a packaged application can ship a restricted form together with a declarative access grant.
Inactive or expired forms are unreachable on this route, the same as on the token route.
Form-as-grant authorization
Section titled “Form-as-grant authorization”Regardless of tier, the record is created server-side by a no-role form-intake principal whose only authority is this form’s grant. The submitter does not need create permission on the target table — the form authorizes the create. When a signed-in user submits, the resulting record is attributed to them (created_by); on the public route there is no submitter to attribute.
Discovery catalog
Section titled “Discovery catalog”An authenticated client lists the forms a caller may open in an application — grouped by category — through the discovery endpoint:
GET /api/apps/{app}/formsThe catalog applies the per-tier visibility rules so it never leaks a form the caller cannot open: public forms are always included, internal forms require application read access, and restricted forms require the matching intake_form grant. Each entry is a lean summary — the token, raw field configs, and settings are deliberately omitted.
{ "data": [ { "category_id": "…", "name": "Hardware", "icon": "Laptop", "sort_order": 10, "forms": [ { "id": "…", "name": "laptop_request", "display_name": "Request a laptop", "summary": "Order a standard-issue laptop", "icon": "Laptop", "visibility": "restricted", "requires_approval": true, "fulfillment_estimate": "3–5 business days", "sort_order": 0 } ] } ]}Groups are ordered by sort_order then name, with an Uncategorized group (its category_id omitted) sorting last. Forms within a group are ordered the same way.
Catalog metadata and categories
Section titled “Catalog metadata and categories”To render the catalog, a form carries optional presentation metadata:
category— a reference to a global form category.icon— a phosphor-icons name.summary— a short description shown in catalog tiles.fulfillment_estimate— a human-readable estimate of how long fulfillment takes.requires_approval— whether submissions need approval before fulfillment.sort_order— display ordering within a category.
Categories live in a global registry (system table form_categories) with a name and a stable slug used as the upsert key for package installs. Each category also has an icon and sort_order.
Frontend contract
Section titled “Frontend contract”An authenticated client submits a form via POST /api/forms/{id}/submit (for example, a useSubmitForm(formId) hook) — not the permission-gated record-create API. Routing through the form route is what lets a low-privilege requester open a form and create the target record without holding write access to its table. Use the discovery catalog (GET /api/apps/{app}/forms) to populate a request portal, then submit the chosen form by id.
Hidden-field defaults
Section titled “Hidden-field defaults”A form can include hidden fields with a default_value. These are resolved server-side on submit, so they cannot be spoofed by the caller. Two expression tokens are supported:
$me.person— resolves to the authenticated submitter’s linked person record. On the public route (no submitter), it resolves to nothing.$form.id— resolves to the form’s own id, useful for a self-reference back from the created record to the form that produced it.
Multi-step forms
Section titled “Multi-step forms”Fields can be grouped into named sections with optional display conditions, letting a front end render multi-step or conditional forms from the schema.
Business rules
Section titled “Business rules”Submissions fire the form_submission trigger, letting you run validation, send notifications, or start workflows after data is collected. See Business Rules.
Every record created through a form records its creation source as form:<form_name> for tracking in audit logs.
Expiry
Section titled “Expiry”A form can be given an expiry time, after which it stops accepting submissions, and can be deactivated at any time. Both checks apply to every tier and route.
Configuration
Section titled “Configuration”Enable the feature in server configuration:
submissions: enabled: true captcha: provider: turnstile site_key: "<turnstile-site-key>" secret_key: "<turnstile-secret-key>"