API Reference

Developer Documentation

Everything you need to integrate Captxa — from the three-line HTML snippet to raw API contracts, Ed25519 token verification, and language examples.

Base URL: https://api.captxa.com TLS 1.2 / 1.3 only Content-Type: application/json

Quick Start

Three steps to a fully working integration. Total HTML change: one <script> tag and one empty <div>.

1

Add the script + mount point to your HTML

index.html
<!-- Add before </body> — ~16 KB, zero external dependencies -->
<script src="https://cdn.jsdelivr.net/gh/captxa/PublicJS@latest/script.js"></script>

<!-- Widget mount-point — place it inside your <form> -->
<div id="captcha-widget"></div>
2

Render the widget once the DOM is ready

app.js
Captxa.render('captcha-widget', {
  serverUrl:      'https://api.captxa.com',
  form:           '#my-form',        // CSS selector — # required
  tokenFieldName: 'captchatoken',    // name attr of the injected hidden <input>
  mode:           'complex',         // 'auto' (default) or 'complex' to always show the puzzle
  onVerify: () => {
    // PoW (or puzzle) passed — safe to enable your submit button
    document.getElementById('submit-btn').disabled = false;
  },
  onError: () => {
    // Verification error — ask the user to refresh
    console.error('Captxa verification failed');
  }
});

// On submit, the token is already in the hidden field — just read it
document.getElementById('my-form').addEventListener('submit', async e => {
  e.preventDefault();
  const token = e.target.querySelector('input[name="captchatoken"]')?.value;
  await fetch('/api/my-action', {
    method:  'POST',
    headers: { 'Content-Type': 'application/json' },
    body:    JSON.stringify({ captchatoken: token /* + other fields */ })
  });
});
3

Validate the token on your backend — never from the browser (or self-verify it locally)

server.js (Node.js)
const res  = await fetch('https://api.captxa.com/api/validate', {
  method:  'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    captcha_token: req.body.captchatoken,
    api_token:    process.env.CAPTXA_API_TOKEN
  })
});
// token format: SIMP or COMP | timestamp | ip | ja4 (first 2 parts) | ja4o (first 2 parts) | Ed25519 signature
// example: "SIMP|1778063831|217.126.21.187|t13d1517h2_8daaf6152771|t13d1517h2_acb858a92679||e9CY..."
// if you set mode:'complex', verify the token starts with "COMP"
const data = await res.json();

if (!data.Is_Correct || data.RequestLimit) {
  return reply.status(403).json({ error: 'Verification failed' });
}
// data.requests — number of times this token has been validated

Widget API — Captxa.render()

The widget is a push model: call render() once and it handles everything — PoW in a Web Worker, puzzle UI if triggered, and automatic token injection into your form.

Option Type Required Description
serverUrl string required Base URL of the Captxa API. Always https://api.captxa.com
form string required CSS selector of the form to protect. Must include the # prefix for IDs. Example: '#login-form'
tokenFieldName string optional Name attribute of the hidden <input> injected into the form. Default: captcha_token
mode string optional Challenge mode. 'auto' (default) — runs a silent PoW first and only escalates to the slider puzzle when the server deems the request suspicious. 'complex' — always presents the slider puzzle, regardless of risk score. Use this on high-value actions (logins, payments) where you want an unconditional human challenge.
onVerify () => void optional Callback fired when verification passes (PoW solved or puzzle completed). Use this to enable your submit button.
onError () => void optional Callback fired when the widget encounters a network or verification error. Prompt the user to refresh.

POST /api/validate

Server-to-server only. Verify a pass token your frontend received and count its usages. Never expose your api_token to the browser.

Request body

{
  "captcha_token": "<Ed25519 pass token>",
  "api_token":     "<your api token>"
}

Responses

