Form Submissions
Every form submission produces a durable record. By default it lands in the built-in form_submissions ledger — the platform's canonical "this form received an answer" log — and, when submitTo.table is set, in your own table too. The two writes are transactional, so you never end up with a ledger entry promising a record that was never written. After the writes commit, onSuccess decides what the submitter sees and onError covers failures.
forms:
- id: 1
name: contact
submitTo:
table: leads
# storeSubmission defaults to true — table AND ledger
fields:
- { kind: table-field, column: email, required: true }
onSuccess:
type: toast
variant: success
message: Thanks! We'll be in touch.
The Submission Ledger
The form_submissions ledger lives in an internal Sovrium-managed schema (mirroring the isolation of auth.*) — you never create or manage it yourself. Every submission writes one ledger row by default; set submitTo.storeSubmission: false to opt out (in which case table or automation becomes mandatory).
| Column | Type | Description |
|---|---|---|
id |
UUID | Stable submission id; surfaced to onSuccess templates via $submission.id. |
form_name |
text | The forms[].name at submission time. |
form_id |
integer | The forms[].id at submission time (denormalized for joins). |
submitted_at |
timestamptz | Server timestamp when the row was created. |
submitter_user_id |
UUID, nullable | The authenticated user, when the form requires authentication. |
submitter_ip |
inet, nullable | Submitter IP (stored hashed by default) — used by anti-spam and audit. |
submitter_user_agent |
text, nullable | Submitter user-agent string. |
data |
jsonb | The full validated answer payload (mirrors what was written to submitTo.table). |
linked_record_table |
text, nullable | The bound table name, when submitTo.table is set. |
linked_record_id |
text, nullable | The inserted record's id (text-typed to handle integer / UUID / string ids). |
status |
enum | Lifecycle state (see below). |
status_reason |
text, nullable | Short reason when status is failed or spam. |
Lifecycle Status
| State | Meaning | Transitions to |
|---|---|---|
received |
Accepted; written to the ledger and bound table (if any). | processing (if automation), done (otherwise). |
processing |
The bound automation is running. | done on success, failed on terminal failure. |
done |
Terminal — all writes and the bound automation completed. | — |
spam |
Terminal — the anti-spam pipeline classified it as spam; preserved for moderation. | — |
failed |
Terminal — a downstream step failed and was not retried; status_reason is set. |
— |
The Dual-Write Contract
When submitTo.table is set, the submission writes one row to that table and one ledger row inside a single transaction. If the table write fails — a constraint violation, type error, or foreign-key miss — the ledger row rolls back too, and the submitter receives an HTTP 4xx/5xx error with fieldErrors:[{ name, message }] naming the offending column. No "ghost" ledger entry is ever left behind.
When submitTo.automation is set, the automation is invoked after both writes commit. An automation failure does not roll back the writes — the submission is recorded, status moves to failed, and the automation's own retry/failure machinery applies.
forms:
- id: 2
name: stream-event
submitTo:
automation: post-event-to-stream
storeSubmission: false # opt out — ledger is skipped; automation handles persistence
fields:
- { kind: standalone, name: event_payload, inputType: long-text }
Bypass the ledger only when you mean it. storeSubmission: false is the only way to skip the ledger and is intended for very high-volume forms that route to a stream or queue. Whenever it is set, submitTo must still specify a table or an automation, or validation fails — a form that persists nowhere is a configuration bug.
On Success
The onSuccess object decides what the submitter sees after a successful submission. It is a discriminated union on type.
type |
Behavior |
|---|---|
successPage |
Render a custom success page (title, message, optional buttonLabel + buttonHref, optional actions[], showSummary). |
redirect |
Navigate to url after an optional delaySeconds (default 2; 0 is immediate). Supports $submission.id / $record.id template variables. |
reset |
Clear the form for another submission, with an optional message and preserveFields[] to retain. |
toast |
Show a transient toast (message, optional variant: success / info). |
message |
Replace the form with an inline message in place. |
onSuccess:
type: redirect
url: /thank-you?ref=$submission.id
delaySeconds: 0
A successPage may render actions[] buttons — each is either action: reset (submit another) or action: navigate (link to url) — and showSummary: true lists the submitted values, honoring hidden fields and per-field read permissions.
On Error
The onError object controls the message shown when a submission fails server-side.
| Property | Description |
|---|---|
type |
toast, message (inline), or errorPage (full page). |
message |
Body message. Supports $t: keys. |
title |
Optional title (used by errorPage). |
variant |
Toast variant (error / warning) — only meaningful when type: toast. |
onError:
type: message
message: Something went wrong saving your request. Please try again.
Field-level validation errors are separate. Server-side validation failures (e.g. an invalid email or a foreign-key violation) return HTTP 400 with a fieldErrors:[{ name, message }] envelope and are surfaced inline on the offending field — independently of onError, which covers whole-submission failures.
Related Pages
- Forms Overview —
submitTo,storeSubmission, and theonSuccess/onErrorschema. - Form Fields — per-field
permissionsthat redact ledger exports. - File Uploads — file metadata stored in the ledger
datapayload. - Tables Overview — the bound table the dual-write targets.