Challenge migration guide

# Migrate from hCaptcha to Botpoison Challenge

Three edits: swap hCaptcha'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 hCaptcha

hCaptcha asks some visitors to label images. That is the step people give up on, and the one many people with low vision or a screen reader cannot do at all.

Its siteverify call takes form-encoded data only, and a risk score comes with the Enterprise plan. On the other plans the answer is pass or fail.

And a CAPTCHA only asks whether a person is there. Spam a person types still arrives. Botpoison puts [Classify](https://botpoison.com/classify/) for that on the same plan.

## What maps to what

|  | hCaptcha | Challenge |
| --- | --- | --- |
| Script | `js.hcaptcha.com/1/api.js` | `unpkg.com/@botpoison/browser` |
| On the form | An `h-captcha` div with `data-sitekey` | `data-botpoison-public-key` on the form itself |
| Field posted | `h-captcha-response` | `_botpoison` |
| Server check | POST to `api.hcaptcha.com/siteverify`, form-encoded, with the secret and the response | The same form POSTed to `/verify`, with `secretKey` and `solution` |
| Result | `success`, and on Enterprise a `score` | `ok`, true or false |

## The three edits. Off hCaptcha, onto Challenge

### Swap the script

Remove hCaptcha's script tag and add ours. It loads async, like theirs did.

HTML

```diff
-<script src="https://js.hcaptcha.com/1/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="h-captcha" 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 h-captcha-response, and ok in place of success.

Node.js:

JavaScript

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

PHP:

PHP

```diff
-$hcaptcha = file_get_contents("https://api.hcaptcha.com/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("HCAPTCHA_SECRET"),
-    "response" => $_POST["h-captcha-response"],
+    "secretKey" => getenv("BOTPOISON_CHALLENGE_SECRET_KEY"),
+    "solution" => $_POST["_botpoison"],
   ]),
 ]]));
-$ok = json_decode($hcaptcha)->success;
+$ok = json_decode($verified)->ok;
 if (!$ok) { http_response_code(403); exit; }
```

Python:

Python

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

Go:

Go

```diff
-res, err := http.PostForm("https://api.hcaptcha.com/siteverify", url.Values{
-  "secret":   {os.Getenv("HCAPTCHA_SECRET")},
-  "response": {r.FormValue("h-captcha-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://api.hcaptcha.com/siteverify" \
-  --data-urlencode "secret=$HCAPTCHA_SECRET" \
-  --data-urlencode "response=<the h-captcha-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, and nobody is shown an image.

No reCAPTCHA compatibility hook. hCaptcha can stand in for `grecaptcha`. Challenge does not, so remove any `hcaptcha` or `grecaptcha` calls with the script, and read `_botpoison` on the server.

The check runs on Send. Nothing runs while the page loads, so a visitor who never submits costs nothing.

## Questions

**Does Challenge fill in the field hCaptcha used?**

No. Challenge posts one field, `_botpoison`, and your server reads that one.

**Is there a test key, like hCaptcha's?**

Yes. With the secret key `test`, the solution `ok` passes and any other fails. See [test mode](https://botpoison.com/documentation/challenge/test-mode/).

**Moving from reCAPTCHA or Turnstile instead?**

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

## Sources

- [hCaptcha developer guide](https://docs.hcaptcha.com/) hCaptcha documentation
- [Switch from reCAPTCHA to hCaptcha](https://docs.hcaptcha.com/switch) hCaptcha documentation

hCaptcha is a trademark of hCaptcha. Botpoison is not affiliated with hCaptcha.

## 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 Turnstile](https://botpoison.com/migrate/from-turnstile/)
  Drop the cf-turnstile widget. The check runs fresh on each Send and moves to one verify call.

### 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.
