Bot protection
Cloudflare Turnstile on the five public writes bots abuse — when a token is required, how to send it, and what the refusals mean.
Five public writes are the ones bots actually abuse: they send email, create records, or both. Those five carry a Cloudflare Turnstile check on top of the API key.
The five protected writes
| Route | Why it is protected |
|---|---|
POST /public/leads | Writes a CRM lead and notifies a sales user. |
POST /public/viewing-requests | Books a slot in a real calendar. |
POST /public/auth/register | Sends a verification email. |
POST /public/auth/forgot-password | Sends a password-reset email. |
POST /public/newsletter | Sends a subscription confirmation. |
Nothing else needs a token. Reads never do.
When a token is required
- Called with a publishable key (
pk_…, i.e. from a browser) → a token is required. - Called with a secret key (
sk_…, i.e. from your server) → the check is skipped. You have already authenticated as the site. - The site has no Turnstile configured → the check is skipped entirely, which is what makes local development work.
Getting a site key
Turnstile needs a site key in the browser (a different thing from your Wzgate site key). Set it per site in Settings → Websites → Connect your website → Bot protection, and read it back from the API so your code never hard-codes it:
const { data } = await (
await fetch(`${API}/public/company-profile`, { headers: siteHeaders() })
).json();
const turnstileSiteKey = data.site?.turnstileSiteKey ?? null;null means this site has no bot check — render the form without a widget.
Sending the token
Render the Turnstile widget, take the token it produces, and put it on the
request as X-Turnstile-Token:
export async function submitLead(body: unknown, turnstileToken?: string) {
const res = await fetch(`${API}/public/leads`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...siteHeaders(),
...(turnstileToken ? { 'X-Turnstile-Token': turnstileToken } : {}),
},
body: JSON.stringify(body),
});
const payload = await res.json();
if (!res.ok) throw new Error(payload.code ?? 'LEAD_FAILED');
return payload.data;
}A token is single-use and bound to one challenge. Put it on the one call, never on your HTTP client's default headers — a stale token left on the defaults is sent, and refused, on every write after the first. Reset the widget after each submit.
The token must not go in the request body: the API validates bodies strictly
and an undeclared field is a 400.
Refusals
| Code | Status | Meaning |
|---|---|---|
BOT_CHECK_REQUIRED | 400 | The route needs a token and none arrived. |
BOT_CHECK_FAILED | 403 | Cloudflare rejected the token — expired, already used, or forged. |
Both are recoverable in the UI: reset the widget, ask the visitor to solve it again, and resubmit. Do not retry automatically with the same token; it will fail the same way.
Like the API keys, the bot check ships in monitor mode first — a missing token is logged and the write is served — and is announced in the changelog before it is enforced. Send the token now and the switch is invisible to you.
Keys and CORS
Publishable and secret API keys, the origin rule, rotation and revocation, how CORS follows your site's domains, and the monitor-then-enforce rollout.
Errors
The two error shapes the public API returns, and a table of every code you can branch on — site resolution, keys, bot checks, viewings, validation and rate limits.