S6网络验证
这是公开的对接文档,任何客户端都可以按下面的说明接入。
页面中的 AppKey 已用 x 打码,真实密钥请登录你的账号在「程序管理 / 对接文档」里查看。

对接文档

按语言分开的接入示例。左侧切换平台,右侧跳转页面内小节。

SDK 快速接入 —— 选你的开发语言,下面就是完整代码与 API 参考
iOS (Objective-C) Swift / SwiftUI Kotlin (Android) C# / .NET (EXE) C / C++ PHP Python Node.js Java(纯 JDK) Go Web JS(浏览器) 易语言
当前选中:PHP —— 点上面任意一个切换;左侧导航栏「SDK 快速接入」分组里也能切。
接口地址与凭据
POST https://s6yz.cn/Verifyfile/api.php
响应加密: 已开启 AES-256-GCM 请求加密: 强制 AES-256-CBC+HMAC
快速开始(3 分钟跑通)
第一次对接?别从上面往下读,按这 3 步走完就通了。
1 拿到你的 AppKey
后台「程序管理」→ 选中程序 → 复制 AppKey(64 位十六进制)。
公开页看不到真实密钥,登录你的账号后在同一页面能看到。
2 选语言,复制那段代码
直接点,跳过去就是那一节的完整实现:
3 只改两个常量,然后调用
代码段顶部一般有两个常量:Api(接口地址)、AppKey(你的密钥)。
改完调 Call("卡密", "机器码"),返回 code = 200 就通了。
协议就这两行,记不住就回来看
请求 Base64( hmac[32] ‖ iv[16] ‖ ciphertext ) — AES-256-CBC + PKCS7
响应 Base64( iv[12] ‖ tag[16] ‖ ciphertext ) — AES-256-GCM
密钥 encKey = HMAC-SHA256(master, "enc") macKey = HMAC-SHA256(master, "mac")
第一次接最容易踩的 3 个坑:
① IV 长度:请求 16 字节,响应 12 字节,别用同一个常量
② HMAC 拼的顺序:必须 iv ‖ ciphertext,反过来就过不了
③ AES 的 key:必须是 HMAC(master,"enc") 派生值,不能直接拿 master
在线调试(点一下就跑真实的加密请求)
在浏览器里用你自己的 AppKey 加密、发送、解密,每一步的中间值都会显示出来。
如果你自己的代码解不开,拿这里的中间值逐项对比就能定位。
AppKey 只在你的浏览器内存里,不会发到服务器,也不会被记录。
请求参数 / 响应结构
明文请求
{
  "app_key":     "你的64位AppKe...",
  "card_code":   "用户输入的卡密",
  "device_hash": "16-64位设备指纹(少于16位会被拒)",
  "action":      "verify"            // 可选,心跳传 heartbeat
}
请求信封(外层结构)
这只是最外层的两个字段。真正把 JSON 变成 encrypted_data 的加密代码, 在下面切到对应语言标签就能看到(C# / PHP / Python / Node / Java / Go / Swift / Kotlin / C++ / 易语言)。
{
  "app_key":        "你的64位AppKe...",
  "encrypted_data": "Base64( hmac[32] ‖ iv[16] ‖ ciphertext )"
}
// 明文内容里必须带 _ts(Unix 秒,10 位),服务端允许 ±300 秒
encrypted_data 不能是空字符串。传了这个字段但值是空串,服务端直接返回 400 请求密文为空 —— 这是为了防止「清空密文 → 服务端退回明文解析」的降级攻击。 要么带上完整密文走加密通道,要么整个字段都不传(明文模式,需后台没强制加密)。
action 必须是下面 16 个之一,拼错会被直接拒绝(400 未知的 action): verify · heartbeat · unbind · ip_unbind · user_register · user_login · user_heartbeat · recharge · ip_unbind_user ·
不传 action 等同于 verify。
成功响应
加密响应
// 应用开了「响应加密」时,整个 JSON 只剩这一个字段,
// 解密前拿不到 code / msg / data,连错误信息都是密文
{
  "encrypted_data": "Base64( iv[12] ‖ tag[16] ‖ ciphertext )"
}
// 解密后才是正常的 { code, msg, data }
请求和响应的密文布局不一样,别写反:
请求 Base64( hmac[32] ‖ iv[16] ‖ ciphertext ) + AES-256-CBC
响应 Base64( iv[12] ‖ tag[16] ‖ ciphertext ) + AES-256-GCM
// —— PHP ——
$bin = base64_decode($enc);
$iv  = substr($bin, 0, 12);
$tag = substr($bin, 12, 16);
$ct  = substr($bin, 28);
$pt  = openssl_decrypt($ct, 'aes-256-gcm', hex2bin($appKey), OPENSSL_RAW_DATA, $iv, $tag);
{
  "code": 200,
  "msg":  "OK",
  "data": {
    "expire_time": "2026-11-30 23:59:59",
    "card_type":   "month",
    "custom_data": "",
    "variables":   { "hb_interval": "300" },
    "notice":      "应用公告内容"          // 可选:后台没填公告时为 null
  }
}
关于 data.notice:后台「程序管理 → 公告管理」里填的内容,验证和心跳都会带下来。 留空则字段为 null。客户端拿到非空字符串后自己弹窗展示即可,不需要额外请求接口。
错误码
code含义客户端处理
200验证通过放行,缓存 expire_time
400参数错误 / 参数类型不对(传了数组或对象)/ 设备码格式非法 / 签名不符 / 缺 _ts不重试,查代码
403卡密无效、封禁、过期、绑定冲突立即失效,重新弹授权
429限频(三种来源:① 程序设的验证限频 ② 注册限频 ③ verify 连续失败 30 次/分钟)退避重试
500服务端异常退避重试
403 和网络错误必须分开处理。403 是"这台机器不该再用",网络错误是"暂时连不上"。混在一起,被封的用户还能继续用一段时间。
应用公告(可选)
请求方式:POST请求地址:/Verifyfile/api.php数据格式:application/json不传卡密,只传 app_key

后台「程序管理 → 公告管理」里填的文本,会在验证成功和心跳的响应里一并下发,字段名 data.notice。 后台没填就返回 null,不需要额外请求接口,也不用改请求参数。

// 响应里的位置
{
  "code": 200,
  "data": {
    "expire_time": "2026-11-30 23:59:59",
    "notice":      "【重要】服务迁移通知,请重新登录"   // 没公告时是 null
  }
}
读取公告
平台取公告并判空
PHP$n = $r['data']['notice'] ?? null; if ($n !== null && $n !== '') { /* 弹窗 */ }
公告内容规范
项目说明
内容格式纯文本。支持换行(JSON 里是 \n),不支持 Markdown 语法
字数上限2000 字(服务端强制截断,超出部分不保存)。建议控制在 200 字内,弹窗好显示
编码UTF-8,中文、Emoji、英文混排都支持
HTML 标签不解析。客户端请按纯文本渲染(textContent / UILabel 的 stringValue),别用 innerHTML,否则等于把 XSS 入口交给任何人
是否加密跟整个响应一起加密,不需要单独处理。后台开了「API 通讯加密」时,客户端按正常流程解密整个 data 后就能拿到明文 notice
空值约定后台留空 → 字段为 null(不是空字符串)。客户端用「非 null 且长度 > 0」判断
长度参考2000 个中文字 ≈ 6 KB,加密后约 8 KB —— 对一次验证响应来说可以接受,但别往上堆
更新时机后台保存后立即生效。已运行的客户端最迟一个心跳周期(默认 5 分钟)收到
不要在公告里放 HTML。服务端不做过滤,也不做转义——它只是把字符串原样塞进 JSON。 如果客户端用 innerHTML 渲染,别人只要能在后台填公告就能让所有客户端执行任意脚本。 一律按纯文本处理。
独立调用(不验证也能拿)

只传 app_key、不传卡密,就是「拉公告 / 变量 / 版本」接口。启动软件时先调一次,用户还没输卡密也能看到公告。

请求体里除了 app_key(和 _ts)什么都别放 —— 尤其不要带 action。
带了 "action":"verify" 却卡密为空,服务端会当成「你漏传了卡密」直接返回 400「缺少卡密(card_code)参数」,拿不到公告。
正确:{"app_key":"…"} 错误:{"app_key":"…","action":"verify"}
// 请求(不需要 card_code,也不需要 device_hash)
{ "app_key": "你的64位AppKe..." }

// 响应
{
  "code": 200,
  "data": {
    "notice":    "【公告】本软件已更新到 2.1.0",
    "variables": { "kf_qq": "123456789", "min_version": "2.0.0" },
    "update":    { "version": "2.1.0", "url": "", "force": 0 }
  }
}
封装成函数(可直接抄)
// —— PHP ——
function getNotice(string $api, string $appKey): array {
    $raw = http_post_json($api, ['app_key' => $appKey]);
    $r = json_decode((string)$raw, true);
    if (!is_array($r))            return ['success' => false, 'notice' => null, 'error' => '解析失败'];
    if ((int)$r['code'] !== 200) return ['success' => false, 'notice' => null, 'error' => $r['msg']];
    return ['success' => true, 'notice' => $r['data']['notice'] ?? null, 'error' => null];
}
心跳也会带公告。所以公告改了之后,已经在用的客户端最迟一个心跳周期(默认 5 分钟)就能收到,不用重启软件。
如果不想每次心跳都弹,客户端自己记一个「已看过的最新公告 hash」即可——比较内容变化再弹。
版本更新(可选)
请求方式:POST请求地址:/Verifyfile/api.php数据格式:application/json不传卡密,只传 app_key

后台「程序管理 → 管理 → 更新配置」里可以给每个程序单独填新版本下载地址、强制更新开关。 这些值会在验证成功、心跳、以及只传 app_key 的轻量调用三种响应里一并下发,字段名 data.update。 不填就不下发(update.url 为空字符串),客户端可以完全忽略这个字段。

// 响应里的位置
{
  "code": 200,
  "data": {
    "expire_time": "2026-11-30 23:59:59",
    "update": {
      "version": "2.2.0",                              // 后台「程序管理 → 编辑」里填的版本号
      "url":     "https://dl.example.com/app-v2.2.0.apk",  // 更新配置里填的下载地址,没填是 ""
      "log":     "修复若干问题",                        // 程序备注,可当更新日志
      "force":   1                                       // 1=强制更新,0=可选
    }
  }
}
四个字段的含义
字段类型说明
versionstring服务端记录的最新版本号,可能为空字符串
urlstring新版本下载地址,为空表示没配更新,客户端应直接跳过
logstring更新说明,取自程序的备注字段
forceint1 = 强制更新(低于 version 应拒绝使用),0 = 仅提示
客户端判断逻辑
// 伪代码:拿到响应后先做这一步,再走正常业务
update = data.update
if (update.url != "") {                  // 后台没配置就什么都不做
    remote  = version_tuple(update.version)   // "2.2.0" -> [2,2,0]
    current = version_tuple(LOCAL_VERSION)    // 客户端自己的版本
    if (remote > current) {
        if (update.force == 1) {
            // 强制:弹不可取消的对话框,只能点「去更新」
            show_blocking_dialog("必须更新到 " + update.version, update.url, update.log)
            return  // 不继续业务
        } else {
            // 可选:弹一个能关闭的提示
            show_optional_dialog("有新版本 " + update.version, update.url, update.log)
            // 继续正常业务
        }
    }
}
版本号比较(别用字符串比)
"2.10.0" 按字符串比会小于 "2.9.0"(因为字符 '1' < '9')。必须按段转成整数再逐段比较。
当前语言写法:PHP: version_compare($a, $b, '<')
读取更新信息
平台取更新信息
PHP$u = $data['update'] ?? null; $url = $u['url'] ?? '';
心跳也会带更新信息。所以后台把 force 改成 1 之后,正在运行的客户端最迟一个心跳周期就能收到并强制更新,不用重新发版。
只传 app_key 不传卡密也能拿到(data.update),适合启动时先查一次版本再让用户输卡。
程序设置参数对接口的影响 (后台「程序管理 → 程序设置」里改,改完立刻生效,不用重新发版)

同一个 AppKey,后台这些开关改一下,接口的校验规则和返回结构就会变。 客户端如果写死了某种行为,开关一改就会出问题 —— 所以对接前先确认下表里的值。

一、校验类(决定请求能不能过)
身份验证固定「正常验证」。后台没有跳过卡密校验、跳过有效期这类放行开关 —— 放行等于把加密和设备绑定一起作废,任何客户端都能无卡进入。
要让某个程序临时停服,请用「运行状态 → 停用」,该程序的所有验证请求一律 code=403。那才是正确的关停方式。
参数取值对接口的影响
machine_check 0 / 1 / 2 卡密模式的设备策略(账号模式看 single_login)。三种都是「一张卡同时能用几台设备」的区别:
1 = 独占绑定(默认)—— 只认第一台。换设备返回 403「卡密已绑定其他设备」,新设备进不来、旧设备继续用。 (这里的「换设备」指同一张卡换一台机器调 verify)
2 = 单点在线 —— 换设备放行,但会清掉旧设备的授权记录,并把 cards.device_hash 覆盖成新设备。 旧设备下一次 heartbeat 收到 403 「该设备没有有效的授权记录,请重新验证卡密」被踢下线。
0 = 多点在线 —— 不校验设备绑定,多台设备各记一条 active_devices,同时在线互不影响,都不踢。

三种模式都受「一台机器只能激活一张卡」限制 —— active_devices 上 (device_hash, app_id) 是唯一键, 同一台机器换用另一张卡会顶掉原来那张(并把旧卡解绑)。这是防共享的核心规则, 不因「多点在线」而放开。

客户端配合:单点在线下,被踢的设备靠 heartbeat 的 403 自己退回验证页;多点在线下不需要任何额外处理。

关于 device_hash 本身:传了就必须是 16–64 位字母数字(含 _ -),否则返回 400; 完全不传时服务端会用 md5("ip:" + 客户端IP) 兜底当设备码 —— 这样极简接入也能跑,但同一出口 IP 的所有用户会被视为同一台设备。 正式接入请一定自己生成并持久化 device_hash (建议用主板 SMBIOS UUID 之类不会因重装系统而变的硬件指纹)。
ip_check 0 / 1 1 = 绑定 IP —— 首次验证时记下 IP,之后换 IP 返回 code=403。
0 = 不绑 IP(默认)。
ip_precision 0 / 1 只在 ip_check=1 时生效。
0 = 精确到 IP(默认,1.2.3.4 完全一致才放行)
1 = 精确到网段 —— IPv4 按 /24(1.2.3.x 算同一台),IPv6 按 /64。
没有第 3 种精度,填其它值一律当 0 处理。
single_login 0 / 1 账号同时只能有一台在线,区别在「新设备能不能登进来」(卡密的设备绑定看 machine_check)。
1 = 独占绑定(默认)—— 账号锁死在首次登录的设备。换设备 user_login 直接返回 code=403「该账号已绑定其他设备」,新设备进不来、旧设备继续用。
适合严格防共享:一个号只能在一台机器,换机器必须走 rebind(扣时长)或找管理员解绑。

0 = 单点在线 · 免费换设备 —— 任意设备都能登,新设备一登,旧设备的 statecode 立刻被覆盖, 旧设备下一次 user_heartbeat 收到 code=403「账号已在其他设备登录…」被踢回登录页。
适合多设备轮换、不额外收费:手机电脑都能登,但同一时刻只有一个在线,像微信/QQ 那样「后登顶前登」。

2 = 单点在线 · 换设备扣时长 —— 行为同上,但每次换设备自动扣 unbind_penalty 小时。
扣费发生在 user_login 内部,不需要客户端额外调 rebind —— 一次登录完成「踢掉旧设备 + 扣时长 + 新设备生效」。
响应里带 data.charged_hours(扣了几小时)与 data.device_changed="1";剩余时长不够扣时返回 code=402 并附 need_hours / remain_hours, 此时不扣时长、设备码也不改。永久卡(2099 到期)不扣。
适合严格控成本:换设备有代价,但用户不用找管理员。

3 = 多点在线 —— 多台设备同时挂着,谁都不掉。 登录时不再覆盖会话,而是给每个设备单独记一条(end_user_sessions 表,一设备一行)。 手机、家里电脑、公司电脑可以同时在线,各发各的心跳,互不影响。
此模式不扣时长、不踢人;同一台设备重复登录会替换它自己那条会话 (不会堆记录,但会让该设备的旧令牌失效)。
响应里带 data.online_devices(当前几台在线)。 管理员可单独踢掉某一台,不影响其它设备。

四种模式一句话区别
1 独占绑定 —— 只认第一台,后来的进不来
0 单点在线·免费 —— 只保留最后一台,老的被踢
2 单点在线·扣时长 —— 只保留最后一台,老的被踢,换一次扣 N 小时
3 多点在线 —— 所有设备都留着,同时在线

客户端配合:单点在线模式下,新设备登录响应带 data.kicked_previous="1",可提示「原设备已下线」;被踢的设备靠 user_heartbeat 的 403 自己退回登录页。
hb_grace 小时数 这个值不参与服务端校验,只是原样下发给客户端。
它会出现在验证/心跳响应的 data.variables.hb_grace 里(大于 0 才有这个 key)。
用法:客户端自己决定「心跳失败后再宽限多久才真正拒绝用户」。
服务端只按 expire_time 判过期,到点就是 code=403。
二、解绑类(决定 unbind 能不能用、扣多少时间)
参数取值对接口的影响
unbind_enabled 0 / 1 0 = 禁止自助解绑 —— unbind 返回 code=400「该应用未开启自助解绑」。
1 = 允许(默认)。
注意是 400 不是 403 —— 这是「功能没开」,不是「卡密失效」, 客户端不该按 403 那样清缓存踢用户下线。
unbind_window 小时数 0 = 不限时(默认)。大于 0 时,同一张卡在这个时间窗内最多解绑 unbind_limit 次,超了返回 code=429。
unbind_limit 次数 0 = 不限次数(默认)。配合 unbind_window 一起用。
unbind_penalty 小时数(可带小数) 0 = 不扣时(默认)。大于 0 时,每次解绑从剩余时长里扣掉这么多小时;
扣完会变成过期状态(expire_time 提前)。
web_unbind
web_unbind_ip
0 / 1 控制网页端解绑入口是否开放,不影响 API 的 unbind。 web_unbind_ip=1 时网页解绑会同时清掉 IP 绑定。
三、限频(决定会不会被 429)
参数取值对接口的影响
reg_limit_window
reg_limit_count
秒数 / 次数 验证接口限频。两个都填 0 = 不限(默认)。
填了之后,同一 IP 在 window 秒内最多 count 次验证请求,超了返回 code=429「请求过于频繁,请稍后再试」,并带 Retry-After 头。
管的是卡密验证链路:verify、heartbeat、user_login、user_heartbeat、recharge。
不影响注册(注册走下面那组)。多人共用出口 IP(公司网、代理池、NAT)时必须填 0 或调大。
register_limit_window
register_limit_count
秒数 / 次数 注册接口限频(独立于上一组)。两个都填 0 = 不限(默认)。
填了之后,同一 IP 在 window 秒内最多注册 count 次,超了返回 code=429「注册过于频繁」。
只作用于 action=user_register,对验证、心跳、登录、充值完全没有影响。
建议:防批量注册小号可以把这组调严(如 3600 秒 / 3 次),验证那组留 0 —— 两者互不牵连。
四、传输格式(决定请求怎么发、响应长什么样)
参数取值对接口的影响
encrypt_req 0 / 1 / 2 请求体是否必须加密。
0 = 跟随全局(默认,看后台「全局配置 → 请求加密」)
1 = 强制加密 —— 不带 encrypted_data 返回 code=400
2 = 强制明文 —— 带了密文也按明文处理
返回 code=400 时,msg 里会写着 "请在客户端开启 S6_ENCRYPT_REQUEST"。
S6_ENCRYPT_REQUEST 指的是客户端 SDK 里的那个开关,不是服务端参数 —— 它对应的就是这张表里的 encrypt_req: 值为 1(或 0 且后台「请求加密」开着)时, 客户端必须把请求体加密成 encrypted_data 再发。
看到这个提示,不代表接口地址或 AppKey 错了,是请求没加密。
encrypt_resp 0 / 1 / 2 响应是否加密。
0 = 跟随全局(默认,看后台「全局配置 → 响应加密」)
1 = 强制加密 —— 响应是 {"encrypted_data":"..."}
2 = 强制不加密 —— 响应是明文 JSON
ts_check 0 / 1 1 = 校验时间戳(默认)—— 请求里的 _ts 与服务器相差超过 300 秒返回 code=400「请求已过期」。
0 = 不校验 —— 客户端时间不准也能用。
ts_strict 0 / 1 只在 ts_check=1 时生效。
1 = 严格模式 —— 额外拒绝超前的时间戳(_ts > 服务器时间+5秒 直接拒),防止用户把手机时间调快绕过。
0 = 只判过期,不判超前(默认)。
sign_check 0 / 1 1 = 校验签名 —— 密文里必须带 sign 字段,值是 md5(card_code + device_hash + _ts + app_key),不对返回 code=400。
0 = 不校验(默认)。
开了签名就必须走加密通道 —— 明文请求签发不出 _ts,会直接返回 code=400。
resp_format json / text json = 标准 JSON(默认)—— {"code":"200","msg":"OK","data":{...}}
text = 极简文本 —— 只回两行 200\nOK,给老客户端兼容用。
注意:text 模式下响应不加密,也不返回 data,拿不到 expire_time 等字段。
code_type string / int string = 返回码是字符串 "code":"200"(默认)
int = 返回码是数字 "code":200
弱类型语言(PHP / JS / Python)两种都能直接用; 强类型语言(Java / C# / Swift / Go)必须跟这个开关对上,否则反序列化会失败。
resp_ts 0 / 1 1 = 响应里多一个 time 字段(Unix 时间戳,秒),可以拿来校准客户端时间。
0 = 不返回(默认)。
五、内容类(不影响校验,只影响返回内容)
参数取值对接口的影响
notice 文本 公告内容。会出现在验证、心跳、用户登录、用户心跳的 data.notice 里。
为空时是 "notice": null —— key 一定存在,只是值是 null。客户端按「可能为 null」处理即可。
app_icon 图片地址 图标地址,部分客户端会拿来做启动页。不影响任何校验逻辑。
agent_key 字符串 代理专用标识,用于区分「这张卡是哪个代理发的」。不影响 API 校验。
三个最容易踩的坑:
1. 两组限频各管各的,别混着看。 reg_limit_* 管验证链路(verify / heartbeat / user_login / user_heartbeat / recharge), register_limit_* 只管 user_register。 填其中一组不会影响另一组 —— 所以「注册卡得很严但用卡不受影响」是能配出来的,把注册那组调小、验证那组留 0 即可。
升级提示:老版本这两组是同一个字段,升级后如果原来 reg_limit_* 填了窄值想继续限制注册, 需要把值再填一遍到 register_limit_*,否则注册会变成不限。
2. code_type 跟客户端的反序列化类型必须对上。 Java 的 getInt("code") 拿到字符串 "200" 会抛异常,C# 的 (int)JsonConvert 同理。 强类型语言建议后台把这个值设成 int,或者在客户端做兼容解析。
3. resp_format=text 会丢掉整个 data。 只回 200\nOK,拿不到 expire_time、notice、update。 除非客户端是纯「能过就行」的判断,否则别开。
怎么查当前值

后台 →「程序管理」→ 点应用名进「程序设置」/「传输设置」,每个开关旁边都有说明。 改完立即生效,不需要客户端重新发版。

单卡密模式(只用卡密)

不注册账号,用户直接输卡密就能用。这是最省事的模式 —— 一把卡密对应一台设备, 换设备要解绑。后台可以按小时 / 天 / 周 / 月 / 季 / 年 / 永久卖卡,也可以在「生成卡密」里 指定任意小时数。

两种模式怎么选
对比项单卡密模式账号模式
用户要做什么输一次卡密,之后自动登录先注册账号 → 买卡密充值 → 账号登录
适合挂机软件、辅助工具、一次性授权、卖时长卡需要用户体系、多设备漫游、在线续费、找回密码的场景
换设备调 unbind 或让管理员解绑,可配扣时/限次账号跟着人走,换设备只需重新登录
续费再买一张卡密,绑定同一设备会自动叠加时长用 recharge 给账号充值
要不要注册不用要
两种模式可以同时开。老用户继续输卡密,新用户走账号注册,互不影响 —— 它们是同一套接口,只靠 action 区分。
单卡密模式的接口一览
action用途必填参数
(不传 / verify)卡密验证card_code device_hash
heartbeat心跳续期card_code device_hash
unbind解除设备绑定card_code
ip_unbind解绑卡密绑定的 IPcard_code(可选 device_hash)

卡密验证 card_code + device_hash

请求方式:POST请求地址:/Verifyfile/api.php数据格式:application/json
请求参数
字段类型说明
app_keychar[]必填应用的 64 位 AppKey,外层明文
card_codechar[]必填用户输入的卡密,不区分大小写
device_hashchar[]选填设备唯一标识,16–64 位。见「设备指纹」章节
actionchar[]可不传,等同于 verify
设备策略由服务端配置决定 —— 客户端不需要传任何额外参数
「一张卡能不能在多台设备上用、换设备会不会让原设备掉线」由程序后台的 「机器码验证」决定。客户端只要正常传 device_hash,行为自动跟随配置:
独占绑定 → 换设备返回 403「卡密已绑定其他设备」,原设备继续用
单点在线 → 换设备返回 200,原设备下一次 heartbeat 收到 403「该设备没有有效的授权记录」被踢
多点在线 → 多台设备同时可用,各自独立心跳,互不影响

三种模式都受「一台机器只能激活一张卡」限制 —— 同一台机器换用另一张卡会顶掉原来那张(并把旧卡解绑)。
返回值:code / msg / data
字段类型说明
codeint200 成功,403 卡密无效/被封/换设备,其他见「错误码」
msgchar[]提示信息,失败时可直接展示给用户
data.expire_timechar[]到期时刻 YYYY-MM-DD HH:MM:SS;永久卡是 2099-12-31 23:59:59
data.card_typechar[]hour / day / week / month / season / year / permanent
data.custom_datachar[]验证时由客户端通过 custom_data 参数上传的自定义内容(如用户账号、机器信息),服务端会存进这张卡并在后续验证/心跳里原样回传;没传过就是空串。注意:不是生成卡密时写的
data.device_changedchar[] '1' = 本次验证发现这张卡原来绑的是别的设备,已换绑到当前设备;'' = 没换。
只有「机器码验证 = 单点在线」才会出现 '1',独占绑定模式换设备直接 403,多点在线模式不绑设备。
卡密模式换设备不扣时长 —— 所以这里没有 charged_hours, 那个字段只有账号模式(user_login)在 single_login=2 时才有。
data.noticechar[]程序公告,后台没填是 null
data.updateobject{ version, url, log, force },版本更新用
data.variablesobject 程序变量(键值对)。没配任何变量时是空对象 {}, 不是 null 也不是 [] —— 客户端按「有没有 key」判断, 不要拿它和 [] 或 null 比。 这个字段在 verify / heartbeat / 只读公告三个响应里形态一致。
signchar[64] 服务端签名(后端配了签名密钥就有)。算法: HMAC-SHA256(code + "|" + msg + "|" + json_encode(data), server_sign_key)。
客户端拿不到 server_sign_key,只需校验它 —— 这样即使 app_key 泄露,伪造的响应也过不了校验。
sign_algochar[]签名算法标识,当前固定 hmac-sha256
为什么要验 sign:外层 encrypted_data 的密钥就是 app_key, 而 app_key 必须下发到客户端才能调用接口。所以「能解密」不等于「来自真服务器」—— 持有 app_key 的人可以造出任何内容。
加上 sign 之后,客户端用服务端密钥校验,就能分辨真假。 强烈建议客户端校验 sign,否则整条链路的可信度只剩 TLS 一层。
sign 是所有接口通用的(不只是验证)
下面这张表列的是 verify 的响应,但 sign / sign_algo 是 output_json() 统一加的,所以每一个接口的每一个响应都带, 包括:heartbeat、user_register、user_login、 user_heartbeat、recharge、unbind、 ip_unbind、ip_unbind_user, 以及所有失败响应(400 / 403 / 404 / 429 / 500)。

验签只需要三步:
通用验签(伪代码)
payload = str(code) + "|" + msg + "|" + json_encode(data)   // data 为 null 时用 "null"
expect  = HMAC_SHA256(payload, server_sign_key)            // 输出小写十六进制
if (expect != resp.sign) { 拒绝这个响应 }
注意点说明
server_sign_key 从哪来 后台「程序管理 → 传输配置 → 响应签名密钥」。只放在你自己的服务端,不要打包进客户端。
data 为 null 时 拼字符串用字面量 null(不是空串)。
JSON 序列化顺序 服务端用 PHP 的 json_encode($data),字段顺序=数据库列顺序。 客户端不要自己重新排序,直接对收到的 data 原文做序列化即可; 若语言差异导致顺序不一致,就改成「先比对 sign,再解析 data」—— 即签名时用收到的原始 JSON 片段。
没有 sign 字段 说明该程序还没配签名密钥(后台可点「重新生成」补上)。 此时只能靠 TLS,建议尽快补。
换了密钥之后 旧客户端会全部验签失败 —— 必须和新版客户端一起切换。
最小可用代码
PHP
if (($r['code'] ?? 0) === 200) {
    $expire = $r['data']['expire_time'];
    // 通过:进主界面,把卡密和到期时间存起来
} else {
    die('验证失败:' . ($r['msg'] ?? '网络错误'));
}
把 card_code 和 device_hash 存到本地。 下次启动直接用 heartbeat 续期,不用让用户每次重新输卡密。
续费就是再买一张卡、在同一台设备上再验证一次 —— 服务端会发现这台设备已经绑过卡, 直接把新卡时长累加到原来那张卡上,不用用户做任何额外操作。

卡密心跳 heartbeat(card_code, device_hash)

请求方式:POST请求地址:/Verifyfile/api.php数据格式:application/json
请求参数
字段类型说明
actionchar[]固定 heartbeat
card_codechar[]必填已激活的卡密
device_hashchar[]条件必填必须是当初激活时那台设备
返回值:code / msg / data
字段类型说明
codeint200 表示心跳正常(类型随程序配置 code_type 变化)
msgchar[]提示信息
data.expire_timechar[]最新到期时间(YYYY-MM-DD HH:MM:SS)
data.noticechar[]应用公告,后台没填时为 null
data.updateobject{ version, url, log, force },版本更新用
data.variablesobject程序变量(键值对)。没配任何变量时是空对象 {}
signchar[64]响应签名,HMAC-SHA256
sign_algochar[]固定 hmac-sha256

心跳比 verify 少两个字段:不返回 data.card_type 和 data.custom_data —— 这两个是首次验证才下发的一次性信息,心跳不重复给。 其余四个 data 字段和 verify 完全一致。
失败时 code=403,客户端应该立刻退回输卡密界面,把本地存的卡密清掉。

心跳别省。默认 5 分钟一次,服务端靠它判断设备在线,也能实时踢掉被封的卡。 纯靠启动时验证一次,用户挂一整晚都不知道卡已经被封了。
掉线判定时间在后台「全局配置 → 心跳超时」里调,建议设为客户端心跳间隔的 3 倍。

解除设备绑定 unbind(card_code)

请求方式:POST请求地址:/Verifyfile/api.php数据格式:application/json
请求参数
字段类型说明
actionchar[]固定 unbind
card_codechar[]必填要解绑的卡密
返回值
字段类型说明
codeint200 成功;400 程序关闭了自助解绑 / 次数超限
msgchar[]成功时会说明扣了多少小时代价
data.penalty_hoursfloat本次解绑扣掉的时长,没配惩罚就是 0
解绑是可以在后台关掉的。「程序配置 → 允许自助解绑」(unbind_enabled)关掉后,客户端调这个接口会直接返回 400 该程序已关闭自助解绑,请联系管理员, 用户只能找你手动解绑。
还能配「N 小时内最多解绑 M 次」和「每次解绑扣 X 小时」,防止有人拿解绑当免费换机工具。
账号模式(用户名 / 密码)

除了卡密,还支持注册账号 → 用卡密充值 → 账号登录。两种模式可以同时用,老用户继续输卡密,新用户注册账号。 所有请求都发往同一个 /Verifyfile/api.php,只用 action 区分。

接口一览
action用途必填参数
user_register注册账号username password confirm_password
rebind 账号换绑(换机器自动解绑 + 扣时长)
user_login账号登录username password device_hash
user_heartbeat心跳续期username password statecode
recharge卡密充值username card_code
ip_unbind_user账号解绑 IPusername password
ip_unbind卡密解绑 IPcard_code(可选 device_hash)
顺序不能反:先 user_register 注册,再 recharge 充值,之后才能 user_login。 新注册的账号到期时间为空,直接登录会返回「账号未充值,请充值后登录」。

用户注册 user_register(username, password, confirm_password)

请求方式:POST请求地址:/Verifyfile/api.php数据格式:application/json
请求参数
字段类型说明
app_keychar[]必填应用的 64 位 AppKey,外层明文
actionchar[]固定 user_register
usernamechar[]必填2–50 位,只允许字母、数字、下划线、点、横线。同一应用内唯一
passwordchar[]必填至少 6 位
confirm_passwordchar[]必填必须与 password 完全一致
返回值:code / msg / data
字段类型说明
codeint200 成功,其他为失败
msgchar[]提示信息,失败时可直接展示给用户
data.usernamechar[]注册成功的用户名
data.idint用户 ID
data.expire_timechar[] 注册后立刻到期的时刻(YYYY-MM-DD HH:MM:SS)。 注册时带 card_code 或后台给了试用时长才有值,否则为 null。 客户端可直接用它显示「剩余 N 天」。
JSON
// 请求
{
  "app_key":          "你的64位AppKe...",
  "action":           "user_register",
  "username":         "zhangsan",
  "password":         "abc123456",
  "confirm_password": "abc123456"
}

// 响应
{ "code": 200, "msg": "注册成功", "data": { "username": "zhangsan", "id": 2 } }

卡密充值 recharge(username, card_code)

请求方式:POST请求地址:/Verifyfile/api.php数据格式:application/json
请求参数
字段类型说明
app_keychar[]必填应用的 64 位 AppKey
actionchar[]固定 recharge
usernamechar[]必填要充值的账号
card_codechar[]必填一张未使用过的卡密
返回值:code / msg / data
字段类型说明
codeint200 成功
data.expire_timechar[]充值后的到期时间,格式 YYYY-MM-DD HH:MM:SS
data.added_hoursfloat本次增加的时长(小时)
data.card_typechar[]卡密类型名称,如「月卡」
JSON
// 请求
{
  "app_key":   "你的64位AppKe...",
  "action":    "recharge",
  "username":  "zhangsan",
  "card_code": "TK3S2UF79TZ2WYSSJE"
}

// 响应
{
  "code": 200, "msg": "充值成功",
  "data": {
    "expire_time": "2026-11-29 14:54:24",
    "added_hours": 720,
    "card_type":   "月卡"
  }
}
卡密用掉立刻标记为已使用,同一张卡不能充两次。账号未过期时在原到期时间上累加,已过期则从当前时间重算。 永久卡不能充值(时长无限,无法累加),请改用卡密模式。

用户登录 user_login(username, password, device_hash)

请求方式:POST请求地址:/Verifyfile/api.php数据格式:application/json
请求参数
字段类型说明
app_keychar[]必填应用的 64 位 AppKey
actionchar[]固定 user_login
usernamechar[]必填注册时的用户名
passwordchar[]必填注册时的密码
device_hashchar[]选填16–64 位设备指纹,见下方 GetMachineCode()
设备策略由服务端配置决定 —— 客户端不需要传任何额外参数
「能不能换设备、换设备会不会让原设备掉线、要不要扣时长」全部由程序后台的 「账号设备策略」决定。客户端只要正常传 device_hash,行为自动跟随配置:
独占绑定 → 换设备返回 403,原设备继续用、不掉线
单点在线 → 换设备返回 200,原设备下一次 user_heartbeat 收到 403 被踢
单点在线 · 扣时长 → 同上,另外响应里 data.charged_hours > 0 表示扣了几小时
多点在线 → 多台设备同时在线,都不掉,data.online_devices 显示当前几台

判断「是否被顶下线」看 user_heartbeat 的 code, 不要看 online_devices —— 多点在线下它是 2、3 也属正常,不代表掉线。
返回值:code / msg / data
字段类型说明
codeint200 成功,403 凭据无效 / 未充值 / 已到期 / 设备冲突
data.expire_timechar[]当前到期时间
data.statecodechar[]会话令牌,心跳必须带上。每次登录都会刷新。
本身没有过期时间 —— 只有重新登录、自助换绑、管理员解绑、封禁、踢下线这几种情况才会变, 所以长时间挂机不会因为它掉线。心跳响应里也会回传同一个值,客户端可随时刷新本地副本。
data.noticechar[]应用公告,后台没填时为 null
data.kicked_previouschar[]本次登录是否顶掉了别的设备:"1" = 有,"" = 没有。
判据:登录前账号的旧 statecode 非空,且 last_login(登录和每次心跳都会刷新)在最近 15 分钟内 —— 两个都满足才算「有活跃会话」。
只看 statecode 非空会把「昨天登录过、客户端早关了」也当成在线,用户一登录就弹提示,那是误报,所以加了活跃时间窗。
客户端拿到 "1" 就提示「该账号已在其他设备登录,原设备已下线」;被顶的那台最迟下一次心跳收到 403 后自己退回登录页。两边合起来才是完整的顶号体验。
data.charged_hoursfloat 本次登录因「换设备」扣了几小时(single_login=2 时才有值)。
0 = 没扣(首次登录、同设备登录、或程序没开「换设备扣时长」)。
客户端可据此提示「换设备已扣除 24 小时」。扣多少由后台「账号设备策略」里的 unbind_penalty 决定。
data.device_changedchar[] 设备码是否变了:"1" = 变了,"" = 没变。
和 kicked_previous 不是一回事:kicked_previous 看「旧会话还活着吗」, device_changed 看「设备码变了吗」。
旧设备早关了(15 分钟没心跳)时,前者是 "",后者仍是 "1"。
data.online_devicesint 该账号当前在线设备数。single_login=3(多点在线)时会大于 1, 其它模式恒为 1(同一时刻只有一台)。
客户端可用它显示「当前 N 台设备在线」。
JSON
// 请求
{
  "app_key":     "你的64位AppKe...",
  "action":      "user_login",
  "username":    "zhangsan",
  "password":    "abc123456",
  "device_hash": "设备指纹"
}

// 响应
{
  "code": 200, "msg": "登录成功",
  "data": {
    "expire_time": "2026-11-29 14:54:24",
    "statecode":   "eba30d6d6a058bc4f1a2...",
    "notice":      "【公告】账号模式也能收到公告"
  }
}
首次登录会绑定 device_hash,之后换设备登录会被拒(403)。需要换设备请在后台「用户管理」里解绑。

用户心跳 user_heartbeat(username, password, statecode)

请求方式:POST请求地址:/Verifyfile/api.php数据格式:application/json
被顶下线怎么发现: 本接口返回 403 就是当前会话已失效(在其它设备登录 / 被管理员解绑 / 被踢)。 收到后应直接退回登录页,不要自动重登(重登会把对方顶掉,两台来回弹)。
多点在线(single_login=3)下不会因为别人登录而收到 403, 只有被管理员主动踢掉某一台才会。
被顶下线怎么发现: 本接口返回 403 就是当前会话已失效(在其它设备登录 / 被管理员解绑 / 被踢)。 收到后应直接退回登录页,不要自动重登(重登会把对方顶掉,两台来回弹)。
多点在线(single_login=3)下不会因为别人登录而收到 403, 只有被管理员主动踢掉某一台才会。
请求参数
字段类型说明
app_keychar[]必填应用的 64 位 AppKey
actionchar[]固定 user_heartbeat
usernamechar[]必填用户名
passwordchar[]必填密码
statecodechar[]必填登录时拿到的会话令牌
返回值:code / msg / data
字段类型说明
codeint200 成功,403 登录态失效 / 已到期 / 已禁用
data.expire_timechar[]最新到期时间
data.noticechar[]应用公告,后台没填时为 null
data.statecodechar[]原样回传登录时的 statecode,不刷新;客户端可继续用同一个
data.online_devicesint该账号当前在线设备数(1 = 只有自己)
JSON
// 请求
{
  "app_key":   "你的64位AppKe...",
  "action":    "user_heartbeat",
  "username":  "zhangsan",
  "password":  "abc123456",
  "statecode": "eba30d6d6a058bc4f1a2..."
}

// 响应
{ "code": 200, "msg": "心跳成功", "data": { "expire_time": "2026-11-29 14:54:24", "notice": "【公告】..." } }
心跳顺便把最新到期时间带回来,用户充值后最迟一个心跳周期就能看到新时间,不用重新登录。
和卡密模式一样,账号登录和心跳也都会带 data.notice(应用公告),非空就弹给用户看。
心跳还会回传 data.statecode,值和本次请求带上来的一模一样(服务端不换)。 客户端拿到后直接覆盖本地存的令牌即可,不必再多调一次 user_login。

用户公告(跟着登录/心跳下发)

请求方式:POST请求地址:/Verifyfile/api.php数据格式:application/json没有独立 action,跟着 user_login / user_heartbeat 一起返回
和卡密模式完全一致:后台「程序管理 → 公告管理」填的内容,会在 user_login 和 user_heartbeat 的响应里以 data.notice 一起返回。 后台没填就是 null。客户端拿到非空字符串直接弹窗,不需要单独调接口。
返回位置
接口字段说明
user_logindata.notice登录成功时下发
user_heartbeatdata.notice每次心跳都下发,公告改了最迟一个心跳周期就能收到
user_heartbeatdata.statecode原样回传本次请求的令牌(服务端不重新生成),可直接覆盖本地值
verify / heartbeatdata.notice卡密模式同样下发
JSON
// 登录 / 心跳的 data 里都会带这个字段
{
  "code": 200,
  "data": {
    "expire_time": "2026-11-29 14:54:24",
    "statecode":   "eba30d6d6a058bc4f1a2...",
    "notice":      "【公告】服务器今晚 23:00 迁移"   // 没公告时为 null
  }
}

账号解绑 IP ip_unbind_user(username, password)

请求方式:POST请求地址:/Verifyfile/api.php数据格式:application/json
用户换了网络(IP 变了)登录被拒时,用这个接口自己解绑。解绑后可以在任意网络登录,下次登录会重新绑定当前 IP。 只有应用开启了「IP 校验」时才需要,没开的话 IP 不影响登录。
请求参数
字段类型说明
app_keychar[]必填应用的 64 位 AppKey
actionchar[]固定 ip_unbind_user
usernamechar[]必填用户名
passwordchar[]选填密码。选填但强烈建议传:不传会走到「用户名或密码错误」,只有 username 完全没传时才报「缺少用户名」
这个接口只在程序开了「IP 绑定」时才有意义。
后台「程序配置 → 防护」里的 ip_check 为 0(默认)时, 服务端从头到尾不记录 IP,所以调用本接口会一律返回 403「当前没有绑定 IP」—— 这不是出错,是本来就没有可解绑的东西。
要让它生效:先把 ip_check 设为 1, 之后客户端每次 verify / user_login 会把当时来源 IP 记下来 (精度由 ip_precision 决定:0=完整 IP,1=前三段), 这时再调本接口才会返回 200 和 data.old_ip。
返回值
字段类型说明
codeint200 成功,403 密码错误 / 未绑定 IP / 已禁用
data.old_ipchar[]解绑前绑定的 IP,便于展示
JSON
{
  "app_key":  "你的64位AppKe...",
  "action":   "ip_unbind_user",
  "username": "zhangsan",
  "password": "abc123456"
}

// 响应
{ "code": 200, "msg": "IP 解绑成功", "data": { "old_ip": "112.19.228.181" } }

卡密解绑 IP ip_unbind(card_code, device_hash?)

请求方式:POST请求地址:/Verifyfile/api.php数据格式:application/json
卡密模式下,这张卡绑定的 IP 不对了(换网络、换机房),用这个接口解绑。 只能解绑还没过期的卡。传了 device_hash 会额外校验归属,防止别人拿泄露的卡密给你解绑。
请求参数
字段类型说明
app_keychar[]必填应用的 64 位 AppKey
actionchar[]固定 ip_unbind
card_codechar[]必填要解绑的卡密
device_hashchar[]选填可选。传了会校验这张卡是不是绑在这台设备上
这个接口只在程序开了「IP 绑定」时才有意义。
后台「程序配置 → 防护」里的 ip_check 为 0(默认)时, 服务端从头到尾不记录 IP,所以调用本接口会一律返回 403「当前没有绑定 IP」—— 这不是出错,是本来就没有可解绑的东西。
要让它生效:先把 ip_check 设为 1, 之后客户端每次 verify / user_login 会把当时来源 IP 记下来 (精度由 ip_precision 决定:0=完整 IP,1=前三段), 这时再调本接口才会返回 200 和 data.old_ip。
返回值
字段类型说明
codeint200 成功,403 卡密无效 / 没绑定记录 / 设备不匹配
data.old_ipchar[]解绑前的 IP
JSON
{
  "app_key":     "你的64位AppKe...",
  "action":      "ip_unbind",
  "card_code":   "TK3S2UF79TZ2WYSSJE",
  "device_hash": "可选的设备指纹"
}

// 响应
{ "code": 200, "msg": "IP 解绑成功", "data": { "old_ip": "112.19.228.181" } }
账号换绑 rebind(换机器自动解绑 + 扣时长)
账号模式开了「单点登录」(single_login=1)时,账号绑死在第一个登录的设备上。 用户换电脑会收到 403「该账号已绑定其他设备」——这时候调 rebind, 就能用账号密码 + 新设备码自动换绑,服务端按程序的「解绑扣时」配置扣时长。
和卡密模式的 unbind 是两套:unbind 按 card_code 解卡密↔设备, rebind 按 username+password 解账号↔设备。
请求参数
参数类型说明
actionchar[]固定 rebind
usernamechar[]必填账号
passwordchar[]必填密码(会校验,不是只传用户名就行)
device_hashchar[]必填新机器的设备码,16–64 位
400程序关闭了自助换绑(后台「自助解绑 / 换绑」选了「禁止」) 把 msg 显示给用户:「该程序已关闭自助换绑,请联系管理员」。 不要当 403 处理 —— 这不是凭据失效,不用清缓存或踢用户下线
402剩余时长不够扣(需 need_hours,剩 remain_hours) 提示「请先充值再换绑」,此时不扣时长、设备码也不改
返回
字段类型说明
data.expire_timechar[]扣完时长后的新到期时间
data.penalty_hoursfloat本次扣了多少小时(后台配 unbind_penalty,0 表示不扣)
data.noticechar[]应用公告,没配就 null
错误码分三种,客户端要分开处理
codemsg 示例客户端该做什么
402剩余时长不够换绑
示例:「剩余时长不够换绑(需 24 小时,当前剩 10 小时)。换绑会扣除时长且不能扣到 0,请先充值再换绑」
边界要记住:剩余时长 ≤ 惩罚时长时一律拒绝。 天卡(24 小时)碰上「扣 24 小时」,换一次绑就会把卡扣成 0 小时 —— 这种「刚好够扣完」也算不够,直接 402 让你去充值, 而不是扣完剩 0 让用户的卡凭空消失。
响应的 data 里带 need_hours(需要多少)和 remain_hours(当前剩多少),可以直接拿来提示。
永久卡(到期时间 2099-12-31)不受这条限制 —— 扣时对它没有意义,按 0 惩罚处理。
引导充值。返回里带 remain_hours 和 need_hours
429换绑次数已达上限(累计最多 2 次) 提示联系管理员。返回里带 limit 和 used
403密码错 / 账号禁用 / 当前设备就是绑定设备 / 没绑过设备 当成普通失败,不要弹「充值」
换绑成功后,旧设备会被立刻踢下线。
服务端会清空 statecode,旧设备的下一次 user_heartbeat 直接返回 code=403「账号已在其他设备登录(或管理员已解绑),当前会话已失效,请重新登录」。
这是故意的,不然旧机器还能一直用。
另外注意:程序开了单点登录时,user_login 的 device_hash 是必填的。
不传会返回 400「该应用已开启单点登录,必须提供设备标识(device_hash)参数」, 而不是悄悄放行。
后台可配的四项(程序配置里)
配置默认作用
unbind_enabled1是否允许自助换绑。设 0 后调 rebind 一律 403
unbind_penalty0扣多少小时。0 = 不扣,直接换绑
unbind_limit0次数上限。0 = 不限,>0 = 最多这么多次
unbind_window00 = 永久累计;>0 = 按小时窗口计数
客户端怎么接(C# 示例)
// 登录被拒时自动换绑
var r = gy.Call(card, device, "user_login");
if (r.code == "403" && r.msg.Contains("已绑定其他设备")) {
    // 先弹确认框,别静默扣时长
    if (MessageBox.Show("换电脑需要换绑,将扣除时长,继续吗?", "确认",
                       MessageBoxButtons.YesNo) == DialogResult.Yes) {
        var rb = gy.Call2("rebind", new {
            username = user, password = pass, device_hash = device
        });
        if (rb.code == "200") {
            MessageBox.Show("换绑成功,新到期时间:" + rb.data.expire_time);
            gy.Call(card, device, "user_login");   // 重新登录
        } else if (rb.code == "402") {
            MessageBox.Show("时长不足,请先充值");
        } else {
            MessageBox.Show("换绑失败:" + rb.msg);
        }
    }
}

三个最容易踩的格式坑

  • _ts 必须是秒,不是毫秒:10 位整数(如 1761912000)。传 13 位毫秒(如 1761912000123)会被判成「请求已过期」,因为差值直接超出 ±300 秒窗口
  • device_hash 长度必须 16–64 字符:短于 16 或长于 64 返回 设备码格式不正确。用 SHA-256 十六进制串(64 位)或 UUID(36 位)都合适
  • app_key 必须 64 位十六进制:不是 Base64,也不是截断的短串。长度不对会直接判 应用身份标识无效
  • verify 连续失败会限频:同一 IP 1 分钟内失败 30 次,后续请求返回 429 验证失败次数过多,请 1 分钟后再试。成功一次就清零。 调试时注意:别用同一个 IP 连续试错卡号,很容易把自己锁 1 分钟。

时间字段说明(expire_time / added_hours)

所有时间都是 服务器本地时间的字符串,格式固定为 yyyy-MM-dd HH:mm:ss,不带时区后缀。 客户端直接按本地时间解析即可;若服务器与用户不在同一时区,以「剩余秒数」为准,不要拿绝对时间去比。
expire_time 的三种形态
返回示例含义客户端建议处理
"2026-11-30 23:59:59" 普通到期时间 正常解析并显示剩余天数/小时
"2099-12-31 23:59:59" 永久有效 当年份 ≥ 2099 就当永久卡,别去算剩余天数(否则会显示「还有 26000 多天」)
null 账号未充值 / 未激活 注册后还没充过值。提示用户去充值,不要当成「已过期」
永久卡判断
PHP
// —— 通用判断:年份 >= 2099 即永久 ——

// PHP
$isPermanent = strpos($expireStr, '2099-') === 0;
剩余时长计算
  • 接口返回的是到期时刻,不是剩余秒数;需要剩余时间就用「到期时刻 − 当前时刻」自己算
  • 永久卡(2099)算出来的剩余天数没有意义,界面上直接显示「永久」
  • expire_time 为 null 时不要参与任何时间计算,按「未充值」分支处理
  • recharge 接口额外返回 data.added_hours(本次充值的时长小时数),可用于提示「已增加 N 小时」
  • 多张卡可以叠加:同一个账号连续充多张卡,时长累加,不会覆盖。 例:天卡(24h)→ 周卡(168h)→ 月卡(720h),最终有效期 = 24+168+720 = 912 小时。
  • 账号未过期时从当前到期时间往后加;已过期时从当下开始算(不会把过期的空档也补上)。
  • 每张卡只能充一次(cards.status 置 1),同一张卡重复调 recharge 返回 400「该卡密已被使用或不处于可充值状态」。
  • 永久卡不能用于 recharge(时长无限无法累加),文档前面已说明;账号体系里请改用月卡/年卡

错误码速查

  • 400 — 参数错误 / 参数类型不对(传了数组或对象)/ 加密格式不对 / 时间戳过期 / 业务前置条件不满足
  • 403 — 鉴权失败 / 卡密被封 / 程序被禁用 / 账号到期 / 卡密已冻结 / 解绑被拒
  • 429 — 触发频率限制,客户端应退避重试
  • 500 — 服务端异常
每个参数都必须是字符串。把 card_code 传成数组 ["a","b"] 或对象 {"$ne":null} 时,服务端返回 400 参数 card_code 格式无效:必须是字符串;card_code 缺失时返回 400 缺少卡密(card_code)参数, 不会被"当成空值"继续往下走。这是防止类型混淆攻击。

这条对所有接口的所有参数都适用,不只 card_code:device_hash、 device_hash、username、password、statecode、 note、pre、reason 传数组或对象时,一律返回 400 参数 xxx 格式无效:必须是字符串。
整数/浮点/布尔/空值会被宽松处理(分别转成字符串 / 1·0 / 空串), 只有数组和对象会被明确拒绝。
复算签名时必须注意两件事
客户端要自己验 sign 时,签的内容是 code|msg|json(data) 三段拼接,用 server_sign_key 做 HMAC-SHA256。有两处很容易踩:

1. data.variables 必须按对象序列化。
服务端下发的是 {}(空对象),但很多语言把它解析成空数组/空字典, 再序列化就变成 [] —— 差一个括号,签名立刻对不上。
复算前先把 variables 强制成对象 (PHP 用 (object),Python 用 dict(), JS 直接就是对象不用转)。

2. 三段之间用竖线 | 拼接,不是拼字符串也不是拼整包 JSON。
json(data) 里的 key 顺序必须和服务端一致 —— 服务端按 PHP 数组 原始顺序序列化,所以最省事的做法是直接从解密后的明文里截 data 那段原文, 不要解码再编码。

验不过就先打印自己的拼接串和服务端的比,多数情况是上面第 1 条。
参数表里三个标记的含义
必填 不传直接 400,文案会说清缺哪个参数。
选填 可以不传,服务端有默认值或按其它规则处理。
条件必填 看程序配置决定,具体条件写在这一行的说明里。

所有参数都必须是字符串或数字,传数组或对象一律 400「参数 xxx 格式无效」, 不会静默转成 "Array"。数字型参数(num / len / app_id / custom_hours)传数组也是 400。
code 的类型取决于程序配置
code 不一定是数字,由程序配置里的 code_type 决定:
code_type = string(默认)→ 返回 "code": "200",是字符串
code_type = int → 返回 "code": 200,是数字

客户端必须用「弱比较」判断(PHP 用 == 不用 ===, 其它语言先转成数字再比),否则程序换成 int 之后 code === "200" 会失效,看起来像「所有接口都失败」。

同理 resp_format = text 时响应只有两行纯文本 code\nmsg, 没有 JSON 结构也没有加密;resp_ts = 1 时会多一个 time 字段(服务器当前秒级时间戳)。这三项都在「程序配置 → 返回值配置」里改。
状态文案对照(客户端可直接展示 msg)
返回 msgcode触发原因
首次激活成功 / OK200卡密有效,已完成设备绑定
程序已经禁用,请联系开发者403程序被管理员手动禁用,或开发者账号到期自动禁用。续费后自动恢复
这张卡暂时无法使用,请联系卖家403卡密所属代理到期,未激活的卡被系统冻结。代理续费后自动解冻
此卡密已被管理员封禁403管理员手动封禁(优先级高于冻结)
访问受限:设备或IP已被云端封禁403命中黑名单。app_id=0 的全站黑名单对所有程序生效,程序专属黑名单只对该程序生效
无效的卡密 (或不属于当前应用)403卡密不存在,或卡密属于别的程序(app_key 与卡密不匹配)
该设备已绑定其他卡密,一个设备只能使用一张卡403一机一卡策略(bind_policy=strict),需先解绑
账号未充值,请充值后登录403账号体系里注册后还没充过值
账号已到期,请充值后登录403账号时长用完
解绑次数已达上限(N 小时内最多 M 次)400超出程序配置的自助解绑次数
请求过于频繁,请稍后再试429触发限频,退避后重试

调用示例

PHP
// —— PHP ——
$r1 = s6_call($api, $appKey, ['action'=>'ip_unbind_user', 'username'=>$u, 'password'=>$p]);
$r2 = s6_call($api, $appKey, ['action'=>'ip_unbind', 'card_code'=>$card]);
IP 校验要不要开?默认是关的。开了之后用户换网络就登不上,必须调解绑接口或找管理员。 家用宽带 IP 会变、手机 4G/5G 换基站也会变,除非你做的是固定 IP 的网吧/公司内网场景,否则不建议开。 IP 精度可以在「程序管理 → 验证配置」里调成网段级,减少误伤。

错误处理(客户端要做的分流)

账号模式下最常见的四类失败,建议按 code + msg 分流处理。 不要只看 code:403 有四种含义,靠 msg 区分。
分流对照
codemsg客户端动作
403账号未充值,请充值后登录弹充值窗口,让用户输卡密调 recharge
403账号已到期,请充值后登录同上,文案可换成「已到期,请续费」
403用户名或密码错误提示检查输入,不要清空已填的用户名
403该账号已绑定其他设备,如需更换请联系管理员解绑显示联系方式,或调 ip_unbind_user 自助解绑
403账号已被禁用提示联系管理员,不要循环重试
400该用户名已被注册 / 密码至少 6 位 / 用户名长度需在 2–50 位之间在注册表单上就地提示
PHP
// —— PHP ——
$lg = s6_call($api, $appKey, ['action'=>'user_login', 'username'=>$u, 'password'=>$p, 'device_hash'=>$mc]);
if (($lg['code'] ?? 0) === 200) { enterMain($lg['data']['expire_time'], $lg['data']['statecode']); }
elseif (strpos($lg['msg'] ?? '', '未充值') !== false || strpos($lg['msg'] ?? '', '已到期') !== false) { showRecharge(); }
elseif (strpos($lg['msg'] ?? '', '其他设备') !== false) { showUnbind(); }
else { showToast($lg['msg'] ?? '登录失败'); }
心跳里也会返回同样的 msg。如果用户在使用过程中到期,user_heartbeat 会返回 403, 客户端应该立刻退回登录页并提示续费,而不是继续静默失败。
错误码对照
场景codemsg
用户名重复400该用户名已被注册
两次密码不一致400两次输入的密码不一致
用户名格式不合规400用户名长度需在 2–50 位之间
密码太短400密码至少 6 位
账号密码错误403用户名或密码错误
未充值就登录403账号未充值,请充值后登录
账号到期403账号已到期,请充值后登录
换设备登录403该账号已绑定其他设备,如需更换请联系管理员解绑
卡密无效 / 已用403该卡密已被使用或不处于可充值状态
statecode 失效403账号已在其他设备登录(或管理员已解绑),当前会话已失效,请重新登录
同一账号在别处登录成功时,服务端会生成新 statecode 覆盖旧的,旧设备下一次心跳必然收到这条 —— 客户端收到就应当直接退到登录页并关掉核心功能。
账号被禁用403账号已被禁用
调用示例(复用上面的加解密函数)
PHP
// —— PHP ——
$reg = s6_call($api, $appKey, ['action'=>'user_register', 'username'=>$u, 'password'=>$p, 'confirm_password'=>$p]);
$rc  = s6_call($api, $appKey, ['action'=>'recharge', 'username'=>$u, 'card_code'=>$card]);
$lg  = s6_call($api, $appKey, ['action'=>'user_login', 'username'=>$u, 'password'=>$p, 'device_hash'=>$mc]);
$hb  = s6_call($api, $appKey, ['action'=>'user_heartbeat', 'username'=>$u, 'password'=>$p, 'statecode'=>$lg['data']['statecode']]);
用加密时 action 要放在密文里面,跟 card_code 一样。外层 app_key 保持明文,服务端靠它选密钥。
当前语言: PHP · 左侧「SDK 快速接入」可切换其它语言
PHP SDK
S6网络验证 PHP SDK —— 不用碰加密。require 一次,new 一次,后面全是方法调用。
下载 SDK 接入包(AppKey 自己填)
直接,点 「下载 PHP SDK(.zip)」 —— demo.php 的接口地址和 AppKey 已经填好了:
s6sdk-php/
  s6verify.php      核心 SDK
  demo.php          示例(AppKey 已注入)
  README.md         加密协议 + 方法速查
下载 PHP SDK(.zip) 接口地址已填好;AppKey 自己填(后台「地址信息」里复制)
适用场景
  • PHP 网站后端做中转验证(客户端 → 你的服务器 → S6)
  • 发卡网 / 卡密销售站对接
  • 命令行运维脚本、定时任务
  • ThinkPHP / Laravel / 原生 PHP 都可以
依赖说明
只用 PHP 自带的 openssl(AES-CBC / AES-GCM)、hash(HMAC)、 json、curl。
不需要 Composer,不需要任何扩展。PHP 7.0+ 都能跑(AES-GCM 需要 openssl 扩展,虚拟主机一般都开了)。
没装 curl 会自动退回 file_get_contents(要求 allow_url_fopen=On)。
初始化
require_once __DIR__ . '/s6verify.php';

$v = new S6Verify(
    'https://你的域名/Verifyfile/api.php',
    '你的64位AppKey'
);

$v->setDevice(get_machine_code());
$v->setTimeout(15);   // 秒,默认 15
辅助方法
方法说明
S6Verify::version()SDK 版本号
$v->lastError最近一次本地失败原因。业务错误在 $r->msg
$v->setDevice($h)设置设备码,返回 this
$v->setTimeout($sec)HTTP 超时秒数
$v->setVerifyTls(false)关 TLS 校验,仅自签证书调试用
推荐调用顺序
require_once 's6verify.php';
$v = (new S6Verify(API_URL, APP_KEY))->setDevice(get_machine_code());

// ① 先拉公告和版本(不需要卡密)
$n = $v->getNotice();
if ($n->ok) {
    if ($n->notice !== '')     show_notice($n->notice);
    if ($n->update_ver !== '') ask_update($n->update_url, $n->force_update === 1);
}

// ② 卡密验证
$r = $v->verify($card);
if (!$r->ok) { echo "验证失败: {$r->msg}"; exit(1); }
enter_main($r->expire_time);

// ③ 心跳:真实项目用 crontab 每 3~5 分钟跑一次
$h = $v->heartbeat($card);
if (!$h->ok) force_back_to_login($h->msg);
API 方法参考
所有方法返回 S6Result 对象。业务结果看 $r->ok(等于 $r->code === 200)。
S6Result 属性
属性类型说明
okbooltrue = 业务成功
codeint服务端 code,或 -1
msgstring可直接展示给用户
expire_timestring到期时间
statecodestring账号模式会话令牌
kicked_previousstring'1' = 顶掉了别的设备
charged_hoursfloat换设备扣了几小时
device_changedstring'1' = 设备码变了
online_devicesint当前在线设备数
card_typestring卡类型
custom_datastring自定义数据
noticestring公告
update_ver / update_url / force_update混合版本更新
variablesarray程序变量,没配时是空数组
rawstring原始 JSON
拉公告与版本 $v->getNotice()
$n = $v->getNotice();
if ($n->ok) {
    echo "公告: {$n->notice}";
    foreach ($n->variables as $k => $val) {
        echo "变量 {$k} = {$val}\n";
    }
}
不需要卡密也不需要账号,启动时先调一次。
卡密验证 $v->verify($card)
$r = $v->verify('KFF669EJ92NVKZYS');
if ($r->ok) {
    echo "验证通过,到期: {$r->expire_time}  类型: {$r->card_type}";
} else {
    echo "验证失败[{$r->code}]: {$r->msg}";
}
code含义
200验证通过
400参数不对 —— 改代码
403卡无效 / 已绑其他设备 / 已过期
429太频繁,等几秒
卡密心跳 $v->heartbeat($card)
$h = $v->heartbeat($card);
if (!$h->ok) force_back_to_login($h->msg);   // 403 = 被踢,别自动重登
自助解绑 $v->unbind($card)
$u = $v->unbind($card);
if ($u->ok)                        echo '已解绑';
elseif ($u->code === 402)         echo '剩余时长不够扣';
else                             echo $u->msg;
账号注册 $v->userRegister($u, $p)
$r = $v->userRegister('玩家昵称', '密码');
echo $r->ok ? '注册成功' : $r->msg;
账号登录 $v->userLogin($u, $p)
$r = $v->userLogin($user, $pwd);
if ($r->ok) {
    $statecode = $r->statecode;                       // 心跳必须带上
    if ($r->kicked_previous === '1') tip('已在其他设备登录,原来的被挤下线');
    if ($r->charged_hours > 0)      tip("换设备扣了 {$r->charged_hours} 小时");
    if ($r->online_devices > 1)     tip("当前 {$r->online_devices} 台设备在线");
    enter_main($r->expire_time);
} elseif ($r->code === 402) {
    tip('换设备要扣时长,你剩余不够');
} else {
    tip($r->msg);
}
设备策略由服务端配置决定 —— 客户端不用传额外参数
独占绑定 → 换设备 403,原设备继续用
单点在线 → 换设备 200,原设备下次心跳 403
多点在线 → 多台同时在线,online_devices 显示几台
判断「是否被顶下线」看心跳 code,不要看 online_devices。
账号心跳 $v->userHeartbeat($u, $p, $sc)
$h = $v->userHeartbeat($user, $pwd, $statecode);
if (!$h->ok) force_back_to_login($h->msg);
else $statecode = $h->statecode;   // 可能刷新
卡密充值 $v->recharge($u, $card)
$r = $v->recharge($user, $card);
echo $r->ok ? "充值成功,到期 {$r->expire_time}" : $r->msg;
多张卡可以叠加:同一账号连续充多张,时长累加不覆盖。
账号换绑 $v->rebind($u, $p)
$r = $v->rebind($user, $pwd);
if ($r->ok)                    echo '换绑成功';
elseif ($r->code === 400)      echo '管理员关闭了自助换绑';
elseif ($r->code === 402)      echo '剩余时长不够扣';
else                         echo $r->msg;
IP 解绑 $v->ipUnbind($card) / $v->ipUnbindUser($u, $p)
$a = $v->ipUnbind($card);            // 卡密
$b = $v->ipUnbindUser($user, $pwd);  // 账号
var_dump($a->ok, $a->msg);
完整可跑示例
<?php
require_once __DIR__ . '/s6verify.php';

define('API_URL', 'https://你的域名/Verifyfile/api.php');
define('APP_KEY', '你的64位AppKey');

function get_machine_code() {
    $raw = '';
    if (PHP_OS_FAMILY === 'Windows') {
        $out = @shell_exec('wmic csproduct get UUID 2>nul');
        if ($out) {
            foreach (preg_split('/[\r\n]+/', trim($out)) as $line) {
                $line = trim($line);
                if ($line !== '' && stripos($line, 'UUID') === false) { $raw = $line; break; }
            }
        }
    } else {
        $raw = @file_get_contents('/etc/machine-id') ?: '';
        $raw = trim($raw);
    }
    if ($raw === '') $raw = php_uname('n');
    return substr(hash('sha256', $raw), 0, 32);
}

$v = (new S6Verify(API_URL, APP_KEY))->setDevice(get_machine_code());

$n = $v->getNotice();
if ($n->ok && $n->notice !== '') {
    echo "公告: {$n->notice}\n";
}

$card = $argv[1] ?? '你的卡密';
$r = $v->verify($card);
if (!$r->ok) {
    echo "验证失败[{$r->code}]: {$r->msg}\n";
    exit(1);
}
echo "验证通过,到期: {$r->expire_time}\n";

// 真实项目用 crontab 每 3~5 分钟跑一次心跳,不要放在 HTTP 请求里
$h = $v->heartbeat($card);
if (!$h->ok) {
    echo "心跳失败[{$h->code}]: {$h->msg}\n";
    exit(1);
}
echo "心跳正常\n";
常见坑
现象原因 / 处理
code=-1,lastError 有值本地失败:网络 / DNS / 证书 / 解密
openssl_decrypt() returns falseopenssl 扩展没开,或者响应 encrypted_data 被截断(检查 post_max_size)
超时虚拟主机挡了出站 HTTPS,问空间商;或把 setTimeout 调大
403 但卡是好的设备码变了。wmic 在 32 位 PHP 跑 64 位系统时可能取不到,要有兜底
心跳一直 403账号模式 statecode 过期。重新 userLogin
页面卡 3 秒在 HTTP 请求里同步调了验证接口。改成后台任务或用缓存
设备码怎么取
方案稳定性
SMBIOS UUID(推荐)Windows wmic csproduct get UUID,Linux /etc/machine-id
主机名 + 主板序列号次选,组装机可能是空串要兜底
MAC 地址(不推荐)换网卡 / 虚拟机迁移就变
SHA-256 取前 32 位 hex 再传。别开 IP 验证,家庭宽带 IP 会变。

服务器中转场景:如果客户端打不到公网(比如内网部署),可以让客户端请求你自己的 PHP 接口, 由你的服务器用这套 SDK 去调S6 —— 但设备码要由客户端上报,不能统一用服务器的机器码。
接入必看的两条铁律:
① 到期时间只在本地当缓存用,真正判断永远以服务端响应为准。 客户端时间随便改,别拿 expire_time 当唯一依据。
② 心跳别省。默认 5 分钟一次,服务端能实时踢掉被封的卡; 只在启动时验一次,用户挂一晚都不知道卡已经被封了。
设备指纹生成(各平台)
服务端只校验格式(16–64 位字母数字),内容由客户端决定。要求同一台机器每次都一样。
平台取什么注意
iOS越狱:MGCopyAnswer(@"UniqueDeviceID");非越狱:Keychain UUID擦机才变,删 App 不变
Windows EXE注册表 MachineGuid + 主板/硬盘序列号重装系统才变;别用随机 GUID 存文件
AndroidSettings.Secure.ANDROID_ID + 序列号恢复出厂才变
macOSIOPlatformUUID重装不变
Linux/etc/machine-id + 网卡 MAC重装才变
推荐做法:把取到的硬件信息拼起来做 SHA256,取前 32 位 hex。 不要包含网卡 MAC 单独一位——用户换个网卡就锁死了。
GetMachineCode() 各平台实现

机器码由客户端本地计算,服务端只做字符串比对,没有「查机器码」的接口。 要求:同一台机器每次算出来完全一样;不同机器不撞。

// —— PHP ——
function getMachineCode(): string {
    $src = php_uname('n') . '|' . php_uname('m') . '|' . APP_KEY;
    return substr(hash('sha256', $src), 0, 32);
}
不要用「随机生成一次然后存本地文件」当机器码。用户删了配置文件就变成新机器,卡直接锁死,还得找你解绑。
也不要只用 MAC 地址——换个网卡就换了机器码。
S6网络验证 · 公开接口文档