Appearance
Spam Protection
Krafter Forms scores every submission against eleven heuristic signals, keeps a honeypot check that fires regardless of the score, rate-limits per IP, and temp-bans IPs that keep tripping the heuristics. Cloudflare Turnstile is supported for forms that want a CAPTCHA, and optional — the default protection needs no third-party service.
Honeypot Fields
A honeypot is a hidden form field that is invisible to real users but gets filled in by automated bots. When a submission includes a non-empty honeypot value, it is flagged as spam.
How It Works
- Add a hidden field named
_honeypotor_gotchato your form. - Hide it from real users using CSS.
- Bots that crawl and fill in all fields will populate the honeypot.
- Krafter detects the filled honeypot and flags the submission as spam.
HTML Example
html
<form action="https://app.krafter.dev/f/contact" method="POST">
<!-- Honeypot field: hidden from humans, visible to bots -->
<div style="position: absolute; left: -9999px;">
<input type="text" name="_honeypot" tabindex="-1" autocomplete="off">
</div>
<input type="text" name="name" required>
<input type="email" name="email" required>
<textarea name="message" required></textarea>
<button type="submit">Send</button>
</form>WARNING
Do not use display: none or visibility: hidden to hide the honeypot field. Some bots detect these CSS properties and skip hidden fields. Use off-screen positioning (left: -9999px) instead.
What Happens to Spam Submissions
When a honeypot is triggered:
- The submission is still stored in the database.
- The
is_spamflag is set totrue. - The
spam_scorefield is populated. - Spam submissions are hidden by default in the dashboard and API responses.
- Email notifications and webhooks are not triggered for spam submissions.
The server still returns a 201 response so bots do not know they were detected:
json
{
"ok": true,
"id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
}Heuristic Scoring
Every submission is scored against eleven signals. Each contributes a weight; the weights are summed, capped at 1.0, and compared against a threshold. A submission at or above the threshold is flagged is_spam.
| Signal | Weight | Fires when |
|---|---|---|
honeypot | 1.0 | _honeypot or _gotcha is non-empty. Flags regardless of the threshold, and even when heuristics are disabled |
bot_user_agent | 0.8 | The User-Agent names a non-browser client (curl, python-requests, okhttp, playwright, bot, crawler, …). Alone this exceeds the default threshold |
fake_email_domain | 0.7 | The email domain publishes neither an MX nor an A record. See MX checking |
gibberish_name | 0.6 | The name field looks machine-generated (long consonant runs, too few vowels, erratic capitalisation) |
gibberish_message | 0.6 | The message field looks machine-generated by the same test |
gmail_dot_abuse | 0.5 | A gmail.com / googlemail.com address with 4+ dots in the local part — Gmail ignores dots, so this fakes distinct addresses |
disposable_email | 0.5 | The domain is a known throwaway provider (mailinator, guerrillamail, yopmail, …) |
url_stuffing | 0.5 | The message contains more than 3 links |
too_fast_submit | 0.5 | The form was submitted under 2 seconds after render. Requires _ts — see timing |
missing_user_agent | 0.4 | The request carries no User-Agent at all |
too_short_message | 0.3 | The message is shorter than min_message_length |
The signals that fire are recorded on the submission, so the dashboard shows why something was flagged.
Only three fields are inspected
The heuristics read the fields named name, email and message. Name your fields that way and the scoring has something to work with; name them full_name and enquiry and most signals stay silent.
Configuring the heuristics
Set spam_protection.heuristics when creating or updating a form:
bash
curl -X PATCH https://app.krafter.dev/api/v1/forms/FORM_ID \
-H "Authorization: Bearer kr_live_abc123def456" \
-H "Content-Type: application/json" \
-d '{
"spam_protection": {
"heuristics": {
"enabled": true,
"threshold": 0.7,
"min_message_length": 10,
"check_mx": true
}
}
}'| Key | Type | Default | Meaning |
|---|---|---|---|
enabled | boolean | true | When false, signals are still recorded but only the honeypot flags |
threshold | number | 0.7 | Score at or above which a submission is spam. Lower catches more and produces more false positives |
min_message_length | integer | 10 | Below this the too_short_message signal fires |
check_mx | boolean | true | Whether to resolve the email domain — see below |
MX checking
fake_email_domain resolves the email domain's MX records, so it is the only signal that leaves the server. The cost is up to 2 seconds for a domain not already cached, then an hour of in-memory hits for that domain. Set check_mx: false if that latency matters more to you than the signal.
It is deliberately fail-open: a resolver that times out or errors never flags, so a flaky network cannot start rejecting real users. Only a domain that authoritatively publishes no MX and no A record scores.
Submit timing
too_fast_submit needs your page to tell Krafter when it rendered. Stamp the render time in milliseconds into a hidden _ts field:
html
<input type="hidden" name="_ts" id="ts">
<script>document.getElementById('ts').value = Date.now();</script>Without _ts the signal is silently inactive — it is opt-in, and there is no way for the server to infer the render time.
Cloudflare Turnstile
For forms that want a real CAPTCHA, Krafter verifies Cloudflare Turnstile tokens server-side. Enable it per form with your Turnstile secret key:
bash
curl -X PATCH https://app.krafter.dev/api/v1/forms/FORM_ID \
-H "Authorization: Bearer kr_live_abc123def456" \
-H "Content-Type: application/json" \
-d '{
"spam_protection": {
"turnstile": {"enabled": true, "secret_key": "0x4AAAAAAA..."}
}
}'The secret is stored encrypted at rest and is never returned by the API — reads come back with "secret_key": "[REDACTED]".
Add the widget to your form with your site key (the public half):
html
<form action="https://app.krafter.dev/f/contact" method="POST">
<input type="email" name="email" required>
<textarea name="message" required></textarea>
<div class="cf-turnstile" data-sitekey="0x4AAAAAAA..."></div>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<button type="submit">Send</button>
</form>The widget posts a cf-turnstile-response field (cf_turnstile_response is also accepted). A submission that fails verification is rejected outright:
json
// 403 Forbidden
{ "error": "captcha_failed", "reason": "verification_failed" }Note the difference from the heuristics: a heuristic flags and stores, Turnstile rejects. A missing or invalid token means the submission never lands.
Repeat-Offender IP Bans
An IP that trips the spam heuristics 8 times within an hour is temp-banned for 24 hours. Banned requests get the same 201 {"ok": true} a real submission gets, so a bot cannot tell it has been detected — but nothing is stored, and no notification or webhook fires.
This is not a rate limit and does not replace one: the rate limit caps volume regardless of intent and catches legitimate users too, while the banlist only picks up sources that keep being flagged as spam. It is not configurable per form, and the counts are per node.
Rate Limiting
Two independent rate-limit layers protect every public form submission endpoint. Both are tracked in-memory using ETS on each Krafter node.
| Layer | Scope | Default | Configurable | 429 body |
|---|---|---|---|---|
| Platform pipeline | All /f/* traffic per IP, per node | 200 requests / 60s | No (platform-wide ceiling on every /f/* route) | {"error": "Rate limit exceeded", "retry_after": <seconds>} plus X-RateLimit-* and Retry-After headers |
| Per-form limiter | Per (form, IP), per node | 10 requests / 60s | Yes — settings.rate_limit on the form | {"error": "rate_limited"} |
The platform pipeline cap is applied first by KrafterWeb.Plugs.RateLimit in the :public_rate_limit pipeline, so raising settings.rate_limit above 200/min has no effect — the pipeline cap kicks in earlier on busy IPs.
The per-form rate-limit counters live in an in-memory ETS table on each Krafter node, kept by
Krafter.RateLimit(Hammer, fixed window). Counts are not shared across nodes, so with N replicas each limit is effectivelylimit x N— Krafter runs one replica today and logs a warning at boot if that stops being true.
The two layers are distinguishable by their JSON body shape and by whether X-RateLimit-* headers are present (they are only set by the platform pipeline, not by the per-form limiter).
Configuring Rate Limits
You can customize the rate limit per form via the form settings. Set settings.rate_limit when creating or updating a form:
bash
curl -X PATCH https://app.krafter.dev/api/v1/forms/FORM_ID \
-H "Authorization: Bearer kr_live_abc123def456" \
-H "Content-Type: application/json" \
-d '{
"settings": {
"rate_limit": 5
}
}'This sets the limit to 5 submissions per IP per 60-second window for this form.
settings.rate_limit must be a positive integer; anything else is rejected with a 422. There is no upper bound, but the platform pipeline cap of 200 req / 60 s per IP applies on top, so values above ~200 do not buy you anything in practice. If you do want to lift the per-form ceiling above the platform cap, contact support — it requires a pipeline-level change, not a settings tweak.
rate_limit must be a positive integer
rate_limit: 0 does not mean "unlimited" and no longer means "one per window" either — since the limiter moved to Hammer, a limit of 0 rejects the first request and every one after it, so the form accepts nothing at all. Negative values behave the same way.
The API rejects 0, negative numbers, floats and strings at write time — {"rate_limit": "5"} used to be accepted and then answered every submission with a 500, because the limiter takes an integer.
If a form of yours already has rate_limit: 0 stored, its intake was closed and is now open again at the default 10/60s: the ingest treats an unusable stored value as absent rather than passing it on, and logs a warning naming the form. Set the limit you actually want.
To run a form effectively unrate-limited at the per-form layer, set a high integer (e.g. 1000000); the platform pipeline cap (200 req / 60 s per IP) still holds the line. Removing the rate_limit key entirely falls back to the default of 10/60s, not "unlimited".
Managing Spam Submissions
Viewing Spam in the Dashboard
By default, spam submissions are hidden in the dashboard. Use the filter controls to show spam entries.
Filtering via API
Use the is_spam query parameter to filter submissions:
bash
# List only spam submissions
curl "https://app.krafter.dev/api/v1/forms/FORM_ID/submissions?is_spam=true" \
-H "Authorization: Bearer kr_live_abc123def456"
# List only non-spam submissions (default)
curl "https://app.krafter.dev/api/v1/forms/FORM_ID/submissions?is_spam=false" \
-H "Authorization: Bearer kr_live_abc123def456"Marking / Unmarking Spam
You can manually mark a legitimate submission as spam or unmark a false positive:
bash
# Mark as spam
curl -X PATCH https://app.krafter.dev/api/v1/forms/FORM_ID/submissions/SUBMISSION_ID \
-H "Authorization: Bearer kr_live_abc123def456" \
-H "Content-Type: application/json" \
-d '{"is_spam": true}'
# Unmark spam (false positive)
curl -X PATCH https://app.krafter.dev/api/v1/forms/FORM_ID/submissions/SUBMISSION_ID \
-H "Authorization: Bearer kr_live_abc123def456" \
-H "Content-Type: application/json" \
-d '{"is_spam": false}'Best Practices
- Always include a honeypot field in your HTML forms. It catches the majority of automated spam with zero friction for real users.
- Name your fields
name,emailandmessagewhere that fits. Most heuristic signals read exactly those three; other names leave them silent. - Stamp
_ts. Two lines of markup buy you thetoo_fast_submitsignal. - Tune the threshold from your own spam queue, not in the abstract. Lower it if spam is getting through, raise it if real submissions are being flagged.
- Reach for Turnstile when the heuristics are not enough. It rejects rather than flags, so it stops abuse the score lets through — at the cost of a widget in front of your users.
- Keep rate limits reasonable. The default of 10 per minute works well for contact forms. Increase it for high-traffic forms like newsletter signups.
- Review spam periodically. Check the spam queue for false positives and unmark them if needed.
- Validate on the server, not only in the browser.
requiredin HTML is advisory; see server-side validation for the setting that enforces it.