# Spam and validation

The server decides. Every submission is validated against the registered schema version and scored for spam. Client validation is a convenience.

## Validation and normalization

Values are checked by type and constraint (see [Form schema](/docs/schema.md)) and normalized: text trimmed, emails lowercased, phone numbers converted to E.164, URLs given a scheme, dates to `YYYY-MM-DD`. Invalid submissions get `400 validation_failed` with a message per field; the components show those messages inline.

Partial submissions validate only the pages completed so far.

## Spam scoring

| Signal | Score | Reason code |
| --- | --- | --- |
| Honeypot field filled | 100 | `honeypot` |
| Submitted under 2 seconds after the form loaded | 60 | `too_fast` |
| Disposable email domain | 50 | `disposable_email` |
| Three or more links in a text field | 40 | `links` |
| [Turnstile](#turnstile) on and token missing or invalid | 70 | `turnstile` |

A score of 70 or more marks the submission `spam`. Spam is stored and shown in the spam tab with its reasons; it never fires notifications or webhooks. Spam still counts toward the monthly response limit (see [Usage](/docs/limits.md)). Mark anything as not spam in the dashboard. The client always gets a normal success response, so bots learn nothing.

Rate limits apply per IP, per form and per origin. Over the limit the API returns `429`.

## Turnstile

[Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) is an optional check that a person, not a script, filled in the form. It uses your own widget, in your Cloudflare account (free):

1. In the Cloudflare dashboard, open **Turnstile** and add a widget. List every hostname the form runs on (`example.com`, and `localhost` if you want the widget while developing). Copy its **site key** and **secret key**.
2. Put the site key in the schema: `settings: { turnstileSiteKey: '0x4AAAAAAA…' }`. The `Form` renders the widget and sends its token with the submission.
3. Give Mirio the secret key:

```bash
npx mirio turnstile set <secret key>
```

The CLI reads the site key from your schema files (pass `--site-key` when forms use more than one), Mirio checks the secret key with Cloudflare and stores it encrypted, and it is never shown again. API: `PUT /v1/project/turnstile` with `{ "siteKey": "…", "secret": "…" }`.

From then on Mirio checks each submission's token with Cloudflare. A missing or invalid token adds 70 (`turnstile`), which on its own marks the submission spam.

- **Use the secret key of the same widget as the site key.** A secret key only accepts its own widget's tokens, and Cloudflare can't tell Mirio that two keys belong together, so a mismatched pair sends every response to spam.
- Until Mirio has the secret key for a form's site key, its tokens aren't checked: registering the form prints a warning, and `npx mirio turnstile` lists which forms are checked.
- If Cloudflare can't be reached, or stops accepting the secret key (you rotated or deleted it), submissions are scored as if Turnstile were off; nothing goes to spam for that. After rotating, run `mirio turnstile set` with the new secret key.
- A token works once. Partial saves of a multi-step form don't use it (the final submit does), and requests made with the site's secret key (your own server, `mirio test`) skip the check.
- AI agents that submit through the API can't solve Turnstile, so on a form with Turnstile their responses go to spam. Leave it off on forms you want agents to fill.
- On a live form, `turnstileSiteKey` changes apply with the next production build, like `agents`.
- `npx mirio turnstile remove` deletes the secret key; tokens aren't checked after that.

To try it, Cloudflare's [test keys](https://developers.cloudflare.com/turnstile/troubleshooting/testing/) work: site key `1x00000000000000000000AA` with secret key `1x0000000000000000000000000000000AA` always passes, `2x00000000000000000000AB` with `2x0000000000000000000000000000000AA` always fails. Switch to your widget's keys before going live.

## What is never dropped

- Spam goes to the spam view.
- A submission from a schema that was never registered is accepted, validated against the production version (or the latest when the form has no production registration yet), flagged `unregistered_schema`, and stored with any unknown keys under `extra`. The owners get at most one digest email per form per 24 hours, counting the flagged responses since the previous one, with the fix: `npx mirio env vercel` (or `netlify`, or `print`) so the site registers its forms on every build.
- Submissions from a localhost origin on a live (claimed) project are stored as `test`: you can try the site locally against the real project. Test responses are enriched and reviewed so you see how triage sorts them, but they are never delivered, never counted as responses, and are hidden in the dashboard unless "Show test responses" is on. Sandbox projects keep treating localhost as normal traffic.
- Forms are never cut off.
