# Vanilla HTML For static sites or any project without a JS framework. The widget renders client-side; the form submits to whatever backend handles your form (a Node/PHP/Ruby/Go server, a Cloudflare Worker, a Pages Function, a third-party form host that supports server-side hooks, etc.). ```html
``` When the form submits, the browser includes `cf-turnstile-response` automatically. Your backend reads it and calls canonical siteverify. ## Backend (any language) Add this to your existing `/api/subscribe` handler before the rest of its logic: ```js // Node / fetch idiom const expectedHostnames = new Set( (process.env.TURNSTILE_HOSTNAMES ?? '') .split(',') .map((h) => h.trim()) .filter(Boolean), ); if (expectedHostnames.size === 0) return res.status(403).end(); const token = req.body['cf-turnstile-response']; const r = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ secret: process.env.TURNSTILE_SECRET, response: token, remoteip: req.ip, }), }); const result = await r.json(); if ( r.ok !== true || result.success !== true || result.action !== 'subscribe' || !expectedHostnames.has(result.hostname) ) { return res.status(403).end(); } // existing handler logic runs here ``` Equivalent calls in other backend languages (each also compares `result.hostname` to a `TURNSTILE_HOSTNAMES` allowlist): ```ruby # Ruby require 'net/http'; require 'uri'; require 'json'; require 'set' expected_hostnames = (ENV['TURNSTILE_HOSTNAMES'] || '').split(',').map(&:strip).reject(&:empty?).to_set halt 403 if expected_hostnames.empty? res = Net::HTTP.post_form(URI('https://challenges.cloudflare.com/turnstile/v0/siteverify'), secret: ENV['TURNSTILE_SECRET'], response: params['cf-turnstile-response'], remoteip: request.ip) result = JSON.parse(res.body) halt 403 unless res.is_a?(Net::HTTPSuccess) && result['success'] == true && result['action'] == 'subscribe' && expected_hostnames.include?(result['hostname']) ``` ```python # Python (requests) expected_hostnames = {h.strip() for h in os.environ.get('TURNSTILE_HOSTNAMES', '').split(',') if h.strip()} if not expected_hostnames: return '', 403 r = requests.post('https://challenges.cloudflare.com/turnstile/v0/siteverify', data={'secret': os.environ['TURNSTILE_SECRET'], 'response': form['cf-turnstile-response'], 'remoteip': request.remote_addr}) result = r.json() if (not r.ok or result.get('success') is not True or result.get('action') != 'subscribe' or result.get('hostname') not in expected_hostnames): return '', 403 ``` `subscribe` is the stable action for this surface. Preserve an existing custom migration action and compare the returned action to the same value. Siteverify is mandatory for every widget mode, including pre-clearance. Set `TURNSTILE_HOSTNAMES` to the deployment-specific frontend hostnames; a production value must not include `localhost` or `127.0.0.1`. ## Variant: AJAX submit instead of form action For an AJAX flow, replace the native form and API script with explicit rendering. Keep this surface's widget ID and reset it in `finally`, which covers network, JSON, validation, and server failures as well as successful same-page completion. ```html ``` ## No backend? If your project is pure-static (no server-side handler — just HTML served from a CDN), Spin doesn't apply. Siteverify is server-side by design. Options: - Add a Cloudflare Pages Function (`functions/api/subscribe.js`) to host the siteverify call. - Deploy a tiny Cloudflare Worker that does siteverify against your existing form host. - Use a third-party form host that exposes a server-side webhook where you can wire siteverify. ## Substitutions | Placeholder | Replace with | | ------------------- | -------------------------------------------------------------------- | | `YOUR_SITEKEY` | The widget site key from Step 8 | | `/api/subscribe` | The path to your existing form-handling endpoint | | `TURNSTILE_SECRET` | Env-var name. Value is the secret captured in Step 8, kept off disk. |