Z Zise Developers

强认证

有几件事不可逆,光有一把 API Key 不够 —— 必须让终端用户本人当场再证明一次

是他。我方的做法是:把这一步放在我方托管的一屏上完成,你不接触任何因子。

红线:我方不接受你的自证

请求体里 "step_up_passed": true、请求头里 x-verified: 1 这类字段,

我方一个都不认,也永远不会加

这不是不信任你。受这道闸保护的是提现地址新增、卡片激活、查看完整卡号 CVV

这几件不可逆的事 —— 一旦这道闸能由调用方自己声明通过,它就不是一道闸了,

而是一个字段。你那边任何一次 SSRF、任何一个能构造请求体的注入点,

都会直接变成「往任意地址提币」。

所以这条流程里你要做的只有两件事:把 URL 交给终端用户

以及拿到票据后重发

哪些动作要

动作端点action
新增提现地址POST /v1/withdraw-addresseswithdraw_address_add
查看完整卡号 / CVVPOST /v1/cards/{id}/secure-sessioncard_secure
激活实体卡POST /v1/cards/{id}/activatecard_activate:<卡 id>

⚠ 删除提现地址(DELETE /v1/withdraw-addresses/{id}不挂强认证 ——

删除是往安全方向走的动作,不该设障碍。

⚠ 部分兑换币对在我方后台配了「需提权」。那种币对在开放 API 上一律拒

(返回 step_up_required且不带 hosted_url),不是「跳过」。

本期开放 API 不开放兑换的提权档 —— 碰到它请与你的客户经理确认这个币对的配置。

交互

第一次调用(不带票据)→ 400


{
  "type": "invalid_request_error",
  "code": "step_up_required",
  "message": "Strong authentication is required. See hosted_url / challenge_id.",
  "request_id": "…",
  "challenge_id": "chl_9f2c…",
  "hosted_url": "https://…/hosted/step-up/chl_9f2c…",
  "expires_at": 1754872500
}

你把 hosted_url 交给终端用户(App 内浏览器打开、或短信/推送发给他)。

那一页在我方域内,我方向他的注册邮箱发一个 6 位验证码,他填对即通过。

expires_at 是 Unix 秒,有效期 5 分钟

⚠ 我方不会在他完成之后回调你。这一页不签发任何会话、不返回任何东西给你 ——

它只把那张票据置成「已通过」。你那边的做法是让用户点一个「我已完成验证」,

或者在页面关闭后重发。

第二次调用同一把幂等键、同一份请求体,加一个头:


POST /v1/withdraw-addresses
x-idempotency-key: <与第一次完全相同>
x-step-up: chl_9f2c…

请求体也要与第一次逐字节相同(签名要重算 —— 时间戳和 nonce 都变了)。

这是幂等表里唯一一处「同键重发应当真的执行」

平时的规矩是:同一把幂等键得到同一个结果,包括错误

第一次拿到 400,第二次拿同一把键发过去,拿回的还是那个 400 的原样回放。

强认证这条是唯一的例外,我方在服务端专门为它开了一个口子。

理由是:同键判据只算请求体、不含任何请求头,而 x-step-up 是请求头 ——

带不带它算出来的哈希一模一样。不开这个口子的话,你按文档重发的那一次会命中

回放分支,把那份已经过期的 step_up_required 原样回放,

业务处理器根本不会执行。也就是说这条流程会在代码层面永远走不完。

所以:

照常回放 —— 那种情况下你要做的是改正之后换一把新键

票据的绑定与有效期

一张票据绑死三样,任何一样对不上都当作没有这张票(于是你会拿到一张新的

step_up_required 而不是一个说明原因的错误):

绑定少了它会怎样
会员你能拿 A 完成的票据去动 B 的钱
动作一张「激活卡片」的票据能用来查看卡密
已通过标志签发即可用 —— 整条链退化成「你自己声明通过了」

外加两条:

磨蹭超过 5 分钟,票据就没了,你会拿到一张新的 step_up_required

这是刻意的短。

查看卡密这一档不一样

POST /v1/cards/{id}/secure-session 过了强认证之后,返回的不是卡号

而是第二个 hosted_url


{ "issued": true, "hosted_url": "https://…/hosted/card/chl_…", "expires_at": … }

完整卡号、有效期、CVV 永不经过你的服务器。它们由我方在那一页上从上游实时拉取、

只渲染给那一个浏览器,不落库、不进日志、不进缓存,60 秒后页面自动遮蔽。

你拿到的只有「票据已签发」这个事实。这是刻意的:卡密经过你的服务器一次,

你就落进了持卡人数据的合规范围里。

排查

现象多半是
重发之后还是 step_up_requiredchallenge_id 换了一个新的终端用户其实没走完那一页;或票据已过 5 分钟;或 x-step-up 里填的是整条 hosted_url 而不是 challenge_id
重发拿回 409 idempotency_key_reused请求体与第一次不一致(哪怕只差一个空格),或这把键打到了另一个端点
换了新幂等键之后成功了,但对账多了一笔你换键重发了一次已经完成的操作。这一档必须沿用原键

⚠ 还有一条与平时相反的:重发成功之后,那把键不会缓存成功结果

再拿它发同一个请求,拿到的是 409 idempotency_in_progress(而不是回放那次成功),

且这个状态会一直持续到 24 小时窗口结束。

所以强认证这条链上,幂等键只用于「第一次 → 带票据重发」这一对

重发拿到明确响应之后就不要再用它了;真的没拿到响应(网络断在半路),

去用 GET 类端点回查结果,不要盲目再发一次。