会员的高危动作要一张强认证票据 —— 谁来出,怎么用。
强认证
有几件事不可逆,光有一把 API Key 不够 —— 必须让终端用户本人当场再证明一次是他。我方的做法是:把这一步放在我方托管的一屏上完成,你不接触任何因子。
红线:我方不接受你的自证
请求体里 "step_up_passed": true、请求头里 x-verified: 1 这类字段,我方一个都不认,也永远不会加。
这不是不信任你。受这道闸保护的是提现地址新增、卡片激活、查看完整卡号 CVV这几件不可逆的事 —— 一旦这道闸能由调用方自己声明通过,它就不是一道闸了,而是一个字段。你那边任何一次 SSRF、任何一个能构造请求体的注入点,都会直接变成「往任意地址提币」。
所以这条流程里你要做的只有两件事:把 URL 交给终端用户,以及拿到票据后重发。
哪些动作要
| 动作 | 端点 | action |
|---|---|---|
| 新增提现地址 | POST /v1/withdraw-addresses | withdraw_address_add |
| 查看完整卡号 / CVV | POST /v1/cards/{id}/secure-session | card_secure |
| 激活实体卡 | POST /v1/cards/{id}/activate | card_activate:<卡 id> |
| 挂失 / 补卡 | POST /v1/cards/{id}/lost | card_lost:<卡 id> |
| 设置卡 PIN | POST /v1/cards/{id}/pin | card_pin:<卡 id> |
| 扫码付款 | POST /v1/qrpay/payments | qrpay_pay:<码值摘要> |
| 兑换成交(该币对配了「需验证」时) | POST /v1/exchange/orders | exchange:<FROM>:<TO> |
| 站内转账(该商户配了「需验证」时) | POST /v1/transfers | transfer:<收款人>:<资产> |
| 理财申购 / 赎回(该产品配了「需验证」时) | POST /v1/earn/subscriptions · POST /v1/earn/redemptions | earn_subscribe:<产品 id> · earn_redeem:<产品 id> |
⚠ 删除提现地址(DELETE /v1/withdraw-addresses/{id})不挂强认证 ——删除是往安全方向走的动作,不该设障碍。同理邮寄地址簿的增删改一律不挂:真实风险是「改默认地址 → 申请实体卡 → 卡寄给攻击者」,而那道闸挂在下单那一步。
三档「什么时候要」
- 恒要 —— 上表前六行。不可逆,或者直接把钱花出去。
- 看配置 —— 兑换 / 转账 / 理财那三行。要不要由我方后台的开关决定,而你读得到:
GET /v1/transfers/config与GET /v1/earn/products/{id}都下发require_step_up。提前读一次、把那一跳画进流程,比等 400 再补要顺。 - 扫码付:每一笔都要。
GET /v1/qrpay/config的require_step_up恒为true,下发它只是为了让你能提前排版。
⚠ 兑换那一行 2026-08-14 之前是「一律拒,且不带
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那一档开。其余任何错误(余额不足、参数不对)照常回放 —— 那种情况下你要做的是改正之后换一把新键。
票据的绑定与有效期
一张票据绑死三样,任何一样对不上都当作没有这张票(于是你会拿到一张新的step_up_required 而不是一个说明原因的错误):
| 绑定 | 少了它会怎样 |
|---|---|
| 会员 | 你能拿 A 完成的票据去动 B 的钱 |
| 动作 | 一张「激活卡片」的票据能用来查看卡密 |
| 已通过标志 | 签发即可用 —— 整条链退化成「你自己声明通过了」 |
外加两条:
- 一次性:验过即删。同一张票据不能用于第二次请求;
- 5 分钟:从签发那一刻算,不是从终端用户打开那一刻算。用户在托管屏上磨蹭超过 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_required,challenge_id 换了一个新的 | 终端用户其实没走完那一页;或票据已过 5 分钟;或 x-step-up 里填的是整条 hosted_url 而不是 challenge_id |
重发拿回 409 idempotency_key_reused | 请求体与第一次不一致(哪怕只差一个空格),或这把键打到了另一个端点 |
| 换了新幂等键之后成功了,但对账多了一笔 | 你换键重发了一次已经完成的操作。这一档必须沿用原键 |
⚠ 还有一条与平时相反的:重发成功之后,那把键不会缓存成功结果。再拿它发同一个请求,拿到的是 409 idempotency_in_progress(而不是回放那次成功),且这个状态会一直持续到 24 小时窗口结束。
所以强认证这条链上,幂等键只用于「第一次 → 带票据重发」这一对。重发拿到明确响应之后就不要再用它了;真的没拿到响应(网络断在半路),去用 GET 类端点回查结果,不要盲目再发一次。