Spam protection

Five layers run on every endpoint whether you configure anything or not, and two more are yours to switch on. The cheapest checks run first, so an abusive request costs as little as possible before it’s turned away.

The pipeline, in order

  1. 01Endpoint lookup — unknown IDs 404, paused endpoints refuse and say so.
  2. 02Rate limits — per address, then the endpoint's daily circuit breaker.
  3. 03Origin and Referer, matched against the endpoint's allowed hosts.
  4. 04Honeypot — a hidden field no human fills.
  5. 05Time trap — submissions that arrive faster than a person can type.
  6. 06Email validation, and the disposable-provider blocklist.
  7. 07Turnstile, when the endpoint has it switched on.
  8. 08The record allowance.
  9. 09Store the record, or forward it and store nothing.

The two quiet checks

The honeypot and the time trap both answer 200 and store nothing. That is on purpose: a bot that learns it was caught is a bot that adapts, and one that thinks it succeeded keeps wasting its time on an endpoint that discards it.

Honeypot

The generated snippets include a visually hidden _hp field. Humans never see it, so they never fill it; naive bots fill every input they find. Any non-empty value and the submission is discarded.

what the snippet ships
<input type="text" name="_hp" tabindex="-1" autocomplete="off"
       aria-hidden="true" style="position:absolute;left:-9999px">

Time trap

Snippets stamp _ts when the form renders. Anything that arrives less than 2seconds later wasn’t typed by a person. Seconds and milliseconds are both accepted, a missing stamp passes — curl has no render time — and a stamp from the future is treated as clock skew rather than a fast human.

IF YOU HAND-ROLL THE FORM

Both fields are optional. Leave them out and everything still works; include them and you get two free layers. What you must not do is prefill _hp or stamp _ts at submit time — that turns a protection into a silent drop of your own real signups.

Allowed domains

Each endpoint has a list of hosts it accepts browser submissions from, plus its project’s domain. Matching is exact host equality — never a substring — so an endpoint that allows example.com refuses example.com.attacker.net and evil-example.com.

  • A request with no Origin and no Referer is allowed: curl and server-to-server calls have neither, and there is no browser credential to protect. The check exists to stop someone embedding your endpoint on their page, which always carries an Origin.
  • An endpoint with no domains configured accepts any origin, so a fresh endpoint isn’t broken before you’ve set it up. Add your domain once it’s live.
  • allow_localhost covers localhost, 127.0.0.1, ::1 and any *.localhost host, for local development.

A refusal returns domain_not_allowed with the host it saw, so you can paste it straight into the allowed list if it was yours.

Email validation

Addresses are checked for syntax and against a maintained list of disposable-email providers, then lowercased before storage. Lists require an address; forms accept a submission without one, because a contact form with only a message is legitimate.

Both refusals are invalid_email, distinguished by reason syntax or disposable— so your form can say something more useful than “invalid”.

Turnstile

Any endpoint can require a Cloudflare Turnstile challenge. Render the widget in your form and its cf-turnstile-response field posts along with everything else — no other change is needed.

reasonWhat happenedWhose fix
missingThe endpoint requires a captcha and no token was posted — usually the widget didn't render.Visitor retries; embedder checks the widget.
rejectedCloudflare saw the token and turned it down. Most often the challenge expired before submit.Visitor retries.
unconfiguredThe endpoint asks for a captcha but the deployment has no Turnstile secret key.The endpoint owner — nothing the visitor does will help.
unavailableCloudflare didn't answer. Carries a retry_after; the submission was not accepted.Visitor retries in a moment.

Turnstile fails closed in all four cases, deliberately. Failing open would mean anyone able to block one outbound call bypasses the captcha entirely. The cost is a lost submission during a Cloudflare outage, on endpoints that opted into a challenge — and the response says which case it was, because three of the four are the visitor’s to retry and one is the owner’s to fix.

Rate limits and the daily cap

  • 10 submissions per minute per address, per endpoint. Stops one source hammering one form.
  • 1,000 submissions per day per endpoint by default, editable in its settings. This is the circuit breaker that catches what per-address limits can’t: a botnet rotating addresses.

Both return rate_limited with retry_after in seconds. Windows are fixed, not rolling, so the value is the time until the current window ends.

What we keep about the sender

A country, taken from the edge, and a salted hash of the address — kept only for abuse forensics. Raw IP addresses are never written to the database. Forms in forward mode store nothing at all: submissions go straight to your own verified address.

PRIVACY FIRST

No form data is stored on our servers. All submissions are forwarded directly to your email. (Forward-mode forms; storage-mode forms keep what you collect until you delete it.)

NEXTAPI referenceThe collect endpoint in full — headers, status codes, CORS — plus what the management API and MCP server will look like.