HTTP 200valid
{ "Is_Correct":true,  "RequestLimit":false, "requests":1 }
HTTP 403invalid / tampered
{ "Is_Correct":false, "reason":"invalid_token" }
HTTP 429token reused beyond limit
{ "Is_Correct":true, "RequestLimit":true, "requests":130 } //If requests>100 --> Requestlimit=true
Field Type Meaning
Is_Correct boolean Whether the token is cryptographically valid and not expired. Your primary check.
RequestLimit boolean true when the token has been validated too many times (100 by default on our backend) — treat this the same as a failure (HTTP 429). Depending on your use case, you may want to enforce a lower limit yourself using the requests field.
requests uint32 Total number of times this token has been submitted to /api/validate. For sensitive actions like login or register, check that this is not greater than 1 to enforce single use. The threshold at which RequestLimit becomes true depends on your plan — always use RequestLimit as your safety net and requests for your own per-action logic.
reason string? Present only on failure. Value: "invalid_token".

Using the requests field in your backend

You can call /api/validate from multiple places in your backend for the same token — for example once at login and again inside a protected dashboard route. Each call increments requests. This lets you enforce per-action limits yourself:

login route — enforce single use
const data = await validateWithCaptxa(token);

if (!data.Is_Correct || data.RequestLimit) {
  return res.status(403).json({ error: 'captcha_failed' });
}

// For login/register: reject if the token has already been used once
if (data.requests > 1) {
  return res.status(403).json({ error: 'token_reused' });
}

The RequestLimit flag is your safety net. The requests counter lets you add stricter per-context rules on top of it — for example blocking any token reused across different IP addresses or sessions.

Self-verification (skip the API call)

If you need maximum throughput or zero external dependencies at validation time, verify the Ed25519 signature locally. Fetch the public key once at startup and cache it in memory.

Public Key URL

The raw Base64-encoded 32-byte Ed25519 public key is published at a stable URL. Fetch it once and cache — the key only changes if the server is re-provisioned.

GET https://captxa.com/keys/Ed25519.txt

Pass token wire format

The token returned in the x-captcha-token response header is a base64url-encoded blob:

Bytes Length Content
signature 64 bytes Ed25519 sig over payload
payload variable Pipe-delimited fields: SIMP or COMP | timestamp | ip | ja4 (first 2 parts) | ja4o (first 2 parts)

Example token (decoded payload)

// Full base64url token as received in x-captcha-token header:
"SIMP|1778063831|217.126.21.187|t13d1517h2_8daaf6152771|t13d1517h2_acb858a92679||e9CY8..."

// Structure: [type]|[unix_ts]|[client_ip]|[ja4_p1]_[ja4_p2]|[ja4o_p1]_[ja4o_p2]|[Ed25519_sig_base64url]
// To enforce mode:'complex' server-side, verify the token starts with "COMP"

The last pipe-delimited segment is the Ed25519 signature (base64url, 64 bytes). Everything before it is the signed payload.

import { createPublicKey, verify } from 'node:crypto';

const PUB_KEY_URL = 'https://api.captxa.com/keys/Ed25519.txt';
let pubKey;

async function loadPublicKey() {
  const b64 = await (await fetch(PUB_KEY_URL)).text();
  const raw = Buffer.from(b64.trim(), 'base64'); // 32 bytes
  const der = Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), raw]);
  pubKey = createPublicKey({ key: der, format: 'der', type: 'spki' });
}

function verifyCaptchaToken(token) {
  // token format: "SIMP|ts|ip|ja4|ja4o|<base64url_sig>"
  const lastPipe = token.lastIndexOf('|');
  const payload  = Buffer.from(token.slice(0, lastPipe));
  const sig      = Buffer.from(token.slice(lastPipe + 1), 'base64url');
  return verify(null, payload, pubKey, sig);
}
import base64, urllib.request
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.exceptions import InvalidSignature

PUB_KEY_URL = "https://api.captxa.com/keys/Ed25519.txt"

def load_public_key():
    with urllib.request.urlopen(PUB_KEY_URL) as r:
        raw = base64.b64decode(r.read().strip())
    return Ed25519PublicKey.from_public_bytes(raw)

pub_key = load_public_key()  # call once at startup

def verify_captcha_token(token: str) -> bool:
    # token format: "SIMP|ts|ip|ja4|ja4o|<base64url_sig>"
    last_pipe = token.rfind("|")
    payload   = token[:last_pipe].encode()
    sig       = base64.urlsafe_b64decode(token[last_pipe+1:] + "==")
    try:
        pub_key.verify(sig, payload)
        return True
    except InvalidSignature:
        return False
