如何为你的客户开通验证码 API 密钥

provision captcha api keys - How to Provision CAPTCHA API Keys for Your Customers

如果你把 CapSkip 跑给别人用,那么客户付款的那一刻,你就可以直接从计费 webhook 里为他开通验证码 API 密钥。Remote Key Management 提供的 POST 端点可以在正在运行的实例上添加、列出、删除、限额和计量密钥,而新密钥在紧接着的下一次识别请求上就已生效。不用重启,没有手工步骤,付款到账和客户开始识别之间不再夹着任何东西。每个密钥还能按验证码类型分别携带自己的额度,于是套餐档位变成由识别工具自己执行的数字,而不是你的计费系统要盯着的规则。本文讲解订阅时怎么发放密钥、怎么设置和追加这些额度、怎么计量每个客户用了多少、退订时怎么回收,以及这种部署最容易犯的那个网络错误。

你需要什么

  • 一台你自己掌控的 Windows 机器上跑着 CapSkip。这台机器就是你的识别服务,也是唯一需要安装该应用的机器。你的客户什么都不用装。
  • 还需要服务器模式,这样客户才真的连得上。本地模式只绑定回环地址,仅服务于本机;服务器模式绑定你的内网或公网 IP,别的机器就能连进来。两种模式都写在 连接设置里,而且在把地址交给任何人之前,最好先准备一个静态公网 IP。
  • 在 CapSkip 窗口里操作一次,用来生成管理 token。这是唯一发生在界面里的步骤,而且不会重复第二次。
  • 一个能发起这些调用的地方:你的计费 webhook 处理程序、你的后台服务,或者调试阶段的一个终端。

在看代码之前有一点值得明说,因为正是它让这套模式成立。你发出去的这些密钥是你自己铸的。它们不是你从供应商那里买来再转卖的额度,而且铸一个密钥本身不花你一分钱。CapSkip 跑在你自己的硬件上,所以一百个客户密钥和一个客户密钥的成本完全一样。

密钥现在还能携带自己的额度,并且按验证码类型分别设置。这让它从一个单纯的标签变成了由识别工具自己执行的套餐档位:「入门版,10,000 次 reCAPTCHA 识别」变成了 CapSkip 会自己递减的数字,而不是你需要盯着的东西。一共有八个计费类别(bucket),而且它们被刻意设计得比识别工具的任务类型更粗:

  • ImageToText 类别涵盖图片识别和 OCR。
  • Recaptcha 类别把 v2、v3 以及两种 Enterprise 变体都算在一起。
  • Turnstile 类别涵盖 Cloudflare Turnstile。
  • GeeTest 类别涵盖 GeeTest。
  • Altcha 类别涵盖 ALTCHA。
  • CaptchaFox 类别涵盖 CaptchaFox。
  • Capy 类别涵盖 Capy Puzzle。
  • FriendlyCaptcha 类别涵盖 Friendly Captcha。

没有设置额度的类别就是无限量,这也正是这个功能出现之前每个密钥的行为。所以升级 CapSkip 本身不改变任何事:你已经发出去的每个密钥在全部八个类别上都仍然是无限量的,直到你给它设一个额度为止;而一个没有提到额度的添加调用,创建出来的仍然是无限量密钥。

第 1 步:打开远程密钥管理

开关在 Settings 里,先进入 API access,再进入 Remote Key Management。打开它,然后点击 Generate 按钮。

token 只显示一次,并且只以加盐哈希的形式保存,所以之后无法再读出来。丢了就重新生成一个,旧的会立刻失效。如果想在不生成新 token 的情况下关闭管理 API,请点击 Revoke,它会清除已保存的 token。请把 token 放在你的计费系统存放其他机密的地方,而不是写进应用代码里。

这些端点就在已经响应识别请求的那个主机和端口上。如果你的客户访问 8080 端口,管理 API 也在同一个端口。这既是便利,也正是你必须当心的地方,后文有专门一节讲它。

功能关闭时,所有管理路径都返回一个没有响应体的 404。这和访问一个不存在的路径得到的结果完全一样,而且是故意这么设计的:扫描端口的人分不清这是一台关掉了该功能的实例,还是一台根本没有这个功能的实例。

