---
name: botpoison
description: Replace reCAPTCHA, hCaptcha, Cloudflare Turnstile or another CAPTCHA on a web form with Botpoison Challenge, an invisible proof-of-work check, and optionally add Botpoison Classify to label each submission spam or clean on the server. Use when asked to remove a CAPTCHA, protect a form from bots or spam, or integrate Botpoison.
---

# Replace a CAPTCHA with Botpoison

Botpoison has two products, and one pair of keys from a project works with both.

- **Challenge** replaces the CAPTCHA. The visitor sees nothing. When they press Send, the browser library runs a small proof-of-work check and sends the solution with the form as a field named `_botpoison`. Your server verifies that solution with one call.
- **Classify** reads what was sent. Your server posts the submission and gets back a label, `spam` or `clean`, with a score and the model that answered.

Do Challenge first. Add Classify only when the user asks for spam filtering as well as bot protection.

Every docs page has a Markdown copy at its own address plus `.md`, such as https://botpoison.com/documentation/challenge/getting-started.md, and https://botpoison.com/llms.txt lists them all.

## Before you start

- Keys come from a project in the Botpoison dashboard: https://botpoison.com/start/ . A project has a public key (`pk_...`) for the page and a secret key (`sk_...`) for the server, and both products accept them. The secret key is shown once, when it is issued.
- A secret key never goes in browser code or in the repository. Read it from the server's environment. If the user has no keys yet, use the placeholders below and tell them where to create the project.
- Work in the request handler that checks the CAPTCHA today, so the new check guards the same route.

## 1. Find the CAPTCHA

Search the code for the vendor's markers, then remove each one as you replace it.

| CAPTCHA | In the page | On the server |
| --- | --- | --- |
| reCAPTCHA | `google.com/recaptcha/api.js`, `recaptcha.net`, `g-recaptcha`, `grecaptcha.`, `data-sitekey` | `g-recaptcha-response`, `recaptcha/api/siteverify` |
| hCaptcha | `js.hcaptcha.com`, `h-captcha`, `hcaptcha.`, `data-sitekey` | `h-captcha-response`, `hcaptcha.com/siteverify` |
| Turnstile | `challenges.cloudflare.com/turnstile`, `cf-turnstile`, `turnstile.`, `data-sitekey` | `cf-turnstile-response`, `turnstile/v0/siteverify` |

Also look for wrapper packages such as `react-google-recaptcha`, `react-google-recaptcha-v3`, `@hcaptcha/react-hcaptcha`, `@marsidev/react-turnstile`, `vue-recaptcha` and `ng-recaptcha`, and for the environment variables that hold the old site key and secret.

## 2. Replace the browser side

Pick the path that matches how the form is sent.

### A form the browser posts by itself

Load the script and put the public key on the form. The library holds the submit, runs the check, adds the hidden `_botpoison` field and then submits the form as before.

<!-- Code.HTML -->

```html
<!-- 1. Import the @botpoison/browser script, pinned to one version -->
<script
  src="https://unpkg.com/@botpoison/browser@1.1.0/dist/index.js"
  integrity="sha384-OV9jtDYCk1ORwcQiG2ooB9pcBIpHSRTng9CbA1vObEm7oaL+nRiFC45XXdQNg22i"
  crossorigin="anonymous"
  async
></script>

<!-- 2. Add your public key to the data-botpoison-public-key attribute -->
<form
  action="https://example.demo"
  data-botpoison-public-key="pk_xxxxxxxx"
>
  <input type="text" name="message" />
  <button type="submit">Send</button>
</form>
```

Delete the CAPTCHA's widget element and its script tag. If the form cannot send, the library fires `botpoison-challenge-error` on the form instead of sending it. Listen for it, and for the start and success events, to keep the button and the visitor informed:

<!-- Code.HTML_EVENTS -->

```html
<form
  id="form"
  action="https://example.demo"
  data-botpoison-public-key="pk_xxxxxxxx"
>
  <input type="text" name="message" />
  <button id="button" type="submit">Send</button>
</form>
<script>
  var formElement = document.getElementById("form");
  var buttonElement = document.getElementById("button");
  formElement.addEventListener("botpoison-challenge-start", function () {
    buttonElement.setAttribute("disabled", "disabled");
  });
  formElement.addEventListener("botpoison-challenge-success", function () {
    buttonElement.removeAttribute("disabled");
  });
  formElement.addEventListener("botpoison-challenge-error", function () {
    buttonElement.removeAttribute("disabled");
  });
</script>
```

### A form sent with JavaScript

