2026-08-09 14:53:46 -04:00
# Vanilla HTML
2026-08-13 21:04:57 -04:00
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.).
2026-08-09 14:53:46 -04:00
```html
<!doctype html>
< html >
< head >
< script
src = "https://challenges.cloudflare.com/turnstile/v0/api.js"
async
defer
></ script >
</ head >
< body >
2026-08-13 21:04:57 -04:00
< form action = "/api/subscribe" method = "POST" >
2026-08-09 14:53:46 -04:00
< input name = "email" type = "email" required />
< div
class = "cf-turnstile"
data-sitekey = "YOUR_SITEKEY"
2026-08-13 21:04:57 -04:00
data-action = "subscribe"
2026-08-09 14:53:46 -04:00
></ div >
< button type = "submit" > Subscribe</ button >
</ form >
</ body >
</ html >
```
2026-08-13 21:04:57 -04:00
When the form submits, the browser includes `cf-turnstile-response` automatically. Your backend reads it and calls canonical siteverify.
2026-08-09 14:53:46 -04:00
2026-08-13 21:04:57 -04:00
## Backend (any language)
2026-08-09 14:53:46 -04:00
2026-08-13 21:04:57 -04:00
Add this to your existing `/api/subscribe` handler before the rest of its logic:
2026-08-09 14:53:46 -04:00
2026-08-13 21:04:57 -04:00
```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 ();
2026-08-09 14:53:46 -04:00
2026-08-13 21:04:57 -04:00
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` .
2026-08-09 14:53:46 -04:00
## Variant: AJAX submit instead of form action
2026-08-13 21:04:57 -04:00
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.
2026-08-09 14:53:46 -04:00
```html
2026-08-13 21:04:57 -04:00
< form id = "subscribe-form" >
< input name = "email" type = "email" required />
< div id = "subscribe-turnstile" ></ div >
< button type = "submit" > Subscribe</ button >
</ form >
2026-08-09 14:53:46 -04:00
< script >
2026-08-13 21:04:57 -04:00
let subscribeWidgetId ;
window . onSubscribeTurnstileLoad = () => {
subscribeWidgetId = window . turnstile . render ( "#subscribe-turnstile" , {
sitekey : "YOUR_SITEKEY" ,
action : "subscribe" ,
2026-08-09 14:53:46 -04:00
});
2026-08-13 21:04:57 -04:00
};
document . getElementById ( "subscribe-form" ). addEventListener ( "submit" , async ( event ) => {
event . preventDefault ();
try {
const res = await fetch ( "/api/subscribe" , {
method : "POST" ,
body : new FormData ( event . currentTarget ),
});
const json = await res . json ();
if ( ! res . ok || json . ok !== true ) throw new Error ( "Submission failed" );
2026-08-09 14:53:46 -04:00
// proceed
2026-08-13 21:04:57 -04:00
} catch {
// surface the error
} finally {
if ( subscribeWidgetId !== undefined ) {
window . turnstile . reset ( subscribeWidgetId );
}
2026-08-09 14:53:46 -04:00
}
});
</ script >
2026-08-13 21:04:57 -04:00
< script
src = "https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onSubscribeTurnstileLoad&render=explicit"
async
defer
></ script >
2026-08-09 14:53:46 -04:00
```
2026-08-13 21:04:57 -04:00
## 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. |