第 2 步:客户订阅时发放密钥

每个端点都是 POST,都接受 JSON 请求体,都需要在 Authorization 头里以 Bearer 凭证的形式带上 token。添加接口需要一个名称。只传名称,密钥值由 CapSkip 生成,这正是这里想要的:客户不该自己挑选凭证。再带上一个 grants 对象,密钥创建出来时就已经带好了这位客户的套餐额度。

# No install needed. Name the key after the customer, not after the plan.
curl -X POST http://YOUR_SERVER:8080/admin/keys/add \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"cust_10482","grants":{"Recaptcha":10000,"ImageToText":50000}}'
{
  "errorId": 0,
  "key": {
    "name": "cust_10482",
    "key": "a1b2c3d4e5f6...",
    "grants": {
      "ImageToText": 50000,
      "Recaptcha": 10000,
      "Turnstile": null,
      "GeeTest": null, "Altcha": null, "CaptchaFox": null, "Capy": null, "FriendlyCaptcha": null
    }
  }
}

响应会报告全部八个类别,而不只是你设置的那两个,所以你永远不需要再读一次来确认自己创建了什么。名称要取自你自己系统里稳定的东西,比如客户 ID 或订阅 ID。名称是唯一的,而且这条规则在添加时就会强制执行,所以名称成了后续所有操作的可靠抓手。改用套餐名或日期来命名,才是让退订那天变得难受的根源。

grants 对象受四条规则约束,而最后一条才是真正救你的那条:

  • 完全不传它,密钥在所有类别上都是无限量。这正是原有的行为,所以在额度功能出现之前写好的集成完全不用改。
  • 漏掉某个类别,或者把它传成 JSON null,那个类别就是无限量。传零则相反,那个类别一开始就是用尽状态。
  • 类别名称不区分大小写,但 credits 必须是一个不带引号的、大于或等于零的 JSON 整数,既不能是带引号的字符串,也不能是小数。
  • 整个请求会先被校验,之后才会创建密钥。类别名称不认识或者数字有问题,都会返回 400 并且什么都不创建,所以一个被拒绝的请求绝不可能留下一个额度只设了一半的密钥。

额度要在创建时就设好,而不是先建密钥再回头去限制它。后一种做法会留下一个窗口,哪怕再短,也是一个全新的客户密钥在完全没有额度限制的状态下上线了。

这条唯一性规则同时也是你的幂等保障。支付服务商会重发 webhook,而重复到达的订阅事件会返回 409 和 ERROR_KEY_EXISTS,不会悄悄给同一个客户再建一个密钥。在处理程序里把这个 409 当成功处理,重放问题就消失了。

密钥值只在那一次响应里返回一次。把它展示给客户,或者存进你的后台读取的地方,因为事后列出密钥属于管理操作,你不会想为了找回某一个客户的凭证去跑它。

第 3 步:升级或续费时调整额度

grant 端点在一个密钥上写入一个类别的额度。按名称或按值来选中密钥,两者只能给一个,和删除接口的规矩一样。

# Upgrade: replace the limit outright.
curl -X POST http://YOUR_SERVER:8080/admin/keys/grant \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"cust_10482","bucket":"Recaptcha","credits":25000,"mode":"set"}'

# Top-up: add to whatever is left.
#   -d '{"name":"cust_10482","bucket":"Recaptcha","credits":10000,"mode":"add"}'

两种模式并不对称。set 模式直接替换掉那个数字,而在 set 模式下把 credits 传成 null 或者干脆不传,就会把额度清回无限量。这也是额度一旦存在之后唯一能取消它的办法。值得注意的是,credits 传零的含义和 null 正好相反:它是把该类别置为用尽,而不是取消限制。add 模式则是在现有值上加上你给的数字,并把无限量当作零来处理,所以它会在一个原本没有额度的类别上建立起额度。它要求 credits 大于零。