Install the library with `npm install @botpoison/browser`, call `challenge()` in the submit handler, and send the `solution` as `_botpoison`:

<!-- Code.JAVASCRIPT -->

```js
import axios from "axios";

// 1. Import the library
import Botpoison from "@botpoison/browser";

// 2. Create a new instance with your public key
const botpoison = new Botpoison({
    publicKey: 'pk_xxxxxxxx'
});

const sendMessage = async (message) => {
  // 3. Process a challenge
  const { solution } = await botpoison.challenge();
  await axios.post("https://example.demo", {
    message,
    // 4. Forward the solution
    _botpoison: solution,
  });
}
```

`challenge()` rejects when the check cannot be completed. Catch it and show the visitor a message rather than sending without a solution. Remove the CAPTCHA's token state and its `execute` or `render` calls. The getting started page has the same step for React, Vue, Angular and Svelte, one after another: https://botpoison.com/documentation/challenge/getting-started.md . The browser library's full reference, events and progress included, is at https://botpoison.com/documentation/libraries/botpoison-browser.md .

### Content Security Policy

If the site sends a CSP, allow `https://unpkg.com` in `script-src` when the page loads the script from unpkg, and `https://api.botpoison.com` in `connect-src`. Drop the CAPTCHA vendor's hosts if nothing else uses them.

## 3. Replace the server check

In Node.js, install `@botpoison/node` (Node 18 or later) and verify the `_botpoison` field where the old siteverify call was:

<!-- Code.NODE_JS -->

```js
// 1. Import the library
import Botpoison from "@botpoison/node";

// or const Botpoison = require("@botpoison/node")

// 2. Create a new instance with your secret key, read from your server's
// environment: BOTPOISON_SECRET_KEY=sk_xxxxxxxx
const botpoison = new Botpoison({
  secretKey: process.env.BOTPOISON_SECRET_KEY
});

server.post("/message", async (req, res) => {
  const { message, _botpoison } = req.body;
  // 3. Verify the solution
  const { ok } = await botpoison.verify(_botpoison);
  if (!ok) {
    throw new Error("Invalid Botpoison solution");
  }
  // ...
});
```

In PHP, Python or Go, post the solution to `/verify` yourself, with the secret key as a bearer token:

<!-- Code.CHALLENGE_PHP -->

```php
<?php
// The secret key comes from your server's environment:
// BOTPOISON_SECRET_KEY=sk_xxxxxxxx
$secret_key = getenv("BOTPOISON_SECRET_KEY");

// 1. Send the solution from the form's _botpoison field to /verify
$response = file_get_contents("https://api.botpoison.com/verify", false, stream_context_create(["http" => [
    "method" => "POST",
    "header" => "Content-Type: application/json\r\nAuthorization: Bearer " . $secret_key,
    "content" => json_encode(["solution" => $_POST["_botpoison"]]),
    "ignore_errors" => true,
]]));

// 2. Accept the form only when ok is true
if (!json_decode($response)->ok) {
    http_response_code(403);
    exit;
}
// ...
```

<!-- Code.CHALLENGE_PYTHON -->

```python
import os
import requests
from flask import Flask, abort, request

# The secret key comes from your server's environment:
# BOTPOISON_SECRET_KEY=sk_xxxxxxxx
SECRET_KEY = os.environ["BOTPOISON_SECRET_KEY"]

app = Flask(__name__)

@app.post("/message")
def message():
    # 1. Send the solution from the form's _botpoison field to /verify
    result = requests.post(
        "https://api.botpoison.com/verify",
        headers={"Authorization": f"Bearer {SECRET_KEY}"},
        json={"solution": request.form["_botpoison"]},
        timeout=8,
    ).json()
    # 2. Accept the form only when ok is true
    if not result.get("ok"):
        abort(403)
    # ...
```

<!-- Code.CHALLENGE_GO -->

```go
package main

import (
	"bytes"
	"encoding/json"
	"net/http"
	"os"
)

// The secret key comes from your server's environment:
// BOTPOISON_SECRET_KEY=sk_xxxxxxxx
var secretKey = os.Getenv("BOTPOISON_SECRET_KEY")

func message(w http.ResponseWriter, r *http.Request) {
	// 1. Send the solution from the form's _botpoison field to /verify
	body, _ := json.Marshal(map[string]string{"solution": r.FormValue("_botpoison")})
	req, _ := http.NewRequest("POST", "https://api.botpoison.com/verify", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+secretKey)
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		http.Error(w, "", http.StatusBadGateway)
		return
	}
	defer res.Body.Close()
	// 2. Accept the form only when ok is true
	var result struct{ Ok bool `json:"ok"` }
	json.NewDecoder(res.Body).Decode(&result)
	if !result.Ok {
		http.Error(w, "", http.StatusForbidden)
		return
	}
	// ...
}
```

