Forms

Forms server

Submit forms from code, choose who may answer them, and seed forms, keep their secrets and give staff access.

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.

StatusCodeCause
400validation_failedA malformed body, rejected answers or a failed captcha. Each issue is { path, message }, with the field key in path[0].
401unauthorizedThe form's access is not met. data.access is true.
403form_closedThe form is outside its open period: data: { closed: true, opensAt }.
404not_foundThe form is unknown or not published.
429rate_limitedToo many submissions. The Retry-After header says when to try again.
500not_configuredCaptcha has no secret key.
502captcha_unavailable, delivery_failedThe captcha provider is unreachable, or delivery failed for a form with storage: 'none'.
A stored submission is written before it is delivered. A failed email never loses it: the error is recorded on the submission, and staff can deliver it again from the admin area.

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.

authThe submitter proves access with
noneNothing.
userA signed-in user: a session, or a token with the forms:submit scope.
tokenA form access token in Authorization: Bearer <token> or x-form-token: <token>.
passwordThe 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, lastUsedAt and revokedAt.
  • 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.

The key is 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/import with { 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:

server/api/admin/forms/[id].delete.ts
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

PermissionAllows
forms:manageManaging forms and submissions as staff.
forms:credentialsForm access tokens, passwords and secrets. Session only.
forms:submitSubmitting forms as the user, and reading forms that need a signed-in user.
forms:piiSensitive (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.

ToolPermissionPurpose
list-forms, get-form, submit-formforms:submitBrowse published forms and submit as the user.
list-all-forms, get-form-definition, create-form, update-formforms:manageManage forms. Delivery settings are never returned or changed.
list-submissions, get-submissionforms:manageSubmissions. 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.

Next steps

  • Reference – every route, and the forms:submitted hook for your own delivery.
  • App – show a form, or its password gate, on any page.
  • MCP – how agents connect and which permissions they hold.
Copyright © 2026