有三种请求形态会被直接拒绝,而不是照做,每一种都是因为一旦照做,失败将无声无息:

  • 加零。一个每晚跑的补额脚本,用目标值减去当前值来计算,对每一个已经达到目标的客户都会算出零。而对一个无限量的类别执行加零,会给它建立起一个恰好为零的额度,让这个密钥彻底停止识别。所以当你真的想表达零时,请用 set 模式。
  • 负数,两种模式都一样。对一个有额度的类别加上负 500,读起来像是一次扣减;但完全相同的请求打到一个无限量的类别上,会向下取整到零,悄无声息地把它变成一个用尽的类别。调用方无法从自己的请求预测会拿到哪一种结果,所以两种都不允许。
  • 带引号的数字。把 credits 的值传成带引号的字符串,本意是「把这个密钥限制到那个数」,但一旦它被当作缺失值处理,含义就完全反过来变成取消额度,而且还会返回成功。带引号恰恰是 shell 脚本一不小心就会做的事,所以 credits 请不要加引号。

有一点需要提前规划:一次授予是一笔额度余额,而不是每月配额。CapSkip 内部没有任何东西是按日历走的。对于按量计费的月付套餐,请在每个计费周期开始时,从你的续费 webhook 里用 set 模式调用 grant 端点写入该套餐的配额。对于未用额度可以结转的套餐,则改用 add 模式写入本周期的配额。

第 4 步:计量每个客户用了多少

usage 端点传一个空对象,一次性返回每个密钥、每个类别的消耗情况。

# Every key, every bucket, in one call.
curl -X POST http://YOUR_SERVER:8080/admin/keys/usage \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" -d '{}'
{
  "errorId": 0,
  "keys": [
    {
      "name": "cust_10482",
      "key": "a1b2c3d4e5f6...",
      "buckets": [
        {
          "bucket": "Recaptcha",
          "granted": 10000,
          "remaining": 9581,
          "used": 419,
          "failed": 12,
          "inFlight": 3,
          "lastUsed": "2026-08-26T09:14:02.1234567Z"
        }
      ]
    }
  ]
}
字段含义
"granted"额度上限,无限量时为 null。
"remaining"剩余额度,类别为无限量时为 null。
"used"成功识别的次数。只有它会消耗额度。
"failed"失败的尝试次数,仅用于诊断,从不计费。
"inFlight"当前已预留的任务数。它已经从剩余额度里扣掉了,失败时会还回去。
"lastUsed"最后一次成功识别的 ISO-8601 UTC 时间,从未使用过则为 null。

这些计数器在密钥的整个生命周期内累计,并且重启后依然保留。由于一次调用就返回所有密钥,请按计划定时轮询它来喂给你的面板,而不是按客户一个个去调。lastUsed 字段也是你找出可以回收的闲置密钥的办法。

第 5 步:客户退订时回收密钥

删除只接受一个选择条件,要么是密钥值,要么是名称。既然你按客户来命名,那就用名称:

# By name, which is unambiguous because names are unique.
curl -X POST http://YOUR_SERVER:8080/admin/keys/delete \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" -d '{"name":"cust_10482"}'

# By value works too, if that is what your records hold:
#   -d '{"key":"the-old-value"}'

回收在下一次识别请求时生效,所以调用一返回,访问权限就没了。从那一刻起该客户的调用会失败并返回 ERROR_KEY_DOES_NOT_EXIST,这个信号足够清楚,他们的集成可以把它呈现为订阅到期,而不是服务故障。

什么时候触发回收,要想清楚再定。在退订事件上删除会立刻切断访问,尽管客户通常已经付到了本周期结束。在周期结束事件上删除,才是多数人真正想要的。扣款失败是第三种情况:在回收之前留一小段宽限期,比让密钥在一次重新扣款时突然消失少惹很多工单。

第 6 步:把密钥列表和计费系统对账

列出接口传一个空对象,返回该实例当前接受的每一个密钥。

# The audit: exactly who can solve right now.
curl -X POST http://YOUR_SERVER:8080/admin/keys/list \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" -d '{}'
{
  "errorId": 0,
  "keys": [
    { "name": "cust_10482", "key": "..." },
    { "name": "cust_10515", "key": "..." }
  ]
}

