Challenge migration guide

# Migrate from Turnstile to Botpoison Challenge

Three edits: swap Cloudflare's script for ours, move the key onto the form, and replace the siteverify call with one verify call. Your form keeps its fields, its layout and its thank-you page.

Last verified 16 October 2026

## Why sites move off Turnstile

Turnstile is free and often invisible, so the reasons to move off it are narrower than with a puzzle CAPTCHA.

Turnstile gets its token when the page loads. Challenge runs its check fresh on each Send, so there is no token to expire while someone fills in a long form.

The free plan allows 20 widgets, 10 hostnames for each, and keeps Cloudflare's branding on the widget.

And Turnstile only answers whether the browser looks like a person. Spam a person types still arrives. Botpoison puts [Classify](https://botpoison.com/classify/) for that on the same plan.

## What maps to what

|  | Turnstile | Challenge |
| --- | --- | --- |
| Script | `/turnstile/v0/api.js` on challenges.cloudflare.com | `unpkg.com/@botpoison/browser` |
| On the form | A `cf-turnstile` div with `data-sitekey` | `data-botpoison-public-key` on the form itself |
| Field posted | `cf-turnstile-response` | `_botpoison` |
| Server check | POST to `/turnstile/v0/siteverify` with the secret and the response | The same form POSTed to `/verify`, with `secretKey` and `solution` |
| Result | `success`, with the `action` and `cdata` the widget carried | `ok`, true or false |

## The three edits. Off Turnstile, onto Challenge

### Swap the script

Remove Cloudflare's script tag and add ours. Ours can be cached or served from your own domain.

HTML

```diff
-<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
+<script src="https://unpkg.com/@botpoison/browser" async></script>
```

### Swap the key on the form

Delete the widget's div and put your public key on the form itself. Nothing renders in its place.

HTML

```diff
 <form
   action="/contact"
   method="POST"
+  data-botpoison-public-key="pk_xxxxxxxx"
 >
   <textarea name="message"></textarea>
-  <div class="cf-turnstile" data-sitekey="your_site_key"></div>
   <button type="submit">Send</button>
 </form>
```

### Swap the check on your server

Post the same form to /verify: secretKey and _botpoison in place of secret and cf-turnstile-response, and ok in place of success.

Node.js:

JavaScript

```diff
-const cloudflare = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
+const verified = await fetch("https://api.botpoison.com/verify", {
   method: "POST",
   body: new URLSearchParams({
-    secret: process.env.TURNSTILE_SECRET,
-    response: req.body["cf-turnstile-response"],
+    secretKey: process.env.BOTPOISON_CHALLENGE_SECRET_KEY,
+    solution: req.body._botpoison,
   }),
 });
-const { success: ok } = await cloudflare.json();
+const { ok } = await verified.json();
 if (!ok) return res.status(403).end();
```

PHP:

PHP

```diff
-$cloudflare = file_get_contents("https://challenges.cloudflare.com/turnstile/v0/siteverify", false, stream_context_create(["http" => [
+$verified = file_get_contents("https://api.botpoison.com/verify", false, stream_context_create(["http" => [
   "method" => "POST",
   "header" => "Content-Type: application/x-www-form-urlencoded",
   "content" => http_build_query([
-    "secret" => getenv("TURNSTILE_SECRET"),
-    "response" => $_POST["cf-turnstile-response"],
+    "secretKey" => getenv("BOTPOISON_CHALLENGE_SECRET_KEY"),
+    "solution" => $_POST["_botpoison"],
   ]),
 ]]));
-$ok = json_decode($cloudflare)->success;
+$ok = json_decode($verified)->ok;
 if (!$ok) { http_response_code(403); exit; }
```

Python:

Python

```diff
-cloudflare = requests.post("https://challenges.cloudflare.com/turnstile/v0/siteverify", data={
-    "secret": os.environ["TURNSTILE_SECRET"],
-    "response": request.form["cf-turnstile-response"],
+verified = requests.post("https://api.botpoison.com/verify", data={
+    "secretKey": os.environ["BOTPOISON_CHALLENGE_SECRET_KEY"],
+    "solution": request.form["_botpoison"],
 })
-ok = cloudflare.json()["success"]
+ok = verified.json()["ok"]
 if not ok:
     abort(403)
```

Go:

Go

```diff
-res, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", url.Values{
-  "secret":   {os.Getenv("TURNSTILE_SECRET")},
-  "response": {r.FormValue("cf-turnstile-response")},
+res, err := http.PostForm("https://api.botpoison.com/verify", url.Values{
+  "secretKey": {os.Getenv("BOTPOISON_CHALLENGE_SECRET_KEY")},
+  "solution":  {r.FormValue("_botpoison")},
 })
 if err != nil {
   http.Error(w, "", http.StatusBadGateway)
   return
 }
-var out struct{ Ok bool `json:"success"` }
+var out struct{ Ok bool `json:"ok"` }
 json.NewDecoder(res.Body).Decode(&out)
 if !out.Ok {
   http.Error(w, "", http.StatusForbidden)
   return
 }
```

HTTP:

Shell

```diff
-curl "https://challenges.cloudflare.com/turnstile/v0/siteverify" \
-  --data-urlencode "secret=$TURNSTILE_SECRET" \
-  --data-urlencode "response=<the cf-turnstile-response field>"
-# {"success": true, ...}
+curl "https://api.botpoison.com/verify" \
+  --data-urlencode "secretKey=$BOTPOISON_CHALLENGE_SECRET_KEY" \
+  --data-urlencode "solution=<the _botpoison field>"
+# {"ok": true, ...}
```

## What behaves differently

No widget. Nothing renders on the page, so there is no widget size, theme or language to set.

The check runs on Send. Turnstile renders and runs when the page loads. Challenge starts when the visitor presses Send, so nothing runs for a visitor who never submits.

No render calls. If your code called `turnstile.render()`, the attribute on the form replaces it, or use [the browser library from JavaScript](https://botpoison.com/documentation/challenge/getting-started/#javascript) for a form you submit yourself.

## Questions

**Do I need to change anything on Cloudflare?**

No. Turnstile works whether or not a site runs on Cloudflare, and so does Challenge. Your DNS, proxy and firewall rules stay as they are.

**Can I keep the firewall in front of the site?**

Yes. Challenge runs in the browser and is checked by your server, so whatever sits in front of the site is not involved.

**Moving from reCAPTCHA or hCaptcha instead?**

See the guides [from reCAPTCHA](https://botpoison.com/migrate/from-recaptcha/) and [from hCaptcha](https://botpoison.com/migrate/from-hcaptcha/).

## Sources

- [Validate the token](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/) Cloudflare Turnstile documentation
- [Turnstile plans](https://developers.cloudflare.com/turnstile/plans/) Cloudflare Turnstile documentation

Turnstile is a trademark of Cloudflare. Botpoison is not affiliated with Cloudflare.

## Other migration guides

### To Challenge

Invisible CAPTCHA alternative

- [From reCAPTCHA](https://botpoison.com/migrate/from-recaptcha/)
  v2 checkbox, v2 invisible or v3 score. Swap the script, the key and the siteverify call.
- [From hCaptcha](https://botpoison.com/migrate/from-hcaptcha/)
  Remove the h-captcha div and the form-encoded siteverify request. Nobody labels images again.

### To Classify

Spam classifier

- [From Akismet](https://botpoison.com/migrate/from-akismet/)
  comment-check answers "true" or "false" as text. Classify answers a label, and a score to hold the borderline ones on.
- [From OOPSpam](https://botpoison.com/migrate/from-oopspam/)
  A score from 0 to 6 against your threshold becomes a label, with the score kept for a review queue.
