WzGate
Build your own website

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

RouteWhy it is protected
POST /public/leadsWrites a CRM lead and notifies a sales user.
POST /public/viewing-requestsBooks a slot in a real calendar.
POST /public/auth/registerSends a verification email.
POST /public/auth/forgot-passwordSends a password-reset email.
POST /public/newsletterSends 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

CodeStatusMeaning
BOT_CHECK_REQUIRED400The route needs a token and none arrived.
BOT_CHECK_FAILED403Cloudflare 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.

On this page