Rust and Java handlers are on https://botpoison.com/documentation/challenge/getting-started.md . From anything else, the request is:

<!-- Code.HTTP_REQUEST_BEARER -->

```bash
curl --request POST \
  --url 'https://api.botpoison.com/verify' \
  --header 'accept: application/json' \
  --header 'content-type: application/json' \
  --header 'authorization: Bearer sk_xxxxxxxx' \
  --data '{"solution": "xxxxxxxx"}'
```

<!-- Code.HTTP_RESPONSE -->

```json
// The status is 200 for every result. Read "ok" to decide whether to accept
// the submission. When "ok" is false, "message" says why.

// Accepted
{ "ok": true }

// Rejected, with the reason
{ "ok": false, "message": "Invalid solution" }
```

The status is 200 for every result, so read `ok`. A solution verifies once. Reject the submission when `_botpoison` is missing or `ok` is false.

The key can also go in an `X-Api-Key` header or as `secretKey` in the body. When a request carries more than one, `/verify` uses the body's key first, then the bearer token, then `X-Api-Key`. The body can be JSON or form-encoded (`application/x-www-form-urlencoded`) with the same field names, so a server can forward `solution` without building JSON. The full reference is at https://botpoison.com/documentation/challenge/rest-api.md .

## 4. Decide what happens if Botpoison is unreachable

- In the browser, the library cannot run the check, so the form fires `botpoison-challenge-error` instead of sending. Show the visitor a message or another way to reach the site.
- On the server, a request that cannot reach Botpoison fails at the network, and a fault on Botpoison's side answers `ok: false` with the message `Internal server error`.
- `verify()` in the Node library sets no timeout, so wrap the call in one of your own.
- To accept submissions during an outage, key on those two outcomes only. Never accept a submission because its `_botpoison` field is missing: that is what every bot sends.

Ask the user whether they want that fail-open before you write it.

## 5. Test it

With the secret key `test`, the solution decides the result, and test calls are not recorded or counted:

<!-- Code.VERIFICATIONS_SIMULATION -->

```js
// The solution "ok" passes
const OK_REQUEST_DATA = { secretKey: "test", solution: "ok" }
const OK_RESPONSE = { ok: true, message: "Test key" }

// Any other solution fails
const NOT_OK_DATA = { secretKey: "test", solution: "nok" }
const NOT_OK_RESPONSE = { ok: false, message: "Test key" }
```

The page still needs a real public key to run the check.

## 6. Optional: add Classify

Classify runs on the server after the Challenge check passes. It takes the same secret key as the Challenge check:

<!-- Code.CLASSIFY_ENV -->

```bash
# .env on your server
BOTPOISON_SECRET_KEY=sk_xxxxxxxx
```

In Node.js, send the whole form and act on the label. `classify()` needs `@botpoison/node` 0.2.0 or later, so check the installed version before you use it:

<!-- Code.CLASSIFY_NODE -->

```js
// 1. Import the library
import Botpoison from "@botpoison/node";

// or const Botpoison = require("@botpoison/node")

// 2. Create a new instance with your secret key, read from your server's
// environment: BOTPOISON_SECRET_KEY=sk_xxxxxxxx
const botpoison = new Botpoison({
  secretKey: process.env.BOTPOISON_SECRET_KEY
});

server.post("/contact", async (req, res) => {
  // 3. Classify the whole form and read the label
  const { label } = await botpoison.classify(req.body, {
    model: "spam-standard",
    timeout: 8000,
  });
  if (label === "spam") {
    // Drop it, or file it away
    return res.status(202).end();
  }
  // ...
});
```

If the same handler verifies Challenge, give the two instances different names, since each takes its own product's secret key. The library's options and errors are at https://botpoison.com/documentation/libraries/botpoison-node.md . PHP, Python, Go, Rust and Java handlers are on https://botpoison.com/documentation/classify/getting-started.md . From any other language, post the fields to `/classify`:

<!-- Code.CLASSIFY_CURL -->

```bash
curl --request POST \
  --url 'https://api.botpoison.com/classify' \
  --header 'accept: application/json' \
  --header 'content-type: application/json' \
  --header 'authorization: Bearer sk_xxxxxxxx' \
  --data '{
    "content": {
      "name": "Jo",
      "email": "jo@example.com",
      "message": "Hello, could you send me a quote?"
    },
    "model": "spam-standard"
  }'
```

