Developer Documentation
Everything you need to integrate Captxa — from the three-line HTML snippet to raw API contracts, Ed25519 token verification, and language examples.
Quick Start
Three steps to a fully working integration. Total HTML
change: one
<script>
tag and one empty
<div>.
Add the script + mount point to your 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>
Render the widget once the DOM is ready
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 */ })
});
});
Validate the token on your backend — never from the browser (or self-verify it locally)
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
{ "Is_Correct":true, "RequestLimit":false, "requests":1 }
{ "Is_Correct":false, "reason":"invalid_token" }
{ "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:
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.
/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" }
/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"
/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
}
}
/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