// Requires libsodium (PHP 7.2+, enabled by default)
function loadPublicKey(): string {
    return base64_decode(trim(file_get_contents('https://api.captxa.com/keys/Ed25519.txt')));
}

$pubKey = loadPublicKey(); // cache in APCu at startup

function verifyCaptchaToken(string $token, string $pubKey): bool {
    // token format: "SIMP|ts|ip|ja4|ja4o|<base64url_sig>"
    $lastPipe = strrpos($token, '|');
    $payload  = substr($token, 0, $lastPipe);
    $sig      = base64_decode(strtr(substr($token, $lastPipe + 1), '-_', '+/'));
    return sodium_crypto_sign_verify_detached($sig, $payload, $pubKey);
}
import java.net.URI; import java.net.http.*;
import java.security.*; import java.security.spec.*;
import java.util.Base64;

public class CaptxaVerifier {
    private static final String PUB_URL = "https://api.captxa.com/keys/Ed25519.txt";
    private final PublicKey pubKey;

    public CaptxaVerifier() throws Exception {
        var client = HttpClient.newHttpClient();
        var req    = HttpRequest.newBuilder().uri(URI.create(PUB_URL)).build();
        byte[] raw = Base64.getDecoder().decode(
            client.send(req, HttpResponse.BodyHandlers.ofString()).body().trim());
        pubKey = KeyFactory.getInstance("Ed25519")
            .generatePublic(new X509EncodedKeySpec(addDerHeader(raw)));
    }

    public boolean verify(String token) throws Exception {
        // token format: "SIMP|ts|ip|ja4|ja4o|<base64url_sig>"
        int    lastPipe = token.lastIndexOf('|');
        byte[] payload  = token.substring(0, lastPipe).getBytes();
        byte[] sig      = Base64.getUrlDecoder().decode(token.substring(lastPipe + 1));
        Signature s = Signature.getInstance("Ed25519");
        s.initVerify(pubKey); s.update(payload);
        return s.verify(sig);
    }

    private byte[] addDerHeader(byte[] raw) {
        byte[] hdr = { 0x30,0x2a,0x30,0x05,0x06,0x03,0x2b,0x65,0x70,0x03,0x21,0x00 };
        byte[] out = new byte[hdr.length + raw.length];
        System.arraycopy(hdr,0,out,0,hdr.length);
        System.arraycopy(raw,0,out,hdr.length,raw.length);
        return out;
    }
}

⚠ When to prefer /api/validate instead

Self-verification skips the request-count check — it cannot detect token replay at the API level. Do it if you want more privacy or speed. If you need to enforce single-use tokens, call /api/validate, which increments a server-side counter and returns HTTP 429 on overuse.

Backend Validation Examples

All examples call POST /api/validate with a JSON body. Swap in your framework's HTTP client.

async function validateCaptcha(token) {
  const res = await fetch('https://api.captxa.com/api/validate', {
    method:  'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      captcha_token: token,
      api_token:    process.env.CAPTXA_API_TOKEN
    })
  });
  if (res.status === 429) return { valid: false, reason: 'request_limit' };
  const data = await res.json();
  return { valid: data.Is_Correct === true && !data.RequestLimit, requests: data.requests };
}

// Usage in a login route:
const { valid, requests } = await validateCaptcha(req.body.captchatoken);
if (!valid || requests > 1) return res.status(403).json({ error: 'captcha_failed' });
import os, requests as http_client

def validate_captcha(token: str) -> dict:
    resp = http_client.post(
        "https://api.captxa.com/api/validate",
        json={
            "captcha_token": token,
            "api_token":    os.environ["CAPTXA_API_TOKEN"],
        },
        timeout=5,
    )
    if resp.status_code == 429:
        return {"valid": False, "reason": "request_limit"}
    data = resp.json()
    valid = data.get("Is_Correct") and not data.get("RequestLimit")
    return {"valid": valid, "requests": data.get("requests", 0)}

# Usage in a login route:
result = validate_captcha(request.form["captchatoken"])
if not result["valid"] or result["requests"] > 1:
    abort(403)
