Server-Side Verification API
The token your frontend receives can only be trusted after it's checked on the server. This endpoint is compatible with the reCAPTCHA / hCaptcha siteverify API (it accepts both JSON request bodies and form encoding) — when migrating from them, your backend usually only needs to change one URL. See the migration guide for a side-by-side walkthrough.
Endpoint
POST https://api.capcat.ai/<site-key>/siteverify
Content-Type: application/json
{ "secret": "<secret_key>", "response": "<captcha_token>" }secret— the secret generated when you created the site in the console (shown only once, at creation or rotation);response— the token produced by the frontend widget (thecap-tokenfield in your form, ore.detail.tokenfrom thesolveevent).
A successful verification returns:
{ "success": true }If customer-owned reCAPTCHA v3 is enabled for the site, the same authenticated response also contains Google's token-bound score:
{ "success": true, "score": 0.7 }score is in the 0–1 range and is omitted for tokens issued without reCAPTCHA v3. The console's minimum score is enforced before a Capcat token is issued. Set the minimum to 0 to accept every valid provider score and apply your own policy here; Google token validity, hostname, and action are still checked. The score is never accepted from the browser. A higher Google score means the interaction is more likely legitimate; this is deliberately the opposite direction from Capcat's native risk_score, where a higher value means more risk. Both fields can appear in one response, so never apply the same comparison operator to both.
The token is single-use: it becomes invalid immediately after a successful verification, and replaying it returns a failure.
It also expires 5 minutes after the challenge is solved (sites created before November 2026 keep 20 minutes). The widget resets itself when its token expires, so a visitor who waits too long simply solves again.
Binding the token to your visitor
Rolling out
The fields in this section are being enabled site by site. Until your site is enabled, remoteip and idempotency_key are accepted and ignored, and the response keeps the shape shown above.
Two optional request fields, also accepted form-encoded:
{
"secret": "<secret_key>",
"response": "<captcha_token>",
"remoteip": "203.0.113.9",
"idempotency_key": "4f7f0f6e-2b1c-4d5e-9a8b-7c6d5e4f3a2b"
}remoteip— the visitor's IP address as your server saw it: one IPv4 or IPv6 address, not anX-Forwarded-Forlist;idempotency_key— see Retrying safely.
A successful response then also tells you when and where the token was obtained:
{
"success": true,
"challenge_ts": "2026-11-05T12:00:00.123Z",
"hostname": "shop.example.com",
"remoteip_check": "match"
}challenge_ts— ISO 8601 time the challenge was solved.hostname— the hostname of the page that solved it, as reported by the browser. It is omitted for clients that send noOrigin(for example native apps). If the site has allowed domains, the hostname has already been checked against them; otherwise compare it with your own domains.remoteip_check— returned only when you sendremoteip:match— the client that solved the challenge andremoteipare in the same /24 (IPv4) or /64 (IPv6) network;mismatch— they are not: the token was most likely solved elsewhere and handed over, the usual pattern of CAPTCHA-solving services;unverifiable— no comparison was possible: one side used IPv4 and the other IPv6 (common when the visitor has IPv6 but your site does not),remoteipwas not a single IP address, or no address was recorded for the token.
Capcat stores only a keyed digest of the network prefix, never the address itself. On most sites remoteip_check is informational: success stays true on a mismatch. Only where Capcat enforces the site's strict or attack policy does a mismatch fail verification instead, with success: false and "error-codes": ["token-context-mismatch"]. Treat it as a risk signal, for example by asking for another verification or holding the action for review, not as proof of abuse: mobile networks and VPNs sometimes switch networks between solving and submitting.
Retrying safely
A token can be verified only once, so when a siteverify call times out you cannot tell whether it was consumed. Send an idempotency_key and retry with the same key and identical parameters: the retry returns the original result instead of a duplicate failure. The key is 16–128 characters of A–Z a–z 0–9 . _ : -; a UUID works. Retries are answered at most 3 times per token, and only until the token's original expiry.
Generate a fresh key for every verification (crypto.randomUUID()) and reuse it only to retry that call. Never derive it from the token, the user or the session: a key reused across requests turns a replayed token into extra successes. A malformed key is rejected with HTTP 400 and "error-codes": ["invalid-idempotency-key"] before the token is touched.
Optional risk verdict
Set the site's Account Defense mode to Score when you want your backend to make the final policy decision. Once the Console runtime card shows Collection, Token metadata, and Score response as on, a successful response also carries the verdict that was bound to that one-time token:
{
"success": true,
"action": "signup",
"risk_status": "ok",
"risk_score": 78,
"risk_level": "high",
"risk_reasons": ["browser_automation_exposed", "high_velocity"],
"risk_model_version": "rules-1",
"risk_signal_coverage": 1
}risk_scoreis an integer from0to100; higher means riskier. It isnullwhen the scorer is unavailable.risk_levelislow,medium,high, orunknown, using the thresholds configured for the site.risk_statusisok,partial, orunavailable; missing signals never become a low score silently.risk_reasonscontains bounded, versioned reason codes. Userisk_model_versionwhen logging decisions so a later policy change remains auditable.risk_signal_coverageis from0to1and describes signal availability, not confidence or probability.
Always reject when success is false. A common customer-managed policy is to allow low, add another verification or manual review for medium, and reject high. Handle unknown, partial, and unavailable explicitly according to the sensitivity of the protected action; never coerce a missing score to zero. The basic compatibility response remains { "success": true } while Score mode or its operator rollout gate is off.
For example, a backend that enables both optional verdicts can keep the final decision local:
const verdict = await response.json();
if (!verdict.success) throw new Error("CAPTCHA verification failed");
if (typeof verdict.score === "number" && verdict.score < 0.3) {
throw new Error("reCAPTCHA risk threshold not met");
}
if (["high", "unknown"].includes(verdict.risk_level)) {
throw new Error("Account Defense policy rejected this action");
}Treat these thresholds as an example, not a universal default: calibrate them on your own protected action and explicitly choose what happens when either optional field is absent.
Examples in multiple languages
curl "https://api.capcat.ai/<site-key>/siteverify" \
-X POST \
-H "Content-Type: application/json" \
-d '{ "secret": "<secret_key>", "response": "<captcha_token>" }'const { success } = await (
await fetch("https://api.capcat.ai/<site-key>/siteverify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ secret: "<secret_key>", response: "<captcha_token>" }),
})
).json();
if (!success) throw new Error("CAPTCHA verification failed");import requests
success = requests.post(
"https://api.capcat.ai/<site-key>/siteverify",
json={"secret": "<secret_key>", "response": "<captcha_token>"},
).json().get("success")<?php
$ctx = stream_context_create([
"http" => [
"method" => "POST",
"header" => "Content-Type: application/json",
"content" => json_encode([
"secret" => "<secret_key>",
"response" => "<captcha_token>",
]),
],
]);
$data = json_decode(file_get_contents(
"https://api.capcat.ai/<site-key>/siteverify", false, $ctx
), true);
var_dump($data["success"] ?? false);