<!-- Markdown version of the Capcat doc "Server-Side Verification API" (rendered page: https://capcat.ai/guide/siteverify.html). Docs index: https://capcat.ai/llms.txt, full single-file version: https://capcat.ai/llms-full.txt -->

# 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](https://capcat.ai/guide/migrate.md) 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 (the `cap-token` field in your form, or `e.detail.token` from the `solve` event).

A successful verification returns:

```json
{ "success": true }
```

If customer-owned reCAPTCHA v3 is enabled for the site, the same authenticated response also
contains Google's token-bound score:

```json
{ "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:

```json
{
  "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 an `X-Forwarded-For` list;
- `idempotency_key` — see [Retrying safely](#retrying-safely).

A successful response then also tells you when and where the token was obtained:

```json
{
  "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 no `Origin` (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 send `remoteip`:
  - `match` — the client that solved the challenge and `remoteip` are 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), `remoteip` was 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:

```json
{
  "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_score` is an integer from `0` to `100`; higher means riskier. It is `null` when the scorer is unavailable.
- `risk_level` is `low`, `medium`, `high`, or `unknown`, using the thresholds configured for the site.
- `risk_status` is `ok`, `partial`, or `unavailable`; missing signals never become a low score silently.
- `risk_reasons` contains bounded, versioned reason codes. Use `risk_model_version` when logging decisions so a later policy change remains auditable.
- `risk_signal_coverage` is from `0` to `1` and 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:

```js
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

```sh [curl]
curl "https://api.capcat.ai/<site-key>/siteverify" \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{ "secret": "<secret_key>", "response": "<captcha_token>" }'
```

```js [Node.js]
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");
```

```py [Python]
import requests

success = requests.post(
    "https://api.capcat.ai/<site-key>/siteverify",
    json={"secret": "<secret_key>", "response": "<captcha_token>"},
).json().get("success")
```

```php [PHP]
<?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);
```