function validateCaptcha(string $token): array {
    $ch = curl_init('https://api.captxa.com/api/validate');
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => ['Content-Type: application/json'],
        CURLOPT_POSTFIELDS     => json_encode([
            'captcha_token' => $token,
            'api_token'     => getenv('CAPTXA_API_TOKEN'),
        ]),
    ]);
    $body = curl_exec($ch);
    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($code === 429) return ['valid' => false];
    $data = json_decode($body, true);
    return [
        'valid'    => ($data['Is_Correct'] ?? false) && !($data['RequestLimit'] ?? false),
        'requests' => $data['requests'] ?? 0,
    ];
}

// Usage:
$result = validateCaptcha($_POST['captchatoken']);
if (!$result['valid'] || $result['requests'] > 1) { http_response_code(403); exit; }
import java.net.URI; import java.net.http.*;
import com.fasterxml.jackson.databind.ObjectMapper;

public record CaptchaResult(boolean valid, int requests) {}

public CaptchaResult validateCaptcha(String token) throws Exception {
    var body = "{\"captcha_token\":\"" + token +
               "\",\"api_token\":\"" + System.getenv("CAPTXA_API_TOKEN") + "\"}";
    var req = HttpRequest.newBuilder()
        .uri(URI.create("https://api.captxa.com/api/validate"))
        .POST(HttpRequest.BodyPublishers.ofString(body))
        .header("Content-Type", "application/json")
        .build();
    var res  = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString());
    if (res.statusCode() == 429) return new CaptchaResult(false, 0);
    var json     = new ObjectMapper().readTree(res.body());
    boolean valid = json.path("Is_Correct").asBoolean()
                 && !json.path("RequestLimit").asBoolean();
    return new CaptchaResult(valid, json.path("requests").asInt());
}

// Usage in login handler:
var result = validateCaptcha(captchaToken);
if (!result.valid() || result.requests() > 1)
    throw new ResponseStatusException(HttpStatus.FORBIDDEN);

Full API Reference

The endpoints below are used internally by the JS widget. You typically do not call them directly — they are documented here for transparency and for developers building custom integrations.

POST /challenge/simp Step 1 of simple path

Submit a browser fingerprint. The server runs triage (IP bloom, JA4 match, rate-limiter, bot-detector). On pass, returns an encrypted challenge token + PoW target. On fail, returns Do_complex_captcha to escalate.

Request body

{
  "webglrenderer":           "NVIDIA RTX 4090/PCIe",
  "timezone":                "Europe/Barcelona",
  "hardwareconcurrency":     16,
  "innerw": 1920, "innerh": 1080,
  "availw": 1920, "availh": 1040,
  "devicememory":            8,
  "webdriver":               false,
  "ischromeruntimemissing":  false,
  "errorstacktripwire":      false
}

200 response

{
  "challenge_token": "<base64url>",
  "pow_challenge":   "a3f81c...<32 hex chars>",
  "pow_difficulty":  18
}

// 403 escalation signal:
{ "valid":false, "error":"Do_complex_captcha" }
POST /solve/simp Step 2 of simple path

Submit the PoW solution. Server decrypts and authenticates the token (ChaCha20-Poly1305), verifies IP + JA4 binding, checks the Bloom replay filter, and validates the PoW. No trajectory analysis — a passing Ed25519 token is issued immediately. The mouse trajectory field may be included in the request body but is not processed in simple mode.

Request body

{
  "challenge_token": "<token from /challenge/simp>",
  "pow_solution":   3471829,
  "trajectory": [
    [120, 340, 0],
    [124, 341, 16],
    // ... [x, y, timestamp_ms]
  ]
}

200 response + header

// Response header — pass token:
x-captcha-token: <Ed25519 signed token>

// Response body is simply the string "true" — no JSON payload
"true"
GET /challenge/complex Step 1 of complex path

Returns a randomised sliding puzzle image (background + piece as base64 JPEG/PNG), a 19-bit PoW challenge, and an encrypted token that embeds the correct solution coordinates. The client never learns the answer — it is sealed inside the token.

