Backend examples
On the endpoint that receives your form, read the captchaapi_response field and
verify it with one server-to-server POST to /api/v1/captcha/verify,
authenticated with your project's secret key. Allow the form through only when the response
says success is true.
Verification is three steps, the same in every language:
- Read the
captchaapi_responsefield from the inbound form POST. - POST
{"response": "<that value>"}tohttps://captchaapi.eu/api/v1/captcha/verifywith anAuthorization: Bearer <secret key>header. - Parse the JSON and branch on
success. HTTP 200 does not mean pass - read the field.
Store the secret key in an environment variable. Never commit it to your
repo, never ship it to the browser, never log it. It's a single key, format
sk_live_... - there is no key list.
Single-use is enforced on our side. A given captchaapi_response
verifies exactly once at the verify call. You don't run a replay cache - replay protection
is part of the verify endpoint.
WordPress
No code required. The official captchaapi.eu plugin handles verification and replay protection internally, so none of the per-language work below applies. Install it, paste your keys, and the login, registration, lost-password, and comment forms plus Contact Form 7 are protected.
Install it from the WordPress.org plugin directory; the source is on GitHub. To protect a form the plugin doesn't cover - WooCommerce, a custom login form, or anything outside WordPress - verify the response yourself using the examples below.
Laravel
Use the official package. For Laravel projects the recommended path iscaptchaapi/laravel- install withcomposer require captchaapi/laraveland you get the verify rule, a Blade component for the widget, and a Livewire trait for nativewire:submitforms, all pre-wired. Source on GitHub.
The hand-rolled snippet below stays as a reference for projects that prefer to vendor the verification logic themselves, or for understanding what the package does internally.
The clean place is a custom validation rule. Put your keys in config/services.php:
// config/services.php
'captcha' => [
'site_key' => env('CAPTCHA_SITE_KEY'),
'secret_key' => env('CAPTCHA_SECRET_KEY'),
],
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Http;
class CaptchaResponse implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value) || $value === '') {
$fail('Captcha verification failed.');
return;
}
$response = Http::withToken(config('services.captcha.secret_key'))
->acceptJson()
->post('https://captchaapi.eu/api/v1/captcha/verify', [
'response' => $value,
]);
if ($response->json('success') !== true) {
$fail('Captcha verification failed.');
}
}
}
Use it in any form request:
public function rules(): array
{
return [
'email' => ['required', 'email'],
'captchaapi_response' => ['required', 'string', new CaptchaResponse()],
];
}
Plain PHP
<?php
function verifyCaptcha(string $response, string $secretKey): bool
{
$ch = curl_init('https://captchaapi.eu/api/v1/captcha/verify');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $secretKey,
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode(['response' => $response]),
]);
$body = curl_exec($ch);
curl_close($ch);
if ($body === false) {
return false;
}
$result = json_decode($body, true);
return is_array($result) && ($result['success'] ?? false) === true;
}
$ok = verifyCaptcha(
$_POST['captchaapi_response'] ?? '',
getenv('CAPTCHA_SECRET_KEY'),
);
if (! $ok) {
http_response_code(422);
exit('Captcha failed.');
}
Node.js
export async function verifyCaptcha(response, secretKey) {
if (typeof response !== 'string' || response === '') return false;
const res = await fetch('https://captchaapi.eu/api/v1/captcha/verify', {
method: 'POST',
headers: {
'Authorization': `Bearer ${secretKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ response }),
});
const result = await res.json();
return result.success === true;
}
// Express example
app.post('/contact', async (req, res) => {
const ok = await verifyCaptcha(
req.body.captchaapi_response,
process.env.CAPTCHA_SECRET_KEY,
);
if (!ok) return res.status(422).send('Captcha failed.');
// proceed with the submission
});
Python
import os
import requests
def verify_captcha(response: str, secret_key: str) -> bool:
if not response:
return False
result = requests.post(
"https://captchaapi.eu/api/v1/captcha/verify",
headers={"Authorization": f"Bearer {secret_key}"},
json={"response": response},
timeout=5,
).json()
return result.get("success") is True
# Flask example
@app.post("/contact")
def contact():
ok = verify_captcha(
request.form.get("captchaapi_response", ""),
os.environ["CAPTCHA_SECRET_KEY"],
)
if not ok:
return "Captcha failed.", 422
# proceed
Ruby
require 'json'
require 'net/http'
require 'uri'
def verify_captcha(response, secret_key)
return false if response.to_s.empty?
uri = URI('https://captchaapi.eu/api/v1/captcha/verify')
request = Net::HTTP::Post.new(uri)
request['Authorization'] = "Bearer #{secret_key}"
request['Content-Type'] = 'application/json'
request.body = JSON.generate(response: response)
http_response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(request)
end
JSON.parse(http_response.body)['success'] == true
rescue StandardError
false
end
# Rails example
class ContactsController < ApplicationController
def create
ok = verify_captcha(
params[:captchaapi_response].to_s,
ENV.fetch('CAPTCHA_SECRET_KEY'),
)
return render(plain: 'Captcha failed.', status: :unprocessable_entity) unless ok
# proceed with the submission
end
end
Go
package captcha
import (
"bytes"
"encoding/json"
"net/http"
)
type verifyResult struct {
Success bool `json:"success"`
ErrorCode string `json:"error_code"`
OverLimit bool `json:"over_limit"`
}
// Verify returns true only when the response checks out. Pass the
// captchaapi_response field and your secret key.
func Verify(response, secretKey string) bool {
if response == "" {
return false
}
body, err := json.Marshal(map[string]string{"response": response})
if err != nil {
return false
}
req, err := http.NewRequest(
http.MethodPost,
"https://captchaapi.eu/api/v1/captcha/verify",
bytes.NewReader(body),
)
if err != nil {
return false
}
req.Header.Set("Authorization", "Bearer "+secretKey)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return false
}
defer res.Body.Close()
var result verifyResult
if err := json.NewDecoder(res.Body).Decode(&result); err != nil {
return false
}
return result.Success
}
Java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
public final class CaptchaVerifier {
private static final HttpClient CLIENT = HttpClient.newHttpClient();
private static final ObjectMapper MAPPER = new ObjectMapper();
/** Returns true only when the response checks out. */
public static boolean verify(String response, String secretKey) {
if (response == null || response.isEmpty()) return false;
try {
String body = MAPPER.writeValueAsString(java.util.Map.of("response", response));
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://captchaapi.eu/api/v1/captcha/verify"))
.header("Authorization", "Bearer " + secretKey)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> res = CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
JsonNode result = MAPPER.readTree(res.body());
return result.path("success").asBoolean(false);
} catch (Exception e) {
return false;
}
}
}
Quick test with curl
To run the flow end to end by hand, request a challenge, solve it, then verify the
token.solution string the same way your backend would. The bash solver loop
below stands in for the browser Web Worker. Substitute YOUR_SITE_KEY and
YOUR_SECRET_KEY with your project's real values from the dashboard.
#!/usr/bin/env bash
set -euo pipefail
SITE_KEY='YOUR_SITE_KEY'
SECRET_KEY='YOUR_SECRET_KEY'
# ── 1. Request a challenge ────────────────────────────────────────────────
CHALLENGE=$(curl -s -X POST https://captchaapi.eu/api/v1/captcha/challenge \
-H 'Content-Type: application/json' \
-d "{\"site_key\":\"${SITE_KEY}\"}")
TOKEN=$(echo "$CHALLENGE" | jq -r .token)
TARGET=$(echo "$CHALLENGE" | jq -r .target)
echo "token = $TOKEN"
echo "target = $TARGET (first 8 hex chars of sha256(token+nonce) must be ≤ this)"
# ── 2. Find a nonce (brute-force solver - for testing only) ───────────────
# In production the browser does this in a Web Worker. The bash version
# below forks `sha256sum` per nonce, so it runs at roughly 500 hashes/s
# on an M-series MacBook - expect 5 s on the easy end of the difficulty
# range and up to ~2 minutes on the hard end. Swap in the
# Python solver if you want sub-second turnaround during development.
NONCE=0
while :; do
HASH=$(printf '%s%s' "$TOKEN" "$NONCE" | sha256sum | cut -c1-8)
HASH_DEC=$((16#$HASH))
if [ "$HASH_DEC" -le "$TARGET" ]; then break; fi
NONCE=$((NONCE + 1))
done
echo "solution = $NONCE (after $((NONCE + 1)) attempts)"
# Faster alternative - Python (~50 ms vs the bash loop's seconds-to-minutes):
# NONCE=$(python3 -c "
# import hashlib
# token, target, n = '$TOKEN', $TARGET, 0
# while int(hashlib.sha256(f'{token}{n}'.encode()).hexdigest()[:8], 16) > target:
# n += 1
# print(n)")
# ── 3. Verify the response (this is what your backend does) ──────────────
# The widget builds this same "token.solution" string and submits it as
# the captchaapi_response form field.
RESPONSE="${TOKEN}.${NONCE}"
curl -s -X POST https://captchaapi.eu/api/v1/captcha/verify \
-H "Authorization: Bearer ${SECRET_KEY}" \
-H 'Content-Type: application/json' \
-d "{\"response\":\"${RESPONSE}\"}" | jq .
# {
# "success": true,
# "error_code": null,
# "over_limit": false
# }
A given response verifies exactly once. Run the verify call twice with the same
string and the second returns "success": false with
"error_code": "invalid_token" - single-use is enforced server-side.
Rotating the secret key
Rotation is managed from the project dashboard: generate a pending key, deploy it to your
CAPTCHA_SECRET_KEY env var, then activate it. The dashboard walks you through
the steps.
Implementation notes
- Read
success, not the status code. A normal pass or fail is HTTP 200. As with every CAPTCHA provider, 200 does not mean pass - branch on thesuccessfield. - Keep the secret key on the server. It goes in the
Authorization: Bearerheader and must never reach the browser. A leaked secret lets anyone verify responses as your project. - Don't leak missing responses as successes. If the
captchaapi_responseinput is absent (the widget never loaded, or the attacker stripped it), treat the submission as failed before calling verify. - Single-use is ours to enforce. A response verifies exactly once. You don't run a replay cache - a replayed response comes back
invalid_token. - Set a timeout on the verify call. Pick a network timeout (a few seconds) and decide your fallback if captchaapi.eu is unreachable - fail closed for auth/payment forms, fail open only where a missed bot is cheap.
- The
riskobject is advisory. The verify response carries ariskscore andsignals(e.g.signals.headless) from our headless / automation detection.successalready reflects the proof-of-work; useriskonly for extra friction or logging on suspicious traffic, never as the sole gate. The signals are soft and forgeable by design. - The
networkobject is yours to judge. Separate fromriskand never part of its score, it reports connection facts as booleans (network.datacenter,network.tor,network.abuse- the last being public abuse-blocklist membership, not VPN detection). Real visitors use VPNs, Tor and cloud egress, so we never score these for you - threshold them against your own threat model: a payment form may add friction on a datacenter IP where a forum lets it pass.