errorId 为 0 表示成功,和识别端点用的是同一套约定。定期跑一次,把名称和你的有效订阅做差集比对。有密钥却没有对应订阅,说明有人还在免费识别;有订阅却没有密钥,说明这个客户的开通 webhook 掉了,而且他多半马上就要来开工单。这两种情况在你去看之前都是无声的,而这个调用是唯一能给出真相的地方。

额度用尽时你的客户会看到什么

某个类别里已经没有剩余额度的密钥,在识别端点上会拿到 ERROR_ZERO_BALANCE,而且只针对属于该类别的任务。这个密钥上的其他类别照常工作。这个错误来自识别 API,而不是这套管理 API,并且在 2captcha、Anti-Captcha、CapSolver 和 DeathByCaptcha 的兼容接口上表现完全一致,所以客户原本针对 2captcha 余额耗尽写好的处理逻辑就已经涵盖了它,他们那边不用改任何东西。

余额查询的行为也很合理。客户轮询 getBalance 或者 res.php 的余额接口时,看到的是把所有已限额类别加总后的真实剩余额度,所以这个数字会随着使用而递减。而在任何类别上都没有额度的密钥,仍然返回原来的 999999.99 占位数值,这意味着那些在提交前会先检查余额是否合理的客户端,照旧能正常工作。

有一个盲点值得知道。传输格式里只放得下一个数字,所以一个还剩 50,000 次图片额度、但 reCAPTCHA 额度为零的密钥,查出来的余额看上去仍然很健康。任何需要按类型细分的场景,都必须改读 usage 端点,而它只对管理员开放。

把它接进订阅 webhook

拼在一起,整个集成就是一张套餐表加四个处理函数:

# pip install requests
import requests

BASE = "http://YOUR_SERVER:8080"
AUTH = {"Authorization": "Bearer YOUR_TOKEN"}

BUCKETS = ("ImageToText", "Recaptcha", "Turnstile", "GeeTest", "Altcha", "CaptchaFox", "Capy", "FriendlyCaptcha")

# None means unlimited; an omitted bucket is unlimited too.
PLANS = {
    "starter":   {"ImageToText": 50_000,  "Recaptcha": 10_000},
    "pro":       {"ImageToText": 500_000, "Recaptcha": 100_000,
                  "Turnstile": 25_000},
    "unlimited": {},
}

def admin(path, body):
    r = requests.post(f"{BASE}/admin/keys/{path}", json=body,
                      headers=AUTH, timeout=10)
    if r.status_code == 409:
        return None          # already exists: a retried webhook
    r.raise_for_status()
    return r.json()

def on_subscription_active(customer_id, plan):
    body = {"name": f"cust_{customer_id}"}
    if PLANS[plan]:
        body["grants"] = PLANS[plan]
    created = admin("add", body)
    return created["key"]["key"] if created else None

def apply_plan(customer_id, plan):
    """Upgrade, downgrade, or start-of-cycle reset."""
    limits = PLANS[plan]
    for bucket in BUCKETS:
        admin("grant", {"name": f"cust_{customer_id}", "bucket": bucket,
                        "mode": "set", "credits": limits.get(bucket)})

def on_subscription_ended(customer_id):
    admin("delete", {"name": f"cust_{customer_id}"})

def usage_report():
    return admin("usage", {})["keys"]

注意 apply_plan 会写入每一个类别,而不是只修改发生变化的那些。这是故意的:降级时,它会把旧套餐有、而新套餐没有的额度清掉,因为在 set 模式下缺失的 credits 值就代表无限量。对于结转型套餐,把它换成 add 模式,并跳过那些额度为 None 的类别,因为 add 模式会拒绝缺失或为零的值。

重放的订阅事件返回 None 时要认真处理。它意味着密钥存在,但你再也看不到它的值了,所以如果第一次没存下来,正确的补救是删掉再重建,而不是去列出。第一次收到时就把值存下来,就完全不会遇到这个局面。

不要把管理 API 留在面向客户的端口上

