Forms

Forms reference

Look up the hooks, HTTP routes, database tables and translation keys of Forms.

This page lists what Forms adds to your server: hooks, routes, tables and translations. It is for app developers and module authors. Set up the module first: Overview.

Hooks

Your app answers these Nitro hooks from a server plugin (server/plugins/*.ts).

HookWhenPayload
forms:submittedA valid submission arrived, and was stored unless storage: 'none'.SubmittedContext
forms:captcha:verifyA captcha token must be verified.CaptchaVerifyContext
forms:deliveredDelivery finished: a listener delivered the submission, or one failed.{ submissionId: string | null, formSlug, status: 'delivered' | 'failed', error? }
type SubmittedContext = {
  event: H3Event
  form: { id: string, slug: string, name: string, version: number, definition: FormDefinition }
  answers: Record<string, unknown> // validated; file fields hold the file names
  files: { fieldKey: string, filename: string, contentType: string, size: number, pathname: string | null }[]
  locale: string
  userId: number | null
  submissionId: string | null // null when storage is 'none'
  stored: boolean
  delivered: boolean // set true once the handler delivered; throw to mark delivery failed
}

type CaptchaVerifyContext = {
  event: H3Event
  provider: 'turnstile' | 'recaptcha' | 'hcaptcha'
  token: string
  ip: string | undefined
  formId?: string
  secretKey?: string
  secretSource?: 'form' | 'env' | 'unset'
  valid: boolean | undefined // set it to decide; left undefined, the provider is asked
}

This listener forwards every submission to a webhook:

server/plugins/forms-webhook.ts
export default defineNitroPlugin((nitro) => {
  nitro.hooks.hook('forms:submitted', async (ctx) => {
    await fetch('https://example.com/hook', { method: 'POST', body: JSON.stringify(ctx.answers) })
    ctx.delivered = true
  })
})
  • Email. The built-in email delivery is a forms:submitted listener. It sends delivery.email through Notify.
  • Stored forms. With storage: 'db', a failing listener sets the submission's deliveryStatus to failed, with the error. The visitor still sees success, and Deliver again in the admin area runs the hooks again.
  • Unstored forms. With storage: 'none', nothing else keeps the answers, so a failure answers 502.

Routes

Routes the module registers, and who may call them. Public routes are under /api/forms, and staff routes under /api/admin/forms. Build the paths with formsApi from @frigear-nu/forms/api, for example formsApi.admin.form(id).

  • Staff means the caller holds forms:manage through a role. See Permissions.
  • Session routes create or remove access credentials. They need forms:credentials, a session-only permission, so a token gets 403 session_required.
  • Errors carry a data.code, such as not_found, forbidden, conflict or validation_failed.
  • Lists answer { items, total, page, pageSize }. They take page, pageSize (default 20, at most 100), sort, order (asc or desc) and their filters.
MethodPathAuthStatusPurpose
GET/api/forms/:slugform access200The published definition, without delivery.
GET/api/forms/:slug/accessnone200What a page needs to choose between the form, a gate and "not found".
POST/api/forms/:slug/submitform access200Submit answers. Rate limited.
GET/api/admin/formsforms:manage200Forms with submission counts. Filters below.
POST/api/admin/formsforms:manage201Create a form. 409 conflict for a taken slug.
POST/api/admin/forms/importforms:manage200Import definitions: { inserted, skipped }.
GET/api/admin/forms/:idforms:manage200One form, with submissionCount.
PATCH/api/admin/forms/:idforms:manage200Update a form. A changed definition bumps version.
DELETE/api/admin/forms/:idforms:manage204Archive the form. It and its submissions are kept.
POST/api/admin/forms/:id/cloneforms:manage201Copy into a new draft (<slug>-copy, -copy-2, …).
GET/api/admin/forms/:id/submissionsforms:manage200A form's submissions. Filters below.
GET/api/admin/forms/:id/tokensforms:manage200The form access tokens, as an array.
POST/api/admin/forms/:id/tokensforms:credentials201Create a token. Only this response contains it.
DELETE/api/admin/forms/:id/tokens/:tokenIdforms:credentials204Revoke a token. It stays listed, with revokedAt.
GET/api/admin/forms/:id/passwordforms:manage200Whether a password is set: { set }.
PUT/api/admin/forms/:id/passwordforms:credentials200Set the password { password }, 6 to 200 characters. Answers { set: true }.
DELETE/api/admin/forms/:id/passwordforms:credentials204Remove the password.
GET/api/admin/forms/:id/secretsforms:manage200The names and sources of the secrets: { keys, captcha, unreadable }.
PUT/api/admin/forms/:id/secrets/:keyforms:credentials200Write one secret { value }. 400 for an unknown key.
DELETE/api/admin/forms/:id/secrets/:keyforms:credentials204Remove one secret. 400 for an unknown key.
GET/api/admin/forms/submissions/:idforms:manage200One submission.
GET/api/admin/forms/submissions/:id/files/:fileIdforms:manage200Download an uploaded file.
POST/api/admin/forms/submissions/:id/redeliverforms:manage200Run the delivery hooks again: { deliveryStatus, deliveryError }.

GET /api/admin/forms filters by q (name or slug), status and archived=false. It sorts by updatedAt (the default, descending), createdAt, name, slug, status or submissionCount.

DELETE /api/admin/forms/:id sets the status to archived, so submissions always keep their form. A PATCH can publish it again.

POST /api/admin/forms/:id/clone copies the name and definition. Submissions, tokens, the password and secrets are not copied.

GET /api/admin/forms/:id/submissions filters by deliveryStatus. It sorts by createdAt (the default, descending), deliveryStatus or locale.

The static import and submissions segments win over :id.

Database

Tables the module adds. Generate and commit a migration after installing.

TableHolds
formsslug, name, status (draft, published, archived), definition and version.
form_submissionsAnswers, definitionSnapshot, formVersion, userId, locale, deliveryStatus and deliveryError.
form_submission_filesThe blob pathname, file name, content type and size of each upload.
form_access_tokensForm access tokens: tokenHash, prefix, lastUsedAt and revokedAt.
form_accessOne row per password-protected form: passwordHash.
form_secretsOne row per form: secrets, an encrypted JSON map of namespaced keys.

deliveryStatus is none, pending, delivered or failed. Only staff can download uploaded files.

Translations

Override any string in your app's i18n/locales/<code>.json. The module registers da and en messages under forms.*, with English fallbacks. See overriding module strings.

{ "forms": { "actions": { "submit": "Send inquiry" } } }

Key groups: actions, step, success, error, embed, preview, field, value, status, delivery, validation, admin, schedule, access, menu, captcha and builder.

  • Form content lives in the definition, as { da, en } texts.
  • Validation messages use forms.validation.*. A field overrides them with validation.message.

Next steps

  • Server API – the access modes and secrets behind these routes.
  • Send an email – configure the Notify driver that delivers form emails.
  • Definition – the definition these routes store.
Copyright © 2026