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) 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 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). 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 is an optional check that a person, not a script, filled in the form. It uses your own widget, in your Cloudflare account (free):
- In the Cloudflare dashboard, open Turnstile and add a widget. List every hostname the form runs on (
example.com, andlocalhostif you want the widget while developing). Copy its site key and secret key. - Put the site key in the schema:
settings: { turnstileSiteKey: '0x4AAAAAAA…' }. TheFormrenders the widget and sends its token with the submission. - Give Kibble the secret key:
npx kibble turnstile set <secret key>
The CLI reads the site key from your schema files (pass --site-key when forms use more than one), Kibble 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 Kibble 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 Kibble that two keys belong together, so a mismatched pair sends every response to spam.
- Until Kibble has the secret key for a form's site key, its tokens aren't checked: registering the form prints a warning, and
npx kibble turnstilelists 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
kibble turnstile setwith 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,
kibble 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,
turnstileSiteKeychanges apply with the next production build, likeagents. npx kibble turnstile removedeletes the secret key; tokens aren't checked after that.
To try it, Cloudflare's test keys 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 underextra. 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 kibble env vercel(ornetlify, orprint) 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.