Skip to main content

Forms

The Forms module exposes three endpoint clusters: admin CRUD for forms + templates, public for visitor rendering + submission, and management for triaging submissions. All admin endpoints sit behind ModuleGuard<FormsModule> — they 404 if the site has the Forms module disabled.

Endpoints

Forms (admin)

MethodPathPermissionDescription
GET/sites/{site_id}/forms?page&page_size&search&sort_by&sort_dirform:readList forms for a site (paginated)
POST/sites/{site_id}/formsform:createCreate a form (optionally from a template)
GET/forms/{id}form:readGet a form with fields + localizations
GET/sites/{site_id}/forms/by-slug/{slug}form:readLook up a form by slug
PUT/forms/{id}form:update:own / form:update:anyUpdate a form (optionally replacing fields and/or localizations atomically)
DELETE/forms/{id}form:delete:own / form:delete:anySoft-delete a form

Form templates

MethodPathPermissionDescription
GET/sites/{site_id}/form-templatesform_template:readList templates for a site
POST/sites/{site_id}/form-templatesform_template:createCreate a template
GET/form-templates/{id}form_template:readGet a template
PUT/form-templates/{id}form_template:updateUpdate a template
DELETE/form-templates/{id}form_template:deleteDelete a template

Public (no auth)

MethodPathDescription
GET/public/forms/{slug}?locale=Fetch a form definition for rendering. Inactive forms 404. Locale param accepts a UUID or code (de, en, etc.) — when set and matching a localization, localized text substitutes for the canonical values.
POST/public/forms/{slug}/submitSubmit a form. Returns the visitor's reference code. IP-rate-limited.
POST/public/submissions/lookupPrivacy-preserving self-service lookup. Returns status + created_at only.
GET/public/submissions/{reference_code}Visitor's full view of their own submission.
DELETE/public/submissions/{reference_code}Idempotent self-service delete. Returns 410 on a repeat call where the submission was previously deleted.

The public endpoints resolve the site from the X-Site-Domain header rather than the URL path. Set it on the ForjaClient config via siteDomain.

Submission management

MethodPathPermissionDescription
GET/forms/{form_id}/submissions?page&page_size&statusform_submission:readList submissions for a form, optionally filtered by status
GET/forms/{form_id}/submissions/status-countsform_submission:readCounts by status (new / in_review / resolved / archived)
GET/submissions/{id}form_submission:readGet a submission with notes + status history
PUT/submissions/{id}/statusform_submission:updateChange status (state-machine enforced server-side)
DELETE/submissions/{id}form_submission:deleteSoft-delete a submission
POST/submissions/{id}/notesform_submission:updateAdd a triage note
DELETE/submissions/{id}/notes/{note_id}form_submission:updateDelete a note

Permission scopes

Two new permission resources:

  • form — Author+ can create/update:own/delete:own; Editor+ unlocks update:any/delete:any.
  • form_template — Editor+ for all operations.
  • form_submission — Reviewer+ for read; Author can update/delete their own forms' submissions; Editor+ unlocks :any.

Templates are editor-only because they're a shared site resource (used by multiple form-creators).

Localization

Two related schemas:

  • FormLocalizationInput / FormLocalizationResponse — per-locale name, description, consent_text. Keyed by (form_id, locale_id).
  • FormFieldLocalizationInput / FormFieldLocalizationResponse — per-locale display_label, placeholder, help_text. Keyed by (form_field_id, locale_id). The field label stays canonical (it's the technical key used as the submission JSONB key).

Admin write endpoints accept localizations atomically as part of the CreateFormRequest / UpdateFormRequest body — sending localizations on UpdateFormRequest performs a wipe-and-rewrite inside the same transaction as the field replace. Omit the field to leave existing localizations untouched.

On the public GET /public/forms/{slug}?locale= endpoint, the backend substitutes localized values into the response before serializing. PublicFormFieldResponse.display_label carries the localized label (defaulting to label), while label remains the technical key.

Validation

Submission validation runs server-side on every POST to /public/forms/{slug}/submit. Rules supported:

  • required — non-empty / non-null / non-empty-array
  • min_length / max_length — string length bounds
  • pattern — ECMAScript-flavored regex (Rust regex crate; no lookbehind/lookahead)
  • min / max — numeric bounds
  • email format validation on field_type: email
  • ISO date format validation on field_type: date
  • Option membership on select / radio / checkbox

The validateSubmission helper in libs/client/src/resources/forms.ts mirrors these rules client-side for instant UX feedback. Server validation is always the source of truth.

Bot protection

When a form has bot_protection: "mandatory", submitters must include a bot_protection_token in the submit payload. Forja stores the token alongside the submission but does not verify it server-side in v1 — integrate your CAPTCHA provider's verification in a webhook handler or downstream pipeline if you need verified-human-only signal.

Reference codes

POST /public/forms/{slug}/submit returns a reference_code formatted as XXXX-XXXX-XXXX — 12 chars from a 28-char alphabet excluding ambiguous I/O/0/1. Codes are generated from the kernel CSPRNG (rand 0.9 with OsRng), have ~57 bits of entropy, and are UNIQUE-constrained per row with a 3-retry insert loop.

Privacy & webhooks

When a submission lands, Forja queues a form.submission.created webhook event for any subscribed webhook endpoint. Field data is intentionally excluded from the payload — receivers get form_id, submission_id, reference_code, and created_at and must call the admin submission endpoint to read field values if they need them. The integration test in forms_webhook_notification_test.rs reads the queued payload back and asserts the visitor's submitted email never appears in it.

Storage modes

A form's storage_mode controls how submission data is indexed:

  • simple (default) — submissions stored as compact JSONB without a search index. Adequate for inbox triage and CSV export.
  • queryable — a GIN index with jsonb_path_ops is created so admin-side searches across submission data are efficient. Use this when you actually need to query submissions by field value from the admin.

GDPR retention

Each form has an optional retention_days integer. A background worker (forms_retention_cleanup) ticks hourly and soft-deletes submissions older than the configured retention. NULL or 0 opts out of auto-deletion.

Module flag

All endpoints are gated behind ModuleGuard<FormsModule>. The site setting module_forms_enabled must be true (toggle under Site Settings → Modules) for the endpoints to respond. Disabled sites get 404 to avoid leaking module status.