Forms server
This page covers what happens on the server when someone submits a form, and what you can configure there: access, rate limits, secrets, seeding, staff access and MCP tools. It is for app developers and module authors. Set up the module first: Overview.
Submit a form
POST /api/forms/:slug/submit takes JSON { answers, locale }. With files, send multipart/form-data instead: an
answers JSON part, a locale part and one file:<key> part per file. locale is a language tag such as en or
da-DK, and defaults to en.
The answer is { ok: true, id }. id is null when the form has storage: 'none'. Build the path with formsApi
from @frigear-nu/forms/api:
import { formsApi } from '@frigear-nu/forms/api'
try {
await $fetch(formsApi.submit('contact'), { method: 'POST', body: { answers, locale: 'en' } })
}
catch (error) {
// 400: one issue per rejected field
const issues = (error as { data?: { data?: { issues?: { path: (string | number)[], message: string }[] } } }).data?.data?.issues ?? []
}
Every error has its message in statusMessage and a snake_case code in data.code.
| Status | Code | Cause |
|---|---|---|
400 | validation_failed | A malformed body, rejected answers or a failed captcha. Each issue is { path, message }, with the field key in path[0]. |
401 | unauthorized | The form's access is not met. data.access is true. |
403 | form_closed | The form is outside its open period: data: { closed: true, opensAt }. |
404 | not_found | The form is unknown or not published. |
429 | rate_limited | Too many submissions. The Retry-After header says when to try again. |
500 | not_configured | Captcha has no secret key. |
502 | captcha_unavailable, delivery_failed | The captcha provider is unreachable, or delivery failed for a form with storage: 'none'. |
GET /api/forms/:slug returns the published definition without delivery, so recipients are never public.
Decide who may submit
A form's access.auth decides who may read and submit it. Its secrets are never part of the definition.
auth | The submitter proves access with |
|---|---|
none | Nothing. |
user | A signed-in user: a session, or a token with the forms:submit scope. |
token | A form access token in Authorization: Bearer <token> or x-form-token: <token>. |
password | The form's shared password in the x-form-password header (FORM_PASSWORD_HEADER). |
- Form access tokens start with
ffk_. Staff create them per form in the admin area, and each is shown once. Only a SHA-256 hash is stored, with a display prefix,lastUsedAtandrevokedAt. - The password is stored as a scrypt hash. A form with
auth: 'password'and no password set stays closed. availability: 'api'only removes the public page. The API still serves the form.
Rate limiting
Submissions are counted per client IP and form in key-value storage, in a fixed window: settings.rateLimit per
settings.rateLimitWindowSeconds. Over the limit the answer is 429 with Retry-After. Wrong password attempts have
their own counter, with the same limit and window.
The count is read and then written, so it is not exact under concurrency. That is enough against casual abuse.
Per-form secrets
Some secrets belong to one form, such as its own captcha secret key. They live in the form_secrets table, encrypted
on the server with AES-256-GCM. They never end up in definition versions, submission snapshots, deliveries, imports or
the public API. Staff can write them, but nobody can read them back.
The allowed keys are captcha.turnstile.secretKey, captcha.recaptcha.secretKey and captcha.hcaptcha.secretKey.
encryptionKey, else NUXT_ENCRYPTION_KEY, else the session password. Set a dedicated key in production.
Changing the key makes every stored secret unreadable: the admin API reports unreadable: true, and captcha checks fall
back to the configured key until staff enter the secrets again.Seeding and import
Forms never inserts anything by itself. There are two ways to load definitions, and both insert only slugs that do not exist yet. Neither overwrites a form.
- Seed.
seed: { enabled: true, forms: [...] }inserts at startup. Build a form shows it. - Import. The Import JSON button in the admin area calls
POST /api/admin/forms/importwith{ forms: [{ slug, name, definition }] }. It answers{ inserted, skipped }.
Let staff manage forms
Every staff route calls authorize(event, permission) from Authorization:
forms:manage for most routes, and forms:credentials for form access tokens, passwords and secrets. Who holds them is
a matter of roles, as in Quick setup.
A route that loads a form authorizes twice. The first call fails with 401 or 403 before anything is looked up. The
second call passes the record, so a policy can refine the answer:
export default defineEventHandler(async (event) => {
await authorize(event, 'forms:manage') // 401 or 403 before any 404
const { id } = await getValidatedRouterParams(event, idParams().parse)
const form = await loadForm(id) // then 404
if (!form) throw notFound('Form not found.')
await authorize(event, 'forms:manage', form) // record-level policies
// ...
})
Changing form access tokens, password or secrets needs forms:credentials, which is session only: a token gets
403 session_required, whatever its scopes.
Permissions and scopes
| Permission | Allows |
|---|---|
forms:manage | Managing forms and submissions as staff. |
forms:credentials | Form access tokens, passwords and secrets. Session only. |
forms:submit | Submitting forms as the user, and reading forms that need a signed-in user. |
forms:pii | Sensitive (MCP only): submission answers and file names in MCP tool output. The admin HTTP routes for submissions need forms:manage only. Grant it by name. |
The named scope forms expands to forms:submit. The full table, with defaults, is under
Permissions.
MCP tools
With settings.mcp: true, Forms adds these tools to the MCP endpoint.
| Tool | Permission | Purpose |
|---|---|---|
list-forms, get-form, submit-form | forms:submit | Browse published forms and submit as the user. |
list-all-forms, get-form-definition, create-form, update-form | forms:manage | Manage forms. Delivery settings are never returned or changed. |
list-submissions, get-submission | forms:manage | Submissions. Answers, submitter and file names need forms:pii too. |
submit-form always submits as the caller, and refuses forms that need a form access token or a password.