{
  "challenge_token": "<base64url — contains COMP|ip|ja4|ts|pow|sol_x|sol_y>",
  "pow_challenge":   "b7e3a2...<32 hex chars>",
  "pow_difficulty":  19,
  "puzzle": {
    "background":    "<base64 JPEG — full puzzle image>",
    "piece":         "<base64 PNG — draggable piece with alpha>",
    "piece_start_x": 0,
    "width":         400,
    "height":        300,
    "piece_size":    80
  }
}
POST /solve/complex Step 2 of complex path

Submit the PoW solution, the pixel position the user dragged the puzzle piece to, and the full drag trajectory. Server verifies the solution within ±7 px tolerance and runs trajectory ML analysis (bot score must be < 0.50).

Request body

{
  "challenge_token": "<token from /challenge/complex>",
  "pow_solution":   7294013,
  "puzzle_x":      187, // pixels from left
  "puzzle_y":      62,  // pixels from top
  "trajectory": [
    [0, 62, 0], [45, 63, 33],
    // ... [x, y, timestamp_ms]
  ]
}

200 response + header

// Response header — pass token:
x-captcha-token: <Ed25519 signed token>

// Response body is simply the string "true" — no JSON payload
"true"

Error Codes

Most error responses share the shape {"valid": false, "error": "<code>"} and are returned with HTTP 403. HTTP 400 (bad_request) is returned for malformed JSON or missing fields. HTTP 401 (missing_api_token / invalid_api_token) is returned from /api/validate when the API token is missing or wrong. Error codes are fixed strings — safe to match programmatically.

error string Endpoint Meaning
Do_complex_captcha /challenge/simp Triage failed — client is redirected to the complex path. Never logged as a hard block.
integrity_filters /solve/* Trajectory too short, static, or zero-duration.
burstiness_failed /solve/* Temporal pattern of mouse events indicates scripted injection.
sample_entropy_failed /solve/* Velocity signal lacks the complexity of natural human movement.
fitts_law_failed /solve/* Movement does not conform to Fitts' psychomotor law.
velocity_check_failed /solve/* Velocity coefficient-of-variation too low (constant-speed bot movement).
bot_score_exceeded /solve/* Aggregated bot score ≥ 0.50 (complex path only — simple path has no trajectory analysis).
trajectory_too_short /solve/complex Drag trajectory has fewer points than the required minimum.
invalid_token /solve/* Token MAC verification failed — token is malformed or tampered.
token_expired /solve/* Token age exceeds 180 seconds, or timestamp is in the future.
ip_mismatch /solve/* Client IP differs from the IP bound into the challenge token.
ja4_mismatch /solve/* JA4 or JA4o TLS fingerprint differs from the challenge-time value.
pow_failed /solve/* SHA-256(challenge ‖ nonce) does not have the required leading zero bits.
puzzle_wrong /solve/complex Submitted puzzle position deviates more than ±7 px from the correct answer.
wrong_token_type /solve/* Token type prefix mismatch (e.g. a COMP token sent to /solve/simp).
final_features_failed /solve/* Combined feature vector check failed during trajectory ML pipeline.
bad_request all endpoints Malformed JSON body, missing required fields, or body size exceeding the limit. HTTP 400.
missing_api_token /api/validate The api_token field was not provided in the request body. HTTP 401.
invalid_api_token /api/validate The provided API token did not match the server-side configured token. HTTP 401.

Rate Limits & Limits

All limits apply per registered domain. The CAPTCHA endpoints are protected by a Count-Min Sketch keyed on ip | ja4 | ja4o | domain — exceeding it silently escalates to the complex path rather than hard-blocking.

Endpoint Limit Behaviour on exceed
/challenge/simp, /solve/simp CMS per-key threshold Silent escalation to complex path (HTTP 403 Do_complex_captcha)
/api/validate Per-token call count (plan-dependent) HTTP 429 with RequestLimit: true — treat as failure
Request body size 8 KB (/challenge), 128 KB (/solve) HTTP 400 bad_request
Challenge TTL 180 seconds HTTP 403 token_expired
Bloom filter reset Every 1 hour Replay-prevention state cleared; old solved tokens can be replayed only within the same hour window

Monthly verification limits by plan are listed on the pricing page. Questions? hello@captxa.com