A server that already has the form as fields can send it form-encoded instead. The form is the content, and two field names are reserved and never classified: `secretKey` for the key and `_model` for the model. Every other field whose name starts with `_` is ignored, as it is in a JSON body:

<!-- Code.CLASSIFY_FORM -->

```bash
curl https://api.botpoison.com/classify \
  --header 'X-Api-Key: sk_xxxxxxxx' \
  --data-urlencode '_model=spam-standard' \
  --data-urlencode 'name=Jo' \
  --data-urlencode 'email=jo@example.com' \
  --data-urlencode 'message=Hello, could you send me a quote?'
```

The key can go in a bearer token, an `X-Api-Key` header or the body's `secretKey`. Unlike `/verify`, `/classify` refuses a request whose places name different keys, with a 401.

Either way, the answer is a verdict:

<!-- Code.CLASSIFY_RESPONSE -->

```json
{
  "label": "clean",
  "score": 0.04,
  "model": "spam-standard"
}
```

Act on `label`. It is Botpoison's recommended cut on the score: `spam` from 0.8 and `clean` below it, so the two never disagree. To hold borderline messages for a person, see https://botpoison.com/documentation/classify/labels-and-scores.md .

Every request names its model in `model` (`_model` in a form-encoded body). `spam-standard` is the only model today. Botpoison moves it to new releases under the same name, and every release is fitted to the same score table, so thresholds keep working. See https://botpoison.com/documentation/classify/models.md .

When Classify refuses or cannot answer, the library throws, and the REST API answers with one of these statuses:

- 400: the body is malformed, the content is empty, too large or nested, or `model` names no model. Fix the request.
- 401: the key is missing, wrong or rerolled, or differs between the places it was sent. Fix the key.
- 429: the key is past its rate limit (`rate_limited`), or a Free workspace has paused after its allowance (`quota_exceeded`). Wait the seconds `Retry-After` names.
- 503: the model is unavailable. The call is not counted. Retry later with backoff.

Deliver the message as clean when the call fails, so an outage never loses a real one:

<!-- Code.CLASSIFY_NODE_ERRORS -->

```js
try {
  const { label } = await botpoison.classify(req.body, {
    model: "spam-standard",
  });
  // ...
} catch (error) {
  if (error instanceof Botpoison.ClassifyError) {
    // error.code is "http", "timeout", "network" or "invalid_response".
    // error.status is the HTTP status when Classify answered.
    console.error(error.code, error.status, error.message);
  }
  // Deliver the message as clean, so an outage never loses a real one
}
```

To test it, use the secret key `test`: a message containing `botpoison-test-spam` answers `spam`, one containing `botpoison-test-review` answers `clean` with a score of 0.6, one containing `botpoison-test-unavailable` answers 503, and anything else answers `clean`. See https://botpoison.com/documentation/classify/test-mode.md .

## Plan limits

Tell the user about these before they rely on the Free plan in production. Usage counts the last 30 days, not the calendar month.

- The Free plan allows 1,000 challenges and 50 classifications over the last 30 days.
- On the Free plan, Classify keeps answering for 7 days after its allowance is reached, then pauses until the last 30 days drop back under it. Challenge never stops. While it is paused, `/classify` answers 429 with `quota_exceeded`.
- Every Free workspace one person creates shares one Classify allowance, so a second workspace does not add to it.
- Paid plans are never cut off.

Each project also has a Classify rate limit: a burst it can send at once, then a steady rate per second. A call past it answers 429 with `rate_limited` and a `Retry-After` header, and every counted answer carries `RateLimit` and `RateLimit-Policy` headers so a client can slow down first.

| Plan | Per second | Burst |
| --- | --- | --- |
| Free | 2 | 10 |
| Standard | 10 | 50 |
| Plus | 25 | 100 |
| Pro | 50 | 200 |

See https://botpoison.com/documentation/platform/usage-and-limits.md and https://botpoison.com/pricing/ .

## Done when

- No CAPTCHA script, widget, token field, package or siteverify call is left.
- The public key is in the page, and every secret key is read from the server's environment.
- The server rejects a submission whose `_botpoison` is missing or whose verify answers `ok: false`.
- The page tells the visitor when `botpoison-challenge-error` fires or `challenge()` rejects.
- If Classify was added, it runs after the Challenge check, acts on `label`, and delivers the message as clean when Classify cannot answer.
