错误码
42 个对外码。认不出的一律按 api_error 处置——
我方不会把内部码泄漏出来,所以你收到一个不在这张表里的码时,那是我方的问题,请连同 request_id 报给我们。
统一信封。按 code 分支,不要按 message 分支 —— message 是给人看的英文说明,
我方保留随时改写它的自由;code 是契约,改它才是破坏性变更。
{
"type": "invalid_request_error",
"code": "insufficient_balance",
"message": "The member's available balance is not enough.",
"request_id": "01J8X4K9…"
}
每个响应都带 X-Request-Id 响应头,错误体里也带一份 request_id。工单第一句话就是它。
type → HTTP
| type | HTTP | 一句话 |
|---|---|---|
invalid_request_error | 400 | 入参不合法,或业务前置条件不满足 |
authentication_error | 401 | 令牌/签名这一层没过 |
permission_error | 403 | 身份是对的,但不许做这件事 |
not_found | 404 | 对象不存在或不属于你 |
idempotency_error | 409 | 同键异体,或首次仍在处理 |
rate_limit_error | 429 | 超配额 |
api_error | 500 | 我方内部错误 |
upstream_error | 502 | 上游不可用 |
upstream_timeout | 504 | 结果不明,可能已执行 |
⚠ 400 里混着两类东西:「你发错了」和「这一单做不成」。二者的重试方式相反,
所以别照着 HTTP 状态码写重试策略 —— 判据只能是 code,见下表的「你该怎么处置」。
三条重试规则,只有这三条
· 同一把幂等键重试:api_error(500) · upstream_error(502) · upstream_timeout(504) ·
idempotency_in_progress(409) · step_up_required(400,完成托管屏之后)。
这些情况下我方可能已经把事情做完了,换新键 == 第二笔真实付款。
· 换一把新键重试:改正入参或补足余额之后。同键重发只会把首次那个失败原样回放
(幂等的语义是同一把键得到同一个结果,不是同一把键最终会成功)。
· 不要重试:request_rejected · insufficient_scope · ip_not_allowed ·
merchant_disabled · product_not_available · 全部 404 · order_not_cancellable ·
state_invalid。重试改变不了判据,只会在你的错误率和我方的日志里各留一堆噪音。
幂等回放的响应带 X-Idempotent-Replay: true,且幂等窗口是 24 小时 ——
超过之后同一把键会被当成一次全新请求受理。
哪些错误带额外字段
| code | 额外字段 |
|---|---|
limit_exceeded | limit_type(single / daily)、limit_scope(merchant / member)。两者都可能缺省 |
step_up_required | challenge_id、hosted_url、expires_at(Unix 秒) |
rate_limited | retry_after(秒),同时有 Retry-After 响应头 |
service_unavailable | retry_after(秒)—— 只在 POST /v1/merchant/deposit-address 上有 |
invalid_fields | fields(逐字段的 key + reason)、reason —— 只在 POST /v1/remit/payees 上有 |
收到不在本表里的 code 怎么办:当成 api_error 处置(同键重试一次,仍失败就报工单
并附 request_id)。我方内部码不直通出网,所以那要么是我方漏了登记、要么是你打到了
别的服务上 —— 两种都该让我们知道。
| 码 | HTTP | 含义 | 你该怎么处置 |
|---|---|---|---|
invalid_credentials | 401 | 换令牌那一步的 x-client-id / x-api-key 不对、Key 已停用、已过期,或者轮换的重叠窗口已经关闭 | 不要循环重试(换令牌本身有 20 次/分钟的闸,撞上去会变成 rate_limited)。「不存在」与「密钥不对」是同一个响应,所以别用它判断 client_id 存不存在。轮换后旧凭据在 prev_valid_until 那一刻硬失效,没有宽限——切换要赶在那之前完成。 |
invalid_token | 401 | 访问令牌缺失、格式不对、已过期(有效期 30 分钟),或这把 Key 已被停用/过期/换了归属 | 拿 POST /v1/connect/token 换一把新的再重发原请求。令牌该被缓存复用 —— 每个请求都换一次的接入形态会自己撞上换令牌的限流。连续几次换完还是 401,去后台看这把 Key 的状态,别在代码里死循环。 |
invalid_signature | 401 | 签名不匹配,或 x-timestamp / x-nonce / x-signature 三个头缺了任意一个 | 逐段核对签名串——五段换行连接,顺序不可换:METHOD(大写)、PATH_WITH_QUERY(含 query)、x-timestamp、x-nonce、hex(sha256(原始 body));HMAC-SHA256 之后 base64。三处最常见的错:① 用了 api_key 而不是 signing_key(验签用的是后者,它可取回);② 把 body 重新序列化了一遍——必须对发出去的原始字节算哈希,重排键序会让签名恒不匹配而报错长得像密钥配错;③ 空 body 的哈希是 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855。重发时必须换一个新 nonce,幂等键不变。 |
signature_required | 401 | 该端点必须签名。POST /v1/deposits 无论 Key 怎么配都强制签名——它是唯一一个凭空产生会员余额的入口 | 按 invalid_signature 那一条把签名补上。⚠ 当前实现里缺签名头走的是 invalid_signature,所以你实际收到这个码的机会很小;两者的处置完全相同,不必分开分支。 |
timestamp_out_of_range | 401 | x-timestamp(Unix 秒)与我方时钟相差超过 ±300 秒 | 给签名机器装 NTP。不要用「重试时沿用上一次的 timestamp」的写法——那会让一次时钟漂移变成永久失败。修好时钟后换新 nonce 重发,幂等键不变。 |
nonce_reused | 401 | 这个 x-nonce 在 5 分钟的重放窗口内已经用过 | 每次发请求都要生成新的 nonce(一个随机 UUID 就够),包括重试。nonce 是防重放的那一半,把它固定下来等于只防篡改不防重放。⚠ 换 nonce 不等于换幂等键——重试时 nonce 必须换、幂等键必须不换。 |
environment_mismatch | 400 | 沙盒凭据打了 live 域,或反过来 | 不要重试。沙盒与 live 是两套凭据、两个主体、两批数据,不存在「配一下就能通」。检查 base URL 与这把 Key 的环境是否成对。⚠ 我方在这里刻意给了明确消息而不是笼统的「凭据无效」,就是因为这个错最常被误判成「密钥抄错了」,然后花半天去查密钥。 |
insufficient_scope | 403 | 这把 API Key 没有该端点要求的 scope | 去商户后台给这把 Key 补 scope。补完立即生效,不用等令牌过期,也不用重新换令牌——scope 每个请求都实时读 Key 行(令牌载荷里那份只作提示)。补不了就说明这条业务线没对你开通,那是 product_not_available 的范畴,找客户经理。 |
ip_not_allowed | 403 | 调用方出口 IP 不在这把 Key 的白名单里 | 不要重试——换台机器重试只会多一条安全事件。去后台把新的出口 IP 加进白名单。live 环境的 Key 强制配白名单,所以「我没配所以应该不限制」在 live 上不成立。这个错最常见的成因是你换了服务器或加了一台。 |
merchant_disabled | 403 | 商户主体不可用。四种内部状态合成一个码(不存在 / 暂停 / 冻结 / 关闭) | 不要重试,联系客户经理。⚠ 一个有用的判别信号:暂停态下只读端点仍然可用,写端点全拒——如果你的现象是「查得到、写不了」,那就是暂停而不是冻结,多半是账务或合规上有一件待办事项。 |
idempotency_key_required | 400 | 写端点没带 x-idempotency-key | 所有写端点(POST / PATCH / DELETE 里动钱或建对象的那些)都必须带。键由你生成并落库,别用请求内容现算——现算的键在你重试时会跟着变,等于没有幂等。GET 不需要也不接受它。 |
idempotency_key_invalid | 400 | 幂等键不是 UUID 形态(8-4-4-4-12 的十六进制) | 用标准 UUID 生成器。别自己拼「订单号 + 时间戳」——它多半不符合这个形状,而且时间戳会让重试拿到一把新键。 |
idempotency_key_reused | 409 | 同一把键此前用过,但这次的请求体或目标端点跟那次不一样 | 换一把新键。请求哈希只算 body、不含任何请求头,所以「同一份 body 打到另一个端点」和「改了一个字段」是同一类错误。这个码通常意味着你的键复用逻辑出了问题——比如按会员而不是按笔生成键。⚠ 它不表示前一笔失败了,前一笔的结果还在,拿原来那把键重发就能取回。 |
idempotency_in_progress | 409 | 用这把键的第一个请求还在处理中 | 等 1~2 秒,用同一把键重试(指数退避,别打紧)。绝不要换新键——换新键就是第二笔真实业务。这个码最常见于你自己的两个进程同时发了同一笔,或首次请求超时后你立刻重发。 |
member_not_found | 404 | 会员不存在、或不属于你、或已被停用——三种情况同一响应 | 不要重试。先确认 x-on-behalf-of 的取值:两种标识都收,一是你自己的 external_member_id,二是我方响应里发出去的 mem_<uuid>——两种之外的(比如邮箱、手机号)一律认不出。会员刚被你 POST /v1/members/{id}/suspend 停用时也是这个码。⚠ 我方刻意不区分这三种,区分开就是一个跨商户会员探测接口。 |
member_context_required | 400 | 这是会员作用域的端点,但没带 x-on-behalf-of | 补上这个头。我方绝不会回落到某个默认会员——那类兜底一旦存在,一次漏传就是把 A 的钱动到了 B 头上。以商户主体执行的端点(如 GET /v1/merchant/balances)反过来不需要它。 |
member_suspended | 400 | 该会员已被停用 | 恢复该会员(POST /v1/members/{id}/suspend 传 suspended: false)后重发,换新键。⚠ 当前实现下你收不到这个码:会员作用域的停用判定发生在 x-on-behalf-of 解析那一步,统一回 member_not_found。别把这个码写进分支逻辑,按 member_not_found 处理即可。 |
kyc_required | 400 | 该会员的 KYC 层级低于这条业务的门槛 | 引导终端用户补齐 KYC,通过后换新键重发。响应体刻意不带层级也不带差多少——门槛是一张表,去 GET /v1/kyc/requirements 取(它按 business 列出 required_level),别按错误响应反推。当前层级读 GET /v1/members/{id} 的 kyc_level。 |
insufficient_balance | 400 | 该会员在该资产上的可用余额不足 | 不要重试。先查 GET /v1/balances 对一遍——注意「可用」不含冻结中、提现中、理财中的部分。补足之后换新键重发(同键会把这个失败原样回放给你)。⚠ 会员余额是你上报出来的:如果你确信他有钱而我方说没有,那是你少上报了一笔 POST /v1/deposits,不是我方算错了。 |
merchant_insufficient_funds | 400 | 你的预付账户可用余额不足 | 去商户后台充值。⚠ 实际线上你更可能看到的是另外两种形态:异步订单线(汇款、发卡)在预付不足时不报错,订单进排队,去 GET /v1/merchant/pending 看队列;即时成交线(扫码付、兑换、理财、提现)当场回 service_unavailable。所以排查资金问题的第一站是 /v1/merchant/pending 和 /v1/merchant/balances,不是错误码。 |
merchant_custody_shortfall | 400 | 确认这笔提现会把你在该资产上的托管声明扣成负数——你想确认一笔自己从未声明过的钱 | 不要重试这一笔,先补上报。 缺的是入金:把没经 POST /v1/deposits 上报的链上到账补齐,再回来确认这笔提现。⚠ 它与 merchant_insufficient_funds 必须分开看——那条的处置是「去充预付」,这条的处置是「你的入金上报少了」。按前者去充值,充多少都不会让这笔通过。 |
product_not_available | 400 | 这个产品没对你授权、已停售,或不满足开通条件 | 不要重试。找客户经理开通,或先用 GET /v1/merchant/lines 看你开了哪几条线。⚠ 这一页上 enabled 与 halted 是两位:后者是低水位自动停售,充值就会自动恢复;前者只能由我方开。把两者合成一位的 SDK 会把一次充值即可解决的暂停当成「这条线被关了」。 |
limit_exceeded | 400 | 撞到了某个额度。看 limit_type(single 单笔 / daily 日累计)与 limit_scope(merchant 你的 / member 会员的) | single → 拆小金额重发(换新键)。daily → 当天不用再试了,次日重来。limit_scope: merchant 的额度在你的商户配置里,找客户经理调;limit_scope: member 是平台准入额度,商户改不了,可用 GET /v1/members/{id}/limits 查该会员当前的占用情况,好向终端用户解释。⚠ 错误体不带阈值,别去猜,那一页才是事实源。 |
request_rejected | 400 | 风控拒绝。这是风控唯一的对外码——不带原因、不带规则名、不带阈值、不带评分 | 绝不重试。 判据不会因为再发一次而改变,重发只会再留一条记录,而记录本身会让这个会员看起来更可疑。也不要试图从响应里反推命中了什么,那些信息一律不出境。要申诉走商户后台工单,附 request_id。⚠ 会员被封禁 / 拉黑 / 账户冻结也收敛在这个码里,所以「这个人以前一直好好的」不构成矛盾。 |
step_up_required | 400 | 这个动作需要终端用户完成强认证。响应带 challenge_id、hosted_url、expires_at | 把 hosted_url 打开给终端用户(我方托管屏,因子校验全在我方域内),他完成之后用同一把幂等键、同一份请求体重发,并带上 x-step-up: <challenge_id>。这是唯一一个「同键重发会真的重新执行」的错误——我方为它在幂等层上开了专门的口子。票据 5 分钟过期、一次性、绑死会员与动作,用完即失效;过期就重新发起拿一张新的。⚠ 请求体里的 step_up_passed: true 这类自证我方一律不接受,别去尝试。 |
order_not_cancellable | 400 | 订单已经越过了可撤销的那个点 | 不要重试。回查订单当前状态再决定下一步。⚠ 提现的两段式确认里,并发把同一笔推进到了另一个终态时也会给这个码——先 GET 一次,如果它已经是你想要的那个状态,那就是成功,不必再动。 |
asset_not_allowed | 400 | 该资产不在你的白名单里,或平台侧未启用,或这条「币 × 网络」没有可用渠道 | 不要重试。用 GET /v1/assets 取你当前可用的资产清单(没配过白名单的商户看到的是全部已启用资产——白名单是收紧手段,不是开通手段)。要加资产找客户经理,我方加一行即可,不需要你发版。 |
invalid_request | 400 | 请求本身不合法:body 不是合法 JSON、必填字段缺失或为空、枚举值认不出、金额格式或小数位数不对 | 改正后换新键重发。这是本表里出现频率最高的码,而它不告诉你是哪个字段——所以接入期请逐个端点对照参数表,不要靠试。⚠ 金额相关的错占了很大一部分:金额一律字符串定点,小数位数 = 该资产的 ledger_scale(见 GET /v1/assets);"12.5" 在 6 位资产上是不合法的,要写 "12.500000";用 JSON number 传金额同样会落到这里。 |
invalid_fields | 400 | 逐字段的校验失败,响应带 fields 数组(每项含 key 与 reason) | 按 fields 逐条回显给终端用户,改完换新键重发。⚠ 目前只有 POST /v1/remit/payees 会带 fields——其余端点的字段级错误统一落在 invalid_request 上,那里没有字段清单可读。所以别写「所有 400 都去读 fields」的通用逻辑,读不到时要能兜住。 |
corridor_not_supported | 400 | 这条汇款走廊我方发不出去(未开通、辖区受限,或这个币种/国家组合没有可用的路由码类型) | 换一条走廊或换一种支付方式,不要重试原参数。⚠ 与 product_not_available 分开的理由就在处置上:那条是「汇款这条线没开给你」(找客户经理),这条是「汇款开着,但这个目的地不行」(换目的地或改用 SWIFT)。 |
resource_not_found | 404 | 引用的对象不在这个会员名下,或根本不存在——两种同一响应 | 不要重试。检查 id 前缀是否用对了(pye_ 收款人、rmt_ 汇款单、crd_ 卡……),以及 x-on-behalf-of 指的是不是持有该对象的那个会员。⚠ 我方刻意不区分「不存在」与「不是你的」,区分开就是一个 id 探测接口。 |
state_invalid | 400 | 这个动作在对象当前状态下不成立(卡已注销、订单已终态、定期理财不可提前赎回、Webhook 投递记录不可重投……) | 先 GET 一次那个对象,按它现在的状态决定下一步;不要盲目重试。⚠ 响应不下发内部状态名——内部状态机不是公开契约,你能依赖的是各端点文档里列出的那几个对外状态值。 |
duplicate_resource | 409 | 已经存在一条一模一样的记录(例如同一个会员重复添加同一个提现地址) | 不要重试,也不要换新键——换了还是撞同一条。列一遍已有记录,直接用那条。⚠ 这与 idempotency_key_reused 不同:这条是业务上重复,与你用了哪把幂等键无关。 |
address_not_allowed | 400 | 这个地址不能用于提现。三种情况同一响应:它是我方自己的充值地址、在黑名单上、或格式/链不合法 | 换一个地址。不要试图从响应里判断是哪一种——我方合并它们正是为了不给出一份我方地址的探测器。如果终端用户坚持这个地址没问题,走工单,附 request_id。⚠ 提现地址簿是主线里唯一的地址来源,新增地址要过强认证并有冷静期,所以这个错不能靠「换个入口绕过去」。 |
qr_code_invalid | 400 | 这个收款码解不出来(格式认不出、不是我方支持的码制,或上游拒绝了它) | 让终端用户重新扫一次或换一个码。不要重试同一份码值。⚠ 同一个码在不同地区/不同码制下的支持范围不同,「上次能付」不保证这次能付。 |
not_found | 404 | 路径不存在,或该对象不存在/不属于你。沙盒专用端点在 live 上也一律回这个码 | 不要重试。先核对路径拼写与 /v1 前缀。⚠ 沙盒端点在 live 上是 404 而不是 403——那不是权限问题,是它在生产上根本不该存在。 |
rate_limited | 429 | 超过配额。带 retry_after(秒)与 Retry-After 响应头 | 按 Retry-After 退避并加随机抖动(一批客户端同时醒来会把一次突发变成持续冲击)。配额按商户计而不是按 Key 计——多建几把 Key 不会让额度变多。当前的分档:POST /v1/deposits 60 次/分钟、会员写 300 次/分钟、下单类 120 次/分钟、只读 1200 次/分钟、换令牌 20 次/分钟。⚠ 撞到只读那一档基本意味着你在把我方当自己的数据库轮询——改成消费 Webhook 事件,别加线程。 |
api_error | 500 | 我方内部错误 | 用同一把幂等键重试(我方可能已经完成了部分工作,换新键有做第二遍的风险)。退避重试两三次仍失败就报工单,必须附 request_id——那是我方定位这一次调用的唯一线索。⚠ 持续在同一个端点、同一类参数上拿到 500,多半不是抖动而是一个真实缺陷,早报早修,别自己扛着重试。 |
internal | 500 | 未捕获的内部异常。它的信封与本表其余各条不同——形如 {"error":{"code":"internal","message":"…"}},没有 type,也没有 request_id | 与 api_error 相同:同键重试,仍失败就报工单。⚠ 请求 id 要从 X-Request-Id 响应头取,别从 body 里读——这正是「响应头永远读,body 只在有把握时读」这条接入建议存在的理由。你的错误解析代码遇到读不到 code 的 5xx 时必须能兜住,而不是自己抛异常。 |
upstream_error | 502 | 上游服务商不可用 | 用同一把幂等键重试,指数退避。持续几分钟不恢复就报工单——我方能看到是哪一家、你看不到(上游名称一律不出网,避免把我方的供应商结构变成公开信息)。 |
upstream_timeout | 504 | 上游超时。结果不明——我方可能已经处理完了 | 必须用同一把幂等键重试,或者先拿订单查询接口查证。换新键 == 第二笔真实付款,这是整份文档里代价最高的一个错误。⚠ 幂等窗口是 24 小时:超过之后同键重投 == 换新键,那时候就只能靠人工对账了,别拖。 |
service_unavailable | 400 | 这条线此刻暂时不可用。可能是资金侧、配置侧或上游侧的原因——刻意不区分 | 注意它是 400 不是 503,按「可重试」处置但要退避,别按 4xx 一律不重试的通用规则把它扔掉。排查顺序:先 GET /v1/merchant/balances 看你的预付够不够,再 GET /v1/merchant/lines 看这条线是 enabled=false(找客户经理)还是 halted=true(充值即自动恢复)。⚠ 会员侧看到的失败文案不会暴露原因——「他自己余额充足却交易失败」这件事,整条链上只有你这一端解释得清。带 retry_after 时(新建入金地址那一处)它的含义是「还没好」而不是「失败了」,等一下再问同一条,别换条链重来。 |