
# 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.

```yaml
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.

:::callout
**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](/en/docs/form-access).

## 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](/en/docs/form-availability) — the submission cap spam is excluded from.
- [Submissions](/en/docs/form-submissions) — the ledger where the spam status is recorded.
- [Access Control](/en/docs/form-access) — signed-in submissions and how they are attributed.
- [Analytics](/en/docs/analytics) — the app-wide block this opt-out sits under.