对这种部署来说,这是最关键的一节,因为同主机同端口的便利是双向的。你的客户需要访问识别端点,而管理端点就在同一个端口上,除了 Bearer token 之外没有别的保护,并且没有任何传输层校验,所以在自定义端口上这个 token 是以明文 HTTP 传输的。任何能监听这段流量的人都能读到它,而任何拿到它的人都能给自己开一个密钥。

在实例前面放一个反向代理,把两类访问者分开:

  • 只把识别相关的路径发布到公网,由代理终结 HTTPS,并让管理路径下的一切都返回 404。
  • 从你自己的后端通过内部通道访问管理端点:如果计费服务就跑在同一台 Windows 机器上,那就走回环地址;否则走内网、VPN 或者一条 SSH 隧道。

经验法则是:管理 token 绝不应离开你的基础设施,它所访问的端口也绝不应是客户能碰到的那个。无论功能开没开,都不要把管理路径暴露到公网上。

还有一条边界值得知道,因为客户面板正是这里最容易想到要做的东西。浏览器调不了这些端点,因为管理接口的响应按设计不带任何 CORS 头。你的面板必须经由你自己的后端转发,而 token 本来也该待在那里。

密钥究竟写到了哪里

密钥存在你原本存密钥的地方。Direct Input(直接输入)和 From File(从文件读取)是两份互相独立的列表,API 读写的是当前选中的那一份。在设置窗口里切换来源,端点看到的内容也会跟着变,所以在你纳闷刚开通的密钥怎么不在列表里之前,先确认自己处在哪种模式。

文件模式有一个行为值得提前知道:第一次写入会把纯文本密钥文件改写成 JSON 格式。内容不会丢,但格式是永久性改变的,所以如果还有别的程序在读这个文件,请先备份。要是压根没有选中文件,写入就无处落地,你会拿到一个 500 和 ERROR_ADMIN_STORE_FAILURE。这里的 500 基本上都是这个原因。

这个功能出现之前建立的列表里,仍然可能存在重名,因为唯一性只在添加时才校验。对这类重名按名称删除会返回 ERROR_KEY_NAME_AMBIGUOUS,并且什么都不会改。改成按值删除,歧义就消失了。

Windows 下的 shell 引号问题,如果你就在这台机器上做测试

CapSkip 是一个 Windows 应用,所以你用来测试的机器往往也是 Windows。命令提示符不把单引号当作引号字符。复制过去的命令会把引号本身当成 JSON 请求体的一部分发出去,解析器拒收,你就在一条看起来完全正确的命令上拿到了 ERROR_ADMIN_BAD_REQUEST。

Shell空响应体带字段时
命令提示符-d "{}"-d "{\"name\":\"prod\"}"
PowerShell,使用 curl.exe-d '{}'-d '{\"name\":\"prod\"}'
Git Bash、macOS、Linux-d '{}'-d '{"name":"prod"}'

在 PowerShell 里要写全名 curl.exe。那里的 curl 是 Invoke-WebRequest 的别名,参数完全不一样,报出来的错和这套 API 毫无关系。

错误码

状态码代码原因
404纯文本功能未开启,或者路径不存在。两者被刻意做成无法区分。
401ERROR_ADMIN_UNAUTHORIZEDtoken 缺失、格式不对,或者不正确。
405ERROR_ADMIN_METHOD_NOT_ALLOWED你发的不是 POST。
400ERROR_ADMIN_BAD_REQUEST请求体格式有误:添加时没给名称,删除时两个选择条件都给了或都没给,类别名称不认识,credits 或 grants 的值不是一个不带引号的非负整数,又或者 add 模式的授予没有带上大于零的 credits。任何东西都不会被创建或修改。
409ERROR_KEY_EXISTS该名称或该值已经在用了。在订阅处理程序里,这通常意味着一次重放的 webhook。
409ERROR_KEY_NAME_AMBIGUOUS按名称删除或授予时匹配到了多个密钥。请改成按密钥值来选中。
404ERROR_KEY_NOT_FOUND删除或授予没有匹配到任何内容。通常是一次已经处理过的退订。
500ERROR_ADMIN_STORE_FAILURE保存失败。通常是文件模式下没有选中文件。

