<!-- Capcat 文档「服务端验证 API」的 Markdown 版本（对应页面 https://capcat.ai/cn/guide/siteverify.html）。全部文档索引：https://capcat.ai/llms.txt -->

# 服务端验证 API

前端拿到的 token 必须在**服务端**校验后才可信任。接口与 reCAPTCHA / hCaptcha 的 siteverify 兼容（请求体 JSON 与表单编码都接受），从它们迁移时服务端通常只需改一个 URL——对照步骤见[迁移指南](https://capcat.ai/cn/guide/migrate.md)。

## 接口

```
POST https://api.capcat.ai/<site-key>/siteverify
Content-Type: application/json

{ "secret": "<secret_key>", "response": "<captcha_token>" }
```

- `secret` — 控制台创建站点时生成的 **secret**（仅创建/轮换时展示一次）；
- `response` — 前端组件产出的 token（表单里的 `cap-token` 字段，或 `solve` 事件的 `e.detail.token`）。

校验通过返回：

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

若站点启用了客户自有的 reCAPTCHA v3，同一个鉴权响应还会带上与 token 绑定的 Google 分数：

```json
{ "success": true, "score": 0.7 }
```

`score` 范围为 `0`–`1`；没有执行 reCAPTCHA v3 的 token 不返回该字段。控制台设置的最低分数会在 Capcat token 签发前执行。把最低分数设为 `0`，即可允许任意有效的 Google 分数通过，再由你的后端在这里自行判断；Google token 本身的有效性、hostname 与 action 仍会被核验。服务端绝不接受浏览器自报的分数。Google `score` 越高表示越可信；这与 Capcat 原生 `risk_score` 越高表示风险越大的方向正好相反。两个字段可以同时出现，绝不能对它们套用相同方向的比较符。

token **一次性有效**：校验成功一次后立即失效，重放会返回失败。

token 还会在解题完成 5 分钟后过期（2026 年 11 月之前创建的站点仍为 20 分钟）。token 过期时组件会自动重置，访客等待过久只需重新验证一次。

## 把 token 绑定到访客

> **分站点开放中**
>
> 本节的字段正在按站点逐步开放。你的站点开放之前，`remoteip` 与 `idempotency_key` 会被接受但忽略，响应保持上文的格式。

两个可选的请求字段，表单编码同样接受：

```json
{
  "secret": "<secret_key>",
  "response": "<captcha_token>",
  "remoteip": "203.0.113.9",
  "idempotency_key": "4f7f0f6e-2b1c-4d5e-9a8b-7c6d5e4f3a2b"
}
```

- `remoteip` — 你的服务端看到的访客 IP：单个 IPv4 或 IPv6 地址，不要传 `X-Forwarded-For` 整串；
- `idempotency_key` — 见[安全重试](#安全重试)。

此时成功响应还会说明 token 是何时、在哪里拿到的：

```json
{
  "success": true,
  "challenge_ts": "2026-11-05T12:00:00.123Z",
  "hostname": "shop.example.com",
  "remoteip_check": "match"
}
```

- `challenge_ts` — 解题完成的时间（ISO 8601）。
- `hostname` — 浏览器上报的解题页面域名。不带 `Origin` 的客户端（例如原生 App）不返回该字段。站点配置了允许的域名时，它已经过名单校验；否则请自行与你的域名比对。
- `remoteip_check` — 只在你传了 `remoteip` 时返回：
  - `match` — 解题的客户端与 `remoteip` 在同一个 /24（IPv4）或 /64（IPv6）网段；
  - `mismatch` — 不在同一网段：token 很可能是在别处解出后转交过来的，这是打码平台的典型做法；
  - `unverifiable` — 无法比对：一边是 IPv4、另一边是 IPv6（访客有 IPv6 而你的站点没有时很常见），`remoteip` 不是单个 IP 地址，或这枚 token 没有记录到地址。

Capcat 只保存网段前缀的 keyed 摘要，不保存地址本身。大多数站点上 `remoteip_check` 只是参考信息：不一致时 `success` 仍为 `true`。只有在 Capcat 对站点执行 **strict** 或 **attack** 策略时，不一致才会直接判失败：返回 `success: false` 和 `"error-codes": ["token-context-mismatch"]`。请把它当作风险信号处理，例如要求再验证一次或转人工审核，而不是当作滥用的证据：移动网络和 VPN 偶尔会在解题和提交之间切换网络。

## 安全重试

token 只能校验一次，所以 `siteverify` 调用超时后，你无法判断它是否已被核销。请求里带上 `idempotency_key`，再用同一个 key、完全相同的参数重试：重试会拿到第一次的结果，而不是「重复使用」的失败。key 为 16–128 个字符，取值 `A–Z a–z 0–9 . _ : -`，UUID 即可。每枚 token 最多响应 3 次重试，且只在 token 原定的有效期内。

每次校验都生成一个新的 key（`crypto.randomUUID()`），只在重试这一次调用时复用。不要从 token、用户或会话推导 key：跨请求复用的 key 会让重放的 token 多拿到几次成功。格式错误的 key 会在核销 token 之前被拒绝，返回 HTTP 400 和 `"error-codes": ["invalid-idempotency-key"]`。

## 可选风险裁决

希望由业务后端自行决定如何处理风险时，在控制台把站点的 Account Defense 模式设为 **Score**。当运行状态卡片中的**采集**、**Token metadata** 与 **Score 回传**均显示开启后，成功响应还会返回与这枚一次性 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` 是 `0`–`100` 的整数，越高表示风险越大；scorer 不可用时为 `null`。
- `risk_level` 为 `low | medium | high | unknown`，使用站点配置的阈值。
- `risk_status` 为 `ok | partial | unavailable`；信号缺失不会被静默当成低风险。
- `risk_reasons` 是有界且带版本的 reason code；记录业务决定时应同时保存 `risk_model_version`，便于审计策略升级前后的差异。
- `risk_signal_coverage` 为 `0`–`1`，表示信号覆盖率，不是置信度或概率。

`success=false` 必须始终拒绝。常见的客户自管策略是：`low` 放行、`medium` 增加二次验证或人工审核、`high` 拒绝；应按受保护操作的敏感度显式处理 `unknown`、`partial` 和 `unavailable`，绝不能把缺失分数强转成零。Score 模式或 operator rollout gate 未开启时，基础兼容响应仍是 `{ "success": true }`。

例如，同时开启两种可选裁决时，业务后端可以保留最终决定权：

```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");
}
```

这里的阈值只是示例，不是通用默认值。应针对自己的受保护动作做校准，并明确决定任一可选字段缺失时如何处理。

## 多语言示例

```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("人机验证未通过");
```

```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);
```
