Public contact forms attract bots. Postboi ships two invisible layers of protection — no puzzles, no checkboxes, nothing for real visitors to do. Both run automatically on every FormData send, with every provider — and on the Postboi provider the captcha is fully managed: no Cloudflare account, no keys, no env vars.
The Captcha component
The fastest way to get everything on this page: drop <Captcha /> — no props — inside
your own <form>. Your form stays a native form (bring any form library you like); the
component renders the honeypot and activates the managed invisible captcha on the
surrounding form, including forms mounted on client-side navigations.
Where does the key come from? bunx postboi init commits your account’s publishable
key to postboi.config.ts (captcha: { key: "pk_…" } — it’s publishable, so
committing is safe), and bunx postboi sync — already in your prepare script — bakes
it from there into the installed package on every install. Tokenless environments (CI)
keep the captcha because the committed config is the source of truth; a POSTBOI_TOKEN just lets sync refresh the key (and the generated from types) from the Postboi provider.
Upgrading from an earlier init? Run bunx postboi sync once with your token and it
writes the key into your config for you. If the component warns that no key is baked,
re-run bunx postboi sync (and restart the dev server), or pass one explicitly with pk="pk_…" from your dashboard.
Props, all optional: pk (key override), origin (loader origin), and honeypot (false to render no honeypot field). Without any key the component is honeypot-only and
logs a console warning. No component? The rest of this page shows the plain-HTML pieces
it renders.
The honeypot — zero config
Add a visually hidden field named 🍯 to your form:
That’s it. Humans never see the field, so it arrives empty. Bots auto-fill every input they
find — and any submission with a filled 🍯 is treated as spam:
- No email is sent.
- The field never appears in your emails (it’s stripped even when empty).
- With
postboi/kit, the action still returns{ success: true }— the bot sees a normal successful submission and learns nothing.
Prefer a class over inline styles if you like, but avoid
display: none— smarter bots skip fields they can detect as hidden that way. Off-screen positioning is more effective.
Calling mail() directly instead of using postboi/kit? A tripped honeypot throws a SpamError (code: "spam"), so you decide what to do — usually pretend success:
Spam skips are intentional, so they never fire the on.error hook — your Sentry
stays quiet.
Managed captcha — the Postboi provider, zero keys
On the Postboi provider, invisible captcha is fully managed: no Cloudflare account, no keys to create, no env vars. Grab the snippet from your dashboard, drop it on the page, and mark the form:
That’s the whole setup. Behind the scenes, Postboi mints a Cloudflare Turnstile widget for your
account the first time the script runs (registering each hostname it sees, localhost
included), renders an invisible challenge into every form[data-captcha], and verifies
the token server-side when the send arrives. The data-key is publishable — it’s safe in
your HTML; the widget secret never leaves Postboi.
Once your account has a widget, form sends are enforced: a form submission arriving
without a valid token — a bot POSTing straight to your action — is rejected with captcha_failed, which postboi/kit surfaces as fail(400, { error }). Sends you make
from code (a string body) are never captcha-checked, so transactional email is
unaffected. Got a form that shouldn’t be verified? Opt it out per send:
Bring your own Turnstile — one env var
Using your own provider (Resend, Mailgun, …), or want your own Cloudflare account? Turnstile is still baked in — two steps:
1. Add the widget to your form (get your keys here):
2. Set the secret key in your environment:
Done. The widget injects a cf-turnstile-response token into the form, and Postboi
verifies it with Cloudflare before every FormData send. The token is stripped from the
email, and invalid or missing tokens are rejected with a PostboiError (code: "captcha_failed"). A configured secret always wins — set one on the Postboi provider and
verification happens locally instead of via managed captcha.
Setting
TURNSTILE_SECRET_KEYenforces Turnstile on every FormData send. That’s deliberate — if a bot could skip the widget and POST directly, the captcha would be decorative. Opt individual sends out withcaptcha: { turnstile: false }.
On runtimes without ambient env vars (Cloudflare Workers), pass the secret explicitly:
Testing? Cloudflare provides dummy keys —
the 1x… secret always passes, the 2x… secret always fails.
Configuration
Both layers work with zero config, but everything is adjustable — globally in postboi.config.ts, per provider instance, or per send (most specific wins):
Error codes
Verification fails closed: if Cloudflare can’t be reached, the send is rejected rather than waved through.