Anti-Spam, Attribution & Analytics
A public form is a public write endpoint. Left open it collects bot submissions, and the useful ones drown. Three small blocks handle that class of concern: antiSpam keeps junk out, submitter governs how one person interacts with a form over time, and analytics decides whether the form's submissions feed aggregate reporting at all.
forms:
- id: 1
name: contact
title: Contact Sales
submitTo: { table: leads }
antiSpam:
honeypot: true
rateLimit: { perIp: 5, perForm: 500, windowSeconds: 60 }
analytics:
enabled: false
fields:
- { kind: table-field, column: email, required: true }
Anti-Spam Properties
Every property is optional, and every default is protective — a form with no antiSpam block is already defended.
| Property | Description | Default |
|---|---|---|
honeypot |
Render a hidden decoy input. A submission that fills it is classified as spam. | true |
rateLimit.perIp |
Submissions accepted from one IP per window. | 10 |
rateLimit.perForm |
Submissions accepted for the whole form, across all IPs, per window. | 1000 |
rateLimit.windowSeconds |
Length of the rolling window, in seconds. | 60 |
captcha |
Name of a connections[] entry providing CAPTCHA verification. |
None |
The honeypot is a conventionally-named hidden input carrying every marker that keeps a human out of it — aria-hidden, tabindex="-1", autocomplete="off", and no visible box. A person never sees it or tabs into it; a naive bot that fills every input in the document trips it immediately.
captcha is accepted by the schema and reserved for a connection-backed verifier. Naming a connection that does not exist produces a startup warning, not a boot failure.
How a Rejection Looks
| Trigger | Response | Ledger status / status_reason |
|---|---|---|
| Honeypot filled | 400 { error: 'invalid request' } |
spam / honeypot |
perIp exceeded |
429 with a Retry-After header |
spam / rate_limit_per_ip |
perForm exceeded |
429 with a Retry-After header |
spam / rate_limit_per_form |
The client is told nothing specific — a bot that learns why it was refused learns how to get past. The reason is recorded on the ledger row instead, where a moderator can review it. A spam-flagged submission never writes to the bound table, never fires the submitTo automation, and never counts against availability.maxSubmissions.
The submitter's IP is stored only as a salted SHA-256 hash. The raw address is never persisted.
Anti-spam is on by default, and a partial block does not turn it off. Omitting antiSpam entirely still renders the honeypot and still enforces 10 submissions per IP per minute. Writing antiSpam: { captcha: 'turnstile' } does not replace those defaults — it adds to them. To genuinely disable the trap you must say so: honeypot: false. Test forms that post repeatedly from one address are the usual way this surprises people.
Submitter Options
submitter collects the rules about the same person meeting the same form more than once.
| Property | Description | Default |
|---|---|---|
saveAndResume |
Let submitters save partial progress and return to it later. | false |
allowEditAfterSubmit |
Let submitters change their answers after submitting. | false |
requireUniqueSubmission |
Reject a second submission from a submitter who has already answered. | false |
These three are accepted and validated by the schema; the submission pipeline does not enforce them yet. Do not rely on requireUniqueSubmission for correctness today — enforce uniqueness with a unique constraint on the bound table's column.
Attribution itself does work: when a session is present the ledger records the submitter's user id and a bound table's created-by column is populated. See Access Control.
Analytics Opt-Out
Every successful submission feeds the aggregate analytics stream by default, which is what makes the admin Responses surface able to chart submissions per day and drop-off per step.
| Property | Description | Default |
|---|---|---|
enabled |
Set false to exclude this form from aggregates and the event stream. |
true |
Set it to false on forms carrying data that must not appear in cross-submission aggregates — health intake, payment details, anything under legal hold. Per-submission inspection in the admin console is unaffected; the opt-out removes the form from the aggregate, not from your records.
Aggregation is gated three times over: an operator-level environment switch, the app-wide analytics block, and this per-form flag. Any one of them off is enough to keep a form out.
Related Pages
- Availability — the submission cap spam is excluded from.
- Submissions — the ledger where the spam status is recorded.
- Access Control — signed-in submissions and how they are attributed.
- Analytics — the app-wide block this opt-out sits under.
Last updated July 27, 2026
This documentation was written with AI, so errors or outdated content are possible. Sovrium is in beta — contributions and corrections are welcome.