错误
每个 4xx/5xx 响应都携带相同的 JSON 信封:
{
"code": 400,
"message": "invalid_input",
"details": [
{ "field": "items[0].unit_price", "message": "must be a positive decimal string" }
]
}code— 数字形式的 HTTP 状态码(400、401、404、……)。适合做通用的 HTTP 层处理,但分支逻辑请基于message—code无法区分例如invalid_input与payment_method_not_supported(两者都是 400)。message— lower-snake-case 的 sentinel 名,源自内部errors.go常量(例如INVALID_INPUT → "invalid_input")。跨版本稳定 — 请基于此分支。details— 仅在校验错误时出现。{ field, message }对象数组,便于前端把错误对应到输入框。其他情况省略。
我们目前不在 body 中返回 trace_id 或 timestamp。本页早期草稿曾承诺过两者 — 那是设想,并非实情。如果需要把服务器日志与一次请求对应起来,请抓取响应的 Date 标头以及 gateway 侧的限流标头(X-RateLimit-*),在工单中引用。
gateway 边缘的拒绝使用不同的形态。 上面的信封是后端服务发出的。在到达服务 之前 就被 gateway 拒绝的请求 —— /b2b/v1/* 调用上缺失 / 非法的 X-Signature、未知的 X-Client-ID,或过期的 X-Timestamp —— 会以 { "error": "...", "message": "..." } 的形态返回,其中 error 是一个粗粒度 slug(unauthorized / bad_request / service_unavailable),message 携带具体信息。没有数值 code,也没有 details。因此校验方应先按 HTTP 状态码分支,再读 message,只有当请求越过 gateway 之后才把 code / details 当作存在。gateway body 示例(401):
{ "error": "unauthorized", "message": "invalid signature" }HTTP 状态码 → 典型 code
| HTTP | 典型 code 值 | 含义 |
|---|---|---|
| 400 | INVALID_INPUT、MISSING_REQUIRED、INVALID_FORMAT、INVALID_LENGTH、INVALID_VALUE、INVALID_USER_STATUS、INVALID_USER_ROLE、PAYMENT_METHOD_NOT_SUPPORTED、AMOUNT_BELOW_MINIMUM | 请求错误 — 看 details |
| 401 | INVALID_CREDENTIALS、INVALID_TOKEN、TOKEN_EXPIRED、INVALID_SIGNATURE(B2B);INVALID_OTP、OTP_EXPIRED、SESSION_EXPIRED(仅限仪表板 / JWT) | 鉴权失败 — 密钥错误、时间戳过期、签名错误。OTP/SESSION_EXPIRED 这些 code 仅在仪表板 JWT 路由(/payment/v1/*)上出现;纯 B2B 集成不会看到。 |
| 402 | INSUFFICIENT_CREDIT | 商户预付余额已用尽 — 充值后重试 |
| 403 | FORBIDDEN、IP_BLOCKED | 密钥有效但缺少此调用所需的 scope 或 IP 许可 |
| 404 | NOT_FOUND、RECORD_NOT_FOUND、USER_NOT_FOUND、SESSION_NOT_FOUND | 资源不存在(或对本商户不存在) |
| 409 | ALREADY_EXISTS、USER_ALREADY_EXISTS、SESSION_ALREADY_EXISTS | 用不同 body 重放幂等键,或状态机拒绝转换 |
| 429 | TOO_MANY_REQUESTS、TOO_MANY_ATTEMPTS | 触发限流;退避后重试 |
| 500 | INTERNAL_SERVER_ERROR、DATABASE_CONNECTION_ERROR、DATABASE_QUERY_ERROR、DATABASE_TRANSACTION_ERROR、REDIS_CONNECTION_ERROR、REDIS_OPERATION_ERROR、EXTERNAL_SERVICE_ERROR | 我们的问题;可安全退避重试。(TWILIO_SERVICE_ERROR / SENDGRID_SERVICE_ERROR 这类 provider 专属 code 在内部存在,但仅在仪表板侧的通知流程中出现,不会在 B2B 端点上暴露。) |
| 503 | SERVICE_UNAVAILABLE | 下游依赖宕机。退避后重试 |
完整 code 参考
你可能看到的 code 完整集合(与 payment-service/pkg/errors/errors.go 一致):
鉴权与会话 (401)
INVALID_CREDENTIALS— 用户名/密码或 API key 组合被拒INVALID_TOKEN— JWT/会话 token 无法解析或被篡改TOKEN_EXPIRED— JWT 过期(超过exp)INVALID_OTP— OTP 不匹配OTP_EXPIRED— OTP 签发时间超出容差窗口SESSION_EXPIRED— 仪表板会话过期INVALID_SIGNATURE— B2B / Webhook 调用上的 HMAC 签名不一致
授权 (403)
FORBIDDEN— 已认证,但角色/scope/商户边界阻止该操作IP_BLOCKED— IP 在滥用名单内
未找到 (404)
NOT_FOUND— 通用RECORD_NOT_FOUND— 给定 ID 的行缺失USER_NOT_FOUND— 用户查找失败SESSION_NOT_FOUND— 仪表板 session id 无法识别
冲突 (409)
ALREADY_EXISTS— 通用USER_ALREADY_EXISTS— 注册触发唯一约束SESSION_ALREADY_EXISTS— 重复 session 插入
校验 (400)
INVALID_INPUT— 通用;请查看detailsMISSING_REQUIRED— 缺失必填字段INVALID_FORMAT— 值不匹配预期格式(如 UUID、URL、email)INVALID_LENGTH— 值过短或过长INVALID_VALUE— 值超出允许的枚举/范围INVALID_USER_STATUS— 用户处于不允许该操作的状态INVALID_USER_ROLE— 角色无该操作权限PAYMENT_METHOD_NOT_SUPPORTED— 该商户未启用此 provider/asset 组合AMOUNT_BELOW_MINIMUM— 订单金额低于每网络或环境级别的下限。响应中的details.floor_usd携带配置的下限(美元),可直接展示;message文本中也会说明。INSUFFICIENT_BALANCE— 买家钱包中该支付资产的余额不足以完成转账。INSUFFICIENT_GAS— 买家钱包缺少用于广播转账的原生 gas。
支付 / 计费 (402)
INSUFFICIENT_CREDIT— 商户预付余额不足以覆盖该操作的 gas / 平台费。从仪表板充值后重试
限流 (429)
TOO_MANY_REQUESTS— gateway IP 级限流TOO_MANY_ATTEMPTS— 同一资源(如 OTP)上重复失败触发节流
存储 / 基础设施 (500)
DATABASE_CONNECTION_ERROR— 无法连接数据库DATABASE_QUERY_ERROR— 查询在运行时失败DATABASE_TRANSACTION_ERROR— commit/rollback 失败REDIS_CONNECTION_ERROR— 无法连接 RedisREDIS_OPERATION_ERROR— Redis 命令失败EXTERNAL_SERVICE_ERROR— 通用第三方失败(未在下方分桶的 provider)TWILIO_SERVICE_ERROR— Twilio SMS / Verify 调用失败SENDGRID_SERVICE_ERROR— SendGrid 邮件发送失败INTERNAL_SERVER_ERROR— 意外的未捕获错误;请抓取响应的Date标头 +X-RateLimit-*并联系我们
可用性 (503)
SERVICE_UNAVAILABLE— 关键下游报告不健康;退避后重试
校验错误 (400)
当问题是请求 body 格式错误时,details 是数组,这样你就能把错误映射回字段:
{
"code": 400,
"message": "invalid_input",
"details": [
{ "field": "items[0].unit_price", "message": "must be a positive decimal string" },
{ "field": "success_url", "message": "must be a valid https URL" }
]
}鉴权错误 (401)
INVALID_SIGNATURE 覆盖三种不同的失败模式 — 唯一区分方法是二分排查:
- secret key 错(再检查环境变量)
- 时间戳偏移 > 5 分钟(同步 NTP)
- 规范化字符串错(最常见:忘了
\n分隔符,或对解析后又重新序列化的 body 签名 — 与你实际发送的不一致)
精确签名算法见 身份验证。
限流 (429)
| 表面 | 限制 |
|---|---|
每条 gateway 路由(含 /b2b/v1/*) | 在 gateway 按 IP 限流 — 500 req/min,共享桶。非按商户的配额。 |
公共结账 (/checkout/*) | 更严格的子桶 — 每 IP 20 req/min |
X-RateLimit-Limit 与 X-RateLimit-Remaining 每次被 gateway 限流的响应都会回显(不仅是成功响应)。请将其视为你 IP 的实时预算。
被限流的响应会包含 Retry-After 标头(距离限流窗口重置的秒数)——请遵循它。作为兜底,请带抖动退避 — 1s 基数 + 指数到 30s。
限流可能调整。如果你合法地撞上了限流(例如对账大段历史数据),请 联系我们 — 批量端点在路线图上。
余额不足 (402)
INSUFFICIENT_CREDIT(HTTP 402 Payment Required)表示商户预付余额
无法覆盖你试图执行的操作的下一笔 gas 和平台费 — 典型场景是结算加密
支付,或执行 gas 由平台代付的链上动作。请从商户仪表板充值
(Billing → Add credit),然后重试该操作;在途任务会等待,余额到位
后自动继续。
服务端错误 (5xx)
500 表示我们无法处理该请求。带退避重试 — 你的 idempotency_key
确保即使原请求已部分成功,也不会重复扣款。
如果一分钟内重试无法恢复,请向买家展示通用的”支付暂时不可用”,
并联系支持团队,附上失败的端点、你的商户 ID、响应的 Date 标头与
X-RateLimit-* 的值,以及请求的大致时间 — 这些足以让我们在日志里
定位到相关请求。
Webhook 投递错误
Webhook 投递是一条独立的失败通道 — 它们不以 API 错误形式出现,因为 不是你的服务器在调用。当一次投递返回非 2xx(或超时),它会按指数 退避在 0s、1min、5min、15min、1h、6h 排队重试(总共六次 — 与 Webhooks → 概览 的时间表一致)。完整 的每次投递历史显示在仪表板的 Developers → Webhooks → [endpoint] → Delivery log。第六次尝试失败后,投递进入死信 — 仪表板会显示一个 “Failed” 标签,等你服务器恢复后可以手动重放。