还有一个错误码属于这里,尽管它来自产品的另一侧。ERROR_ZERO_BALANCE 是由识别端点返回的,而不是这套管理 API,出现在某个密钥在对应任务所属的那个类别里已经没有额度的时候。如果客户报告了它,答案是给他补额度,而不是开通流程出了什么问题。

这些错误码和识别用的错误码是并列关系,不是替代关系,完整清单见 CapSkip API 文档.

常见问题

新密钥多久才真正生效?

下一次识别请求就生效。没有任何缓存,也不需要重新加载,所以一个刚在你的结账页拿到密钥的客户,同一分钟内就能用它。反方向的回收同样即时,正因如此,退订处理程序的触发时机是一个策略决定,而不是技术限制。

每多一个客户密钥要额外花钱吗?

仍然不花钱。铸一个密钥是免费的,背后也没有任何供应商的计量。变化在于密钥现在可以携带你自己的额度,因此它能代表一个套餐档位:账怎么算是你的事,价怎么定也是你的事。 CAPTCHA 识别 SDK 你给它哪个密钥它就用哪个,所以怎么划分完全由你设计。

我能通过这套 API 给客户限流吗?

不能,而且额度并不能替代限流。额度是预算,不是每秒请求数的上限:它限制的是客户总共能拿到多少,而不是他在某一秒里能把端点打得多狠。限流仍然属于你本来就要放在实例前面的那个反向代理,按客户发来的 API 密钥来做。

额度每个月会自动重置吗?

不会。一次授予是一笔会一直递减的余额,直到你去改它为止,而且 CapSkip 内部没有任何东西是按日历走的。在每个计费周期开始时用 set 模式调用 grant 端点,把它重置为该套餐的配额;如果未用额度应当结转,就改用 add 模式。

识别失败会消耗客户的额度吗?

不会。只有成功识别才消耗额度。失败会在用量报告里单独计数,所以你依然看得到,而一个正在处理中却失败了的任务会把预留还回去。这意味着一个正在被难缠站点反复挑战的客户,不会在那些什么都没换回来的尝试上悄悄烧光自己的额度。

我升级 CapSkip 之后,已有的密钥会怎样?

不会有任何变化。你已经发出去的每个密钥在全部八个类别上都是无限量的,直到你给它设一个额度为止;而没有提到 grants 的添加调用,创建出来的仍然是和以前一模一样的无限量密钥。这个功能在运营者主动启用之前是完全不起作用的,所以既没有迁移步骤,也不存在客户失去访问权限的窗口期。

客户能看到自己剩下多少额度吗?

通过余额查询可以。一旦设置了额度,它返回的就是真实剩余总量,而不再是原来那个占位数值,所以数字会随着他们使用而递减。按类别细分则是另一回事:它需要管理 token,而且一次返回所有密钥,所以请从你自己的面板里呈现,而不要把那个端点暴露给客户。

我把管理 token 弄丢了,现在怎么办?

在同一个设置面板里重新生成一个。旧 token 会立刻失效,所以没有清理步骤,也不存在两个 token 同时有效的窗口期。你的客户不受影响:他们的密钥原封不动,识别照常进行。唯一中断的是你自己的开通流程,直到你把 webhook 处理程序读取的那个机密更新为止。

最短的版本

Remote Key Management 只需开启一次,把 token 和你其他的计费机密放在一起。客户订阅生效时添加一个以客户命名的密钥,并且创建时就带上该套餐的 grants,重放时把 409 当成功处理。额度的重置或追加放在续费 webhook 里做,因为这里没有任何东西是按日历走的。按计划定时轮询 usage 端点,它既用于计费对账,也用于找出闲置密钥。订阅结束时按名称删除,并把密钥列表和你的有效订阅做比对,因为那是唯一能给出真实答案的地方。然后在前面放一个代理,让管理路径远离客户能看到的端口。做到这些, 验证码绕过 就变成了你可以用自己的硬件转售的东西,用你自己的数字来计量,开通速度取决于你的结账流程触发 webhook 有多快。