logo
captchaAPI

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:

  1. Read the captchaapi_response field from the inbound form POST.
  2. POST {"response": "<that value>"} to https://captchaapi.eu/api/v1/captcha/verify with an Authorization: Bearer <secret key> header.
  3. 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 is captchaapi/laravel - install with composer require captchaapi/laravel and you get the verify rule, a Blade component for the widget, and a Livewire trait for native wire:submit forms, 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 the success field.
  • Keep the secret key on the server. It goes in the Authorization: Bearer header 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_response input 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 risk object is advisory. The verify response carries a risk score and signals (e.g. signals.headless) from our headless / automation detection. success already reflects the proof-of-work; use risk only for extra friction or logging on suspicious traffic, never as the sole gate. The signals are soft and forgeable by design.
  • The network object is yours to judge. Separate from risk and 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.