Docs
After submitSpam and validation
View as Markdown(opens in a new tab)

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):

  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 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 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 kibble 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, 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, turnstileSiteKey changes apply with the next production build, like agents.
  • npx kibble turnstile remove deletes 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 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 kibble 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.