Spam protection
Honeypot, signed timestamps, spam scoring, a human check, allowed domains and rate limits.
Every form gets layered protection with no setup: a honeypot, a signed render timestamp, spam scoring and rate limits. On top of that you can add a human check and restrict the form to your own domains.
| Layer | On by default | Outcome |
|---|---|---|
| Rate limits | Yes, Standard preset | 429 rate_limited |
| Allowed domains | No | Spam, or 403 origin_not_allowed |
| Human check | No | 403 challenge_required or a check page |
| Honeypot | Yes | +5 to the score |
| Spam scoring | Yes | Spam at 5 or more |
How spam is handled
A post that is judged as spam is stored with the status spam and still gets a normal success response, so bots learn nothing. Spam sends no webhooks and no emails, doesn't count toward your monthly usage, and is deleted after 30 days. Review it in the dashboard or with the REST API; marking a submission as not spam sends its webhooks then.
Honeypot
Add a hidden input that people never fill in. The default name is _gotcha; you can change it per form under Honeypot field in the form's settings. Hiding techniques are on HTML forms.
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />Spam scoring
Each post gets a score from these signals. At 5 or more it is stored as spam.
| Signal | Reason in the dashboard | Score |
|---|---|---|
| The honeypot is filled in | honeypot | +5 |
| Sent less than 2 seconds after the form rendered | timestamp_fast | +3 |
| The signed render timestamp is forged or older than 24 hours | timestamp_invalid | +3 |
| A link in a name or subject field | url_in_name | +3 |
| The same fields were sent to this form in the last 24 hours | duplicate | +3 |
| Three or more links in total | many_urls | +2 |
| A known spam phrase (22 built in) | keyword | +2 |
| An email address on a disposable email domain | disposable_email | +2 |
- The render timestamp only exists when the form is rendered by the SDK or the shadcn/ui component, which send a signed
_ikiform_ts. Plain HTML posts without it are not penalised. - A name field is any field whose name contains
name,firstname,lastname,fullname,surnameorsubject, but not words likecompany,websiteordomain. - Two reasons force spam regardless of score:
origin_mismatchfrom allowed domains andcaptcha_unavailablewhen Turnstile can't be reached.
Human check
Each form has a Spam check setting with three options. It is off by default, so existing fetch and curl integrations keep working when you create a form.
| Option | What it does |
|---|---|
| Off | No challenge. Honeypot, timestamp and scoring still run. |
| Ikiform (recommended) | Cloudflare Turnstile run by Ikiform on ikiform.com. No Cloudflare account or keys needed. |
| Your own Turnstile keys | Your Turnstile widget and secret key. The token's hostname is checked against your allowed domains. |
How the Ikiform check runs
HTML form
- The browser posts the form
- No token yet: ikiform.com shows a short check page
- Turnstile runs, the page posts the same fields again with a token
- 303 to your redirect or the thank-you page
JavaScript
- challenge(formId) mounts a hidden ikiform.com frame
- Turnstile runs, and only shows itself if it needs a click
- submit(formId, data, { turnstileToken })
- 201 with the submission id
Server with a secret key
- POST with Authorization: Bearer ik_sk_...
- The Ikiform check is skipped
- 201 with the submission id
import { challenge, submit } from "@ikiform/sdk";
const form = document.querySelector("form");
form.addEventListener("submit", async (event) => {
event.preventDefault();
const turnstileToken = await challenge("frm_xxxxxxxxxxxxxxxx");
const result = await submit("frm_xxxxxxxxxxxxxxxx", new FormData(form), { turnstileToken });
if (result.ok) form.replaceWith("Thanks, we got it.");
});Tokens are single use and live 300 seconds, so get one per submit. A JSON post without a token gets 403 challenge_required with a challenge_url; a token that fails verification gets 403 captcha_failed.
{
"ok": false,
"error": {
"code": "challenge_required",
"message": "Complete the human check, then send the form again.",
"challenge_url": "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx/challenge"
},
"request_id": "req_01k7v2c9t3e8m5q4x6z0a1b2c3"
}captcha_unavailable, so you can restore it rather than lose it.Your own Turnstile keys
Paste your site key and secret key into the form's settings. The secret is encrypted at rest. Render the widget in your form so it adds cf-turnstile-response to the post, or pass the token to submit as turnstileToken. Posts without a valid token get 403 captcha_failed, including posts with a secret API key.
Allowed domains
List the domains your form lives on under Allowed domains. A post must then come from one of them or a subdomain, judged by the Origin header, or the Referer when there is no origin. Leave the list empty to accept posts from any site.
| Allowed domains | Post from | Matches |
|---|---|---|
example.com | https://example.com | Yes |
example.com | https://www.example.com | Yes, subdomain |
example.com | http://localhost:3000 | No, add localhost while developing |
example.com | https://example.co | No |
app.example.com | https://example.com | No, a parent domain doesn't match |
When a post comes from another site decides what happens to a mismatch: Mark as spam (the default) or Reject with 403 origin_not_allowed. Posts with a secret API key skip this check. Allowed domains also decide which _redirect targets are followed and where the Ikiform check frame may be embedded.
Rate limits
Each form has a Rate limit preset, counted exactly per visitor IP and form. Standard allows 5 posts a minute, Relaxed 20 a minute and Strict 2 every 10 minutes. The full rules are on Rate limits.