商户接口文档
本文面向第一次对接的商户程序员。文中的 https://api.example.com 是示例地址,所有接口路径都以 /v1 开头。实际的接口网关、商户后台地址、AppID 和平台的通知来源 IP,请登录商户后台在「对接资料」页面查看,或者看上级发给你的对接信息。
1. 对接步骤
- 拿到 AppID 和密钥。由平台或你的上级在商户后台为你生成一组 API 凭证:
app_id(形如app_xxxxxxxxxxxxxxxx)和密钥(64 位十六进制字符串)。密钥明文只在生成时显示一次,之后只能看到指纹,请立即保存到服务端的配置或密钥管理系统中,不要写进前端代码、App 安装包或代码仓库。 - 在商户后台设置 IP 白名单。登录商户后台,把你服务器调用接口时使用的出口公网 IP 填进这把密钥的白名单。设置白名单后,其他 IP 即使拿到密钥也无法调用(返回 403
IP_NOT_ALLOWED)。代付接口强制要求设置白名单,没设置时返回 403IP_WHITELIST_REQUIRED。 - 下单。按 签名 规则给请求签名,调用 下单接口。把返回的
cashier_url(收银页链接)或pay_payload交给用户去支付。用 在线签名调试器 或 SDK 可以快速验证签名是否正确。 - 处理通知。订单状态变化后,平台会向你下单时填的
notify_url发送签名的 HTTP POST 通知。你的服务要验签、按event_id去重、更新自己的订单,然后返回 HTTP 2xx。详见 事件通知。如果平台出口 IP 需要加到你的防火墙白名单,见 出口 IP。 - 查询兜底。通知可能延迟或丢失(你的服务宕机、网络不通)。对长时间没有收到通知的订单,用 查询接口 主动查询,以查询结果为准。调用接口超时时,用同一个商户单号查询或重试,不要换单号重新下单。
10000。2. 通用约定
| 项目 | 说明 |
|---|---|
| 接口地址 | https://api.example.com/v1/...,使用 HTTPS。 |
| 编码 | 请求和响应都是 JSON,UTF-8 编码。有请求体时请求头带 Content-Type: application/json。 |
| 请求头 | X-App-Id:你的 app_id。X-Timestamp:秒级 Unix 时间戳,和平台时间相差不能超过 300 秒。X-Nonce:每次请求不同,16 到 64 位,只能包含 0-9 A-Z a-z _ -,10 分钟内不能重复。X-Signature:签名,小写十六进制。 |
| 请求体大小 | 最大 65536 字节(64KB),超过返回 413 BODY_TOO_LARGE。 |
| 未知字段 | 请求体有接口没定义的字段时返回 400 INVALID_PARAM,不会被忽略。字段名写错能马上发现。 |
| 金额 | 单位是分,整数。 |
| 时间格式 | 响应里的时间是 RFC3339 格式,例如 2026-01-01T08:30:00+08:00。 |
| 响应格式 | {"code":"OK","message":"","request_id":"...","data":{...}}。成功时 HTTP 200,code 为 OK;失败时 HTTP 状态码为 4xx/5xx,code 是稳定的英文错误码,message 是中文说明。request_id 请在联系平台排查问题时提供。 |
| 响应签名 | 验签通过的请求,响应头带 X-Signature、X-Timestamp、X-Request-Id,可以验证响应确实来自平台,见 响应验签。验签失败(401 等)的响应没有签名。 |
| 幂等 | 商户单号(out_trade_no、out_refund_no)在你名下唯一。用相同的单号和相同的参数重复提交,返回原订单(HTTP 200),不会重复下单;单号相同但参数不同,返回 409 DUPLICATE_OUT_TRADE_NO 或 DUPLICATE_OUT_REFUND_NO。 |
| 限流 | 同一来源 IP 每秒最多 200 次(验签前检查);同一个 app_id 默认每秒最多 50 次,超过返回 429 RATE_LIMITED,降低频率后重试。 |
| 查询范围 | 只能查询自己名下的订单。别人的订单和不存在的订单返回同样的 404,不会泄露是否存在。 |
3. 签名
3.1 请求签名
签名覆盖整个请求体,不需要对 JSON 字段排序或拼接。先拼出待签名串,再用密钥做 HMAC-SHA256:
待签名串 = 大写METHOD + "\n" + PATH + "\n" + 排序后的QUERY + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + hex(sha256(BODY))
签名 = hex(HMAC-SHA256(密钥, 待签名串)) // 小写十六进制
METHOD:GET或POST,大写。PATH:实际请求 URL 的路径部分,不含域名和查询串,例如/v1/payin/orders。如果你的网关地址带路径前缀,PATH 要包含这个前缀。排序后的QUERY:先按参数名排序,同名参数再按值排序(按 UTF-8 字节序)。每项写成编码(名)=编码(值),用&连接。没有查询参数时是空串(待签名串里该行为空行)。TIMESTAMP、NONCE:和请求头X-Timestamp、X-Nonce的值完全相同。BODY:请求体的原始字节。没有请求体(GET 请求,或没有请求体的 POST)时是空串,空串的 sha256 照常参与,值为e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855。- 密钥按 UTF-8 字符串使用,也就是直接把 64 位十六进制字符串当作密钥文本,不要先把它解码成二进制。
查询串的编码规则
和 Go 语言的 url.QueryEscape 完全一致:
A-Z a-z 0-9 - _ . ~不编码。- 空格编码为
+。 - 其他字节(中文按 UTF-8 字节)编码为
%XX,十六进制用大写。例如*编码为%2A,中编码为%E4%B8%AD。
urlencode 会把 ~ 编成 %7E;Python 的 quote_plus 默认保留 /(要传 safe='');Java 的 URLEncoder 不编码 * 并把 ~ 编成 %7E。建议实际发出的 URL 查询串就用排序编码后的这个串,保证签名和请求一致。请求体签名用的字节必须和实际发出的字节完全一致:先把请求对象序列化成 JSON 字符串,再用这个字符串计算签名并原样发送。JSON 序列化时不必转义中文和 /(转义了也能验签,只是日志不易读)。
3.2 完整示例
下面是一个可以直接对照的示例,调试器里点“载入示例”可以得到相同的结果。
密钥(示例用,不是真实密钥):
0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
请求:POST /v1/payin/orders,X-Timestamp: 1767225600,X-Nonce: n0nce-0000000001,没有查询参数。请求体(一行,无多余空白):
{"out_trade_no":"M202601010001","product_code":"ALIPAY_H5","amount":10000,"subject":"测试商品","notify_url":"https://m.example.com/notify"}
第 1 步,计算请求体的 sha256(十六进制小写):
8ea26eef89ccd7f6c7624fe428283f32d9af2db43327e505f3f124ccba52427c
第 2 步,按顺序用换行符 \n 连接 6 项(第 3 行是空的,因为没有查询参数),得到待签名串,共 6 行:
POST
/v1/payin/orders
1767225600
n0nce-0000000001
8ea26eef89ccd7f6c7624fe428283f32d9af2db43327e505f3f124ccba52427c
注意第 3 行是空行(排序后的 QUERY 为空),不含任何字符;最后一行结尾没有换行符。
第 3 步,用密钥对待签名串做 HMAC-SHA256,得到签名:
d596c056bc211383eb3c208fb707a234931c7c7660f919a7af9dbf5d195cd547
第 4 步,发送请求:
POST https://api.example.com/v1/payin/orders
Content-Type: application/json
X-App-Id: app_xxxxxxxxxxxxxxxx
X-Timestamp: 1767225600
X-Nonce: n0nce-0000000001
X-Signature: d596c056bc211383eb3c208fb707a234931c7c7660f919a7af9dbf5d195cd547
{"out_trade_no":"M202601010001","product_code":"ALIPAY_H5","amount":10000,"subject":"测试商品","notify_url":"https://m.example.com/notify"}
带查询参数的例子:GET /v1/payouts?z=2&out_trade_no=A+B~C%2A%E4%B8%AD&a=b&a=a&empty=,“排序后的 QUERY”是 a=a&a=b&empty=&out_trade_no=A+B~C%2A%E4%B8%AD&z=2(时间戳 1767225600,Nonce n0nce_0000000003,签名 7228739edeaf0c6dff64ee73d745163a60d54faf5c4db6cc3c1ae8e98410b0a2)。其中参数值 A B~C*中 的空格、*、中文都按上面的规则编码。这个例子也在页面加载时用来自检调试器,见下一节。
没有请求体的 POST(关闭订单):POST /v1/payin/orders/P2026010100000001/close,不要发送请求体,待签名串里 BODY 部分用空串的 sha256。
3.3 响应验签
验签通过的请求,平台的响应带 3 个头:X-Signature、X-Timestamp、X-Request-Id。响应的待签名串是 4 行:
响应待签名串 = HTTP状态码 + "\n" + X-Request-Id + "\n" + X-Timestamp + "\n" + hex(sha256(响应体原始字节))
签名 = hex(HMAC-SHA256(密钥, 响应待签名串))
示例(HTTP 200,X-Request-Id: 01JGZ8Q4R5S6T7V8W9X0Y1Z2A3,X-Timestamp: 1767225600,响应体 {"code":"OK","message":"","request_id":"01JGZ8Q4R5S6T7V8W9X0Y1Z2A3","data":{"order_no":"P1"}}):
200
01JGZ8Q4R5S6T7V8W9X0Y1Z2A3
1767225600
bd1480965b3c4bdcaed90c0f5fe83abae05e010d2cf2740dfcf6866f91b4370c
对应签名 0cee6bfb3f7566624bc5e28aecee85e5789038652546252046fd8483c04a0de9。响应带 X-Signature 时建议验签,不一致就丢弃这次响应,不要使用里面的数据。响应体要用收到的原始字节计算,不要先解析再重新序列化。
4. 在线签名调试器
填入和你的请求相同的内容,就能看到排序后的查询串、待签名串和签名,用来和你程序算出的结果对比。密钥只在你的浏览器里计算,不会发送出去:本页的调试器不发起任何网络请求,计算使用浏览器自带的 WebCrypto(HMAC-SHA256、SHA-256)。即便如此,仍建议只在这里使用测试密钥。
排序并编码后的查询串
请求体 sha256
待签名串(逐行,行号后的竖线只是分隔,不属于内容)
签名(X-Signature)
5. 接口
下面每个接口的请求都要按 签名 规则带 4 个请求头。表格里“必填”列为“是”的字段不能缺少。
| 接口 | 说明 |
|---|---|
POST /v1/payin/orders | 下单 |
GET /v1/payin/orders/{order_no}GET /v1/payin/orders?out_trade_no= | 查询订单 |
POST /v1/payin/orders/{order_no}/close | 关闭订单 |
POST /v1/refunds | 申请退款 |
GET /v1/refunds/{refund_no}GET /v1/refunds?out_refund_no= | 查询退款 |
POST /v1/payouts/fee-quote | 代付手续费试算 |
POST /v1/payouts | 发起代付 |
GET /v1/payouts/{order_no}GET /v1/payouts?out_trade_no= | 查询代付 |
POST5.1 下单
POST https://api.example.com/v1/payin/orders
请求字段
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
out_trade_no | string | 是 | 你的订单号,最长 64 个字符,在你名下唯一。重复提交且参数相同时返回原订单。 |
product_code | string | 是 | 支付产品编码,例如 ALIPAY_H5。可用的产品编码向平台或上级确认,未开通的产品返回 PRODUCT_NOT_ENABLED。 |
amount | int | 是 | 订单金额,单位分,必须大于 0。超出产品或通道允许的金额范围时没有通道可用,返回 CHANNEL_UNAVAILABLE,message 里写明原因。 |
subject | string | 是 | 商品名称或订单标题,最长 128 个字。 |
notify_url | string | 否 | 支付结果通知地址,最长 512 个字符。只允许 http/https,端口只能是 80、443、8080、8443,不能是内网地址。不填则平台不会发通知,你只能靠查询接口得知结果。 |
return_url | string | 否 | 用户支付完成后跳转回你网站的地址,只能是 http 或 https 地址,最长 512 个字符。 |
client_ip | string | 否 | 用户的 IP 地址,最长 45 个字符。填写后可以让风控更准确,部分通道要求提供。 |
attach | string | 否 | 附加数据,最长 512 个字符,原样放在通知的 data.attach 里(下单和查询的响应里没有这个字段)。 |
expire_seconds | int | 否 | 订单有效期,单位秒,不填或填 0 使用通道和产品的默认有效期;填写时不能小于 60,且只能比默认值短,不会延长。 |
请求示例
{
"out_trade_no": "M202601010001",
"product_code": "ALIPAY_H5",
"amount": 10000,
"subject": "测试商品",
"notify_url": "https://m.example.com/notify",
"return_url": "https://m.example.com/return",
"client_ip": "203.0.113.8",
"attach": "uid=1001",
"expire_seconds": 900
}
响应字段(data)
下单、查询订单、关闭订单返回相同结构:
| 字段 | 类型 | 说明 |
|---|---|---|
order_no | string | 平台订单号。 |
out_trade_no | string | 你的订单号。 |
status | string | 订单状态,见 状态说明。 |
order_amount | int | 订单金额,单位分,等于你下单时的 amount。 |
pay_amount | int | 用户实际应付金额,单位分。有立减活动时小于 order_amount,否则相等。 |
pay_payload | object | 通道返回的支付参数,内容随通道不同(常见字段如 pay_url)。没有时不出现。 |
cashier_url | string | 平台收银页链接,把用户引导到这个地址即可支付。只有待支付的订单才有;产品配置为直接返回上游链接时没有,这时使用 pay_payload。 |
expire_at | string | 订单过期时间。过期后收银页停止受理。 |
final_close_at | string | 订单最终关闭时间,等于过期时间加上通道的迟到回调宽限期。宽限期内如果收到上游的支付成功,订单仍会变成已支付。 |
paid_at | string | 支付成功时间,未支付时不出现。 |
error_code | string | 下单失败时的错误码,没有失败时不出现。 |
error_msg | string | 下单失败时的原因,没有失败时不出现。 |
status 为 failed,原因在 error_code、error_msg。请判断 status,不要只看 HTTP 状态码。平台不会自动换通道重试;需要重试请换一个新的商户单号下单。响应示例
{
"code": "OK",
"message": "",
"request_id": "01JGZ8Q4R5S6T7V8W9X0Y1Z2A3",
"data": {
"order_no": "P2026010100000001",
"out_trade_no": "M202601010001",
"status": "pending",
"order_amount": 10000,
"pay_amount": 10000,
"pay_payload": {"pay_url": "https://pay.example.com/xxxx"},
"cashier_url": "https://cashier.example.com/c/xxxx",
"expire_at": "2026-01-01T08:15:00+08:00",
"final_close_at": "2026-01-01T08:20:00+08:00"
}
}
可能的错误码:INVALID_PARAM、MERCHANT_FROZEN、PAYIN_DISABLED、PRODUCT_NOT_ENABLED、RISK_REJECTED、CHANNEL_UNAVAILABLE、FEE_NOT_CONFIGURED、DUPLICATE_OUT_TRADE_NO、SERVICE_UNAVAILABLE,见 错误码。
GET5.2 查询订单
GET https://api.example.com/v1/payin/orders/{order_no}
GET https://api.example.com/v1/payin/orders?out_trade_no={out_trade_no}
按平台订单号或你的商户单号查询。没有请求体。用商户单号查询时,参数名是 out_trade_no,缺少时返回 400 INVALID_PARAM。响应字段和 下单 相同;订单待支付时响应带 cashier_url。订单不存在或不属于你返回 404 ORDER_NOT_FOUND。
{
"code": "OK",
"message": "",
"request_id": "01JGZ8Q4R5S6T7V8W9X0Y1Z2A5",
"data": {
"order_no": "P2026010100000001",
"out_trade_no": "M202601010001",
"status": "paid",
"order_amount": 10000,
"pay_amount": 10000,
"expire_at": "2026-01-01T08:15:00+08:00",
"final_close_at": "2026-01-01T08:20:00+08:00",
"paid_at": "2026-01-01T08:03:21+08:00"
}
}
POST5.3 关闭订单
POST https://api.example.com/v1/payin/orders/{order_no}/close
商户主动关闭待支付订单。没有请求体,签名时 BODY 用空串。返回关单后的订单(结构同下单响应),状态有几种可能:
closed:已关闭。expired:部分通道不支持立即撤销,订单先进入已过期,等到final_close_at(宽限期结束)后变成closed。paid:关闭前发现用户其实已经付款,订单状态为已支付,请按已支付处理。
订单已经是 closed、expired、failed 时重复关闭,直接返回当前状态。订单已支付(含部分退款、全额退款)时返回 409 ORDER_NOT_CLOSABLE,需要退款请用 退款接口;订单正在提交上游(created)时也返回 ORDER_NOT_CLOSABLE,稍后重试。订单最终关闭时会收到 payin.closed 通知。
POST5.4 申请退款
POST https://api.example.com/v1/refunds
支持部分退款,用 out_refund_no 保证幂等。同一张订单同一时间只能有一笔退款在处理中。
请求字段
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
order_no | string | 二选一 | 平台订单号。和 out_trade_no 必须且只能填一个。 |
out_trade_no | string | 二选一 | 你的订单号。 |
out_refund_no | string | 是 | 你的退款单号,最长 64 个字符,在你名下唯一。 |
amount | int | 是 | 退款金额,单位分,必须大于 0,不能超过订单剩余可退金额。 |
reason | string | 否 | 退款原因,最长 255 个字。 |
{
"order_no": "P2026010100000001",
"out_refund_no": "RF202601010001",
"amount": 3000,
"reason": "用户申请退款"
}
响应字段(data)
| 字段 | 类型 | 说明 |
|---|---|---|
refund_no | string | 平台退款单号。 |
out_refund_no | string | 你的退款单号。 |
order_no | string | 平台订单号。 |
out_trade_no | string | 你的订单号。 |
amount | int | 退款金额,单位分。 |
status | string | 退款状态:created、processing、success、failed、unknown,见 状态说明。 |
error_code | string | 退款失败的错误码,没有失败时不出现。 |
error_msg | string | 退款失败的原因,没有失败时不出现。 |
created_at | string | 退款单创建时间。 |
finished_at | string | 退款完成时间,未完成时不出现。 |
{
"code": "OK",
"message": "",
"request_id": "01JGZ8Q4R5S6T7V8W9X0Y1Z2A6",
"data": {
"refund_no": "R2026010100000001",
"out_refund_no": "RF202601010001",
"order_no": "P2026010100000001",
"out_trade_no": "M202601010001",
"amount": 3000,
"status": "processing",
"created_at": "2026-01-01T09:00:00+08:00"
}
}
status 为 processing 或 unknown 时,以之后的 refund.succeeded / refund.failed 通知或查询结果为准。可能的错误码:INVALID_PARAM、ORDER_NOT_FOUND、ORDER_NOT_REFUNDABLE、REFUND_NOT_SUPPORTED、REFUND_IN_PROGRESS、REFUND_AMOUNT_EXCEEDED、INSUFFICIENT_BALANCE、DUPLICATE_OUT_REFUND_NO。
GET5.5 查询退款
GET https://api.example.com/v1/refunds/{refund_no}
GET https://api.example.com/v1/refunds?out_refund_no={out_refund_no}
没有请求体,响应字段同 申请退款。退款单不存在或不属于你返回 404 REFUND_NOT_FOUND。
POST5.6 代付手续费试算
POST https://api.example.com/v1/payouts/fee-quote
按当前生效的费率试算,不冻结资金、不生成订单。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
product_code | string | 是 | 代付产品编码。 |
amount | int | 是 | 代付金额,单位分,必须大于 0。 |
响应字段(data)
| 字段 | 类型 | 说明 |
|---|---|---|
amount | int | 代付金额,单位分。 |
fee | int | 手续费,单位分。 |
fee_mode | string | 手续费收取方式:external(外扣:冻结 amount + fee,收款人收到 amount);internal(内扣:冻结 amount,收款人收到 amount − fee)。 |
hold_amount | int | 需要从你的账户冻结的金额,单位分。 |
payee_amount | int | 收款人实际到账金额,单位分。 |
请求:
{"product_code": "BANK_PAYOUT", "amount": 50000}
响应:
{
"code": "OK",
"message": "",
"request_id": "01JGZ8Q4R5S6T7V8W9X0Y1Z2A7",
"data": {
"amount": 50000,
"fee": 300,
"fee_mode": "external",
"hold_amount": 50300,
"payee_amount": 50000
}
}
可能的错误码:INVALID_PARAM(金额不大于 0,或内扣时手续费超过金额)、PAYOUT_DISABLED、PRODUCT_NOT_ENABLED。
POST5.7 发起代付
POST https://api.example.com/v1/payouts
IP_WHITELIST_REQUIRED。请求必须来自白名单内的 IP。请求字段
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
out_trade_no | string | 是 | 你的代付单号,最长 64 个字符,在你名下唯一。 |
product_code | string | 是 | 代付产品编码。 |
amount | int | 是 | 代付金额,单位分,必须大于 0。 |
payee | object | 是 | 收款人,字段见下。 |
payee.name | string | 是 | 收款人户名,最长 64 个字。 |
payee.account | string | 是 | 银行卡号,12 到 30 位数字。 |
payee.type | string | 是 | personal(对私)或 corporate(对公)。 |
payee.cnaps_code | string | 对公必填 | 12 位联行号。对公(corporate)必须填;对私填写时也必须是 12 位。 |
payee.bank_code | string | 否 | 银行编码,最长 16 个字符。 |
payee.bank_name | string | 否 | 开户行名称,最长 64 个字。 |
notify_url | string | 否 | 代付结果通知地址,规则同下单的 notify_url。 |
attach | string | 否 | 附加数据,最长 512 个字符,原样放在通知里。 |
{
"out_trade_no": "W202601010001",
"product_code": "BANK_PAYOUT",
"amount": 50000,
"payee": {
"name": "张三",
"account": "6222020200112233445",
"type": "personal",
"bank_code": "ICBC",
"bank_name": "中国工商银行"
},
"notify_url": "https://m.example.com/payout-notify",
"attach": "batch=7"
}
卡号和姓名平台收到后立即加密保存,响应和查询只返回打码后的姓名和卡号后四位。
响应字段(data)
发起代付和查询代付返回相同结构:
| 字段 | 类型 | 说明 |
|---|---|---|
order_no | string | 平台代付单号。 |
out_trade_no | string | 你的代付单号。 |
status | string | 代付状态,见 状态说明。 |
amount | int | 代付金额,单位分。 |
fee | int | 手续费,单位分。 |
fee_mode | string | external(外扣)或 internal(内扣)。 |
hold_amount | int | 冻结金额,单位分。 |
payee_amount | int | 收款人实际到账金额,单位分;内扣时为 amount − fee。 |
payee_name | string | 收款人姓名(已打码)。 |
payee_account_last4 | string | 收款账号后四位。 |
failure_code | string | 失败原因码,未失败时不出现,见 状态说明。 |
failure_msg | string | 失败原因说明,未失败时不出现。 |
created_at | string | 创建时间。 |
finished_at | string | 完成时间,未完成时不出现。 |
{
"code": "OK",
"message": "",
"request_id": "01JGZ8Q4R5S6T7V8W9X0Y1Z2A8",
"data": {
"order_no": "D2026010100000001",
"out_trade_no": "W202601010001",
"status": "processing",
"amount": 50000,
"fee": 300,
"fee_mode": "external",
"hold_amount": 50300,
"payee_amount": 50000,
"payee_name": "张*",
"payee_account_last4": "3445",
"created_at": "2026-01-01T10:00:00+08:00"
}
}
status 为 failed,failure_code 写明原因(这类单没有冻结资金)。请判断 status。status 为 processing、reviewing、unknown 时,以之后的 payout.succeeded / payout.failed 通知或查询结果为准,不要当作失败重复提交。可能的错误码:IP_WHITELIST_REQUIRED、INVALID_PARAM、MERCHANT_NOT_FOUND、MERCHANT_FROZEN、PAYOUT_DISABLED、PRODUCT_NOT_ENABLED、DUPLICATE_OUT_TRADE_NO、SERVICE_UNAVAILABLE。
GET5.8 查询代付
GET https://api.example.com/v1/payouts/{order_no}
GET https://api.example.com/v1/payouts?out_trade_no={out_trade_no}
没有请求体,响应字段同 发起代付。不存在或不属于你返回 404 ORDER_NOT_FOUND。
6. 状态说明
代收订单 status
| 值 | 含义 | 是否终态 |
|---|---|---|
created | 已生成订单,还在向上游下单。通常很快变化。 | 否 |
pending | 下单成功,等待用户支付。 | 否 |
paid | 已支付并入账。收到 payin.paid 通知。 | 是(可退款) |
partial_refunded | 已部分退款。 | 可继续退款 |
refunded | 已全额退款。 | 是 |
expired | 已过有效期,收银页不再受理,但宽限期内仍接受迟到的支付成功。 | 否,到 final_close_at 变为 closed |
closed | 最终关闭。收到 payin.closed 通知。 | 是 |
failed | 上游下单失败,看 error_code、error_msg。平台不自动换通道。 | 是 |
退款单 status
| 值 | 含义 |
|---|---|
created | 退款单已生成,还没有提交上游。 |
processing | 处理中,等待通知或查询。 |
success | 退款成功(终态)。 |
failed | 退款失败(终态),看 error_code、error_msg。 |
unknown | 结果不明(例如调用上游超时),等待平台查单确认,以通知或查询结果为准。 |
代付单 status
| 值 | 含义 |
|---|---|
accepted | 已受理,正在风控和冻结(短暂状态)。 |
reviewing | 等待人工审核,资金已冻结。 |
processing | 已提交或等待提交上游。 |
unknown | 当前结果不明,平台会查单确认,不要重复提交。 |
success | 代付成功(终态)。 |
failed | 代付失败(终态),看 failure_code、failure_msg。 |
代付失败原因 failure_code
| 值 | 含义 |
|---|---|
RISK_REJECTED | 被风控规则拒绝,failure_msg 写明规则原因。 |
PAYEE_RISK_REJECTED | 收款账户存在风险,暂不能代付。 |
CHANNEL_UNAVAILABLE | 没有可用的代付通道。 |
INSUFFICIENT_BALANCE | 可用余额不足以冻结所需金额。 |
DAILY_CAP_EXCEEDED | 超过单日代付上限。 |
REVIEW_REJECTED | 人工审核未通过。 |
REVIEW_TIMEOUT | 人工审核超时。 |
NOT_SUBMITTED | 审核通过后通道已经不可用,没有提交上游。 |
除上表外,上游通道返回失败时,failure_code 可能是上游给出的错误码,请以 failure_msg 为准。
7. 事件通知
7.1 格式
订单状态变化时,平台向订单的 notify_url 发送 HTTP POST。退款通知发到原订单的 notify_url。请求头:
| 请求头 | 说明 |
|---|---|
Content-Type | application/json; charset=utf-8 |
X-App-Id | 签名所用密钥对应的 app_id(用你最新的一把有效密钥签名;轮换密钥期间,验签代码要按 X-App-Id 选择对应的密钥)。 |
X-Timestamp | 秒级时间戳。 |
X-Nonce | 随机串。 |
X-Signature | 签名。 |
X-Event-Id | 事件 ID,同请求体里的 event_id。 |
请求体:
| 字段 | 类型 | 说明 |
|---|---|---|
event_id | string | 事件唯一 ID。同一个事件重试时 event_id 不变。 |
event_type | string | 事件类型,见 事件和 data。 |
created_at | string | 事件产生时间(UTC,RFC3339)。 |
data | object | 事件内容。 |
{
"event_id": "01JGZ9A1B2C3D4E5F6G7H8J9K0",
"event_type": "payin.paid",
"created_at": "2026-01-01T00:03:21Z",
"data": {
"order_no": "P2026010100000001",
"out_trade_no": "M202601010001",
"status": "paid",
"order_amount": 10000,
"pay_amount": 10000,
"merchant_fee": 60,
"merchant_credit": 9940,
"paid_at": "2026-01-01T08:03:21+08:00",
"attach": "uid=1001"
}
}
平台不跟随重定向,你的地址返回 3xx 按失败处理。通知地址只允许 http/https,端口只能是 80、443、8080、8443,不能解析到内网地址。平台发送通知的超时时间是 10 秒。
7.2 验签和处理
通知的签名方法和你给平台发请求时完全一样(3.1),你可以复用同一套签名代码:
METHOD是POST。PATH和QUERY取你的通知地址收到请求时的路径和查询参数(例如通知地址是https://m.example.com/pay/notify?shop=1,则 PATH 为/pay/notify,QUERY 为shop=1)。BODY是收到的请求体原始字节,不要先解析再序列化。
示例(密钥同上,X-Timestamp: 1767225602,X-Nonce: n0nce-0000000005):请求体 {"event_id":"E1","event_type":"payin.paid","created_at":"2026-01-01T00:00:02Z","data":{"order_no":"P1","status":"paid"}},待签名串:
POST
/pay/notify
shop=1
1767225602
n0nce-0000000005
fc2c16e95e3338fb0830456c947d7ff9d5919115b3e3d3b1816d64e02e0ac281
签名为 f759856d695aa15f2dbafe968be0972314fb84ea6a5eb400bce4573f2ef9523e。
收到通知后按下面顺序处理:
- 检查
X-App-Id等于你自己的 app_id。 - 检查
X-Timestamp与你服务器时间相差不超过 300 秒。 - 按上面方法计算签名,和
X-Signature比较(用常量时间比较函数)。不一致就拒绝(返回 4xx),不要处理数据。 - 按
event_id去重:把已处理的event_id记下来,重复收到时不再重复处理业务,但仍要返回 2xx。 - 更新你自己的订单状态。金额以通知里的
data为准,必要时再调用 查询接口 核对。 - 返回 HTTP 2xx(响应内容任意,例如
success)。返回非 2xx、3xx 或超时,平台都会重试。
7.3 重试规则
通知失败(连接失败、超时、返回非 2xx)后按下面的间隔重试。第 1 次投递在事件产生后立即进行,之后每次失败等待对应的间隔再投递:
| 失败次数 | 下一次投递的等待时间 |
|---|---|
| 第 1 次失败后 | 30 秒 |
| 第 2 次失败后 | 1 分钟 |
| 第 3 次失败后 | 5 分钟 |
| 第 4 次失败后 | 10 分钟 |
| 第 5 次失败后 | 30 分钟 |
| 第 6 次失败后 | 1 小时 |
| 第 7 次失败后 | 2 小时 |
| 第 8 次失败后 | 6 小时 |
| 第 9 次失败后 | 放弃,平台生成异常单交给运营人员处理 |
也就是一个事件最多投递 9 次。放弃之后平台不会再自动发送,请用 查询接口 兜底,也可以联系平台处理。
7.4 事件和 data
| event_type | 触发时机 | data 字段 |
|---|---|---|
payin.paid | 订单支付成功 | order_no、out_trade_no、status(paid)、order_amount、pay_amount、merchant_fee(手续费,分)、merchant_credit(入账金额,分)、paid_at、attach |
payin.closed | 订单最终关闭 | order_no、out_trade_no、status(closed)、order_amount、pay_amount、closed_at、attach |
refund.succeeded | 退款成功 | refund_no、out_refund_no、order_no、out_trade_no、amount、status(success)、finished_at |
refund.failed | 退款失败 | 同上,status 为 failed,另有 error_code、error_msg |
payout.succeeded | 代付成功 | order_no、out_trade_no、status(success)、amount、fee、payee_amount、finished_at、attach |
payout.failed | 代付失败 | order_no、out_trade_no、status(failed)、amount、fee、failure_code、failure_msg、attach |
test.ping | 在商户后台点击“发送测试通知” | message:测试说明文字 |
金额字段单位都是分。建议按 event_type 分支处理,对不认识的 event_type 返回 2xx 即可(避免平台持续重试)。test.ping 用于联调:验签通过并返回 2xx 即算成功。
7.5 出口 IP 白名单
如果你的服务器有防火墙或只接收白名单 IP 的请求,请放行下面这些平台发通知所用的出口 IP:
平台的通知来源 IP 在商户后台「对接资料」页面查看。平台换服务器时 IP 可能变化,以后台显示的为准。
注意这是平台访问你的出口 IP;你访问平台接口时使用的 IP 则要加到商户后台的 API 白名单里,两者方向不同。
8. 错误码
失败时响应为 {"code":"错误码","message":"中文说明","request_id":"..."}(INVALID_SIGNATURE 另带 data.sign_base)。HTTP 状态码和错误码一起判断。网络错误或超时不是业务错误,见 常见问题。
验签和通用错误
验签阶段的错误,响应没有签名头。
| 错误码 | HTTP | 含义 | 处理建议 |
|---|---|---|---|
MISSING_AUTH_HEADER | 401 | 缺少 X-App-Id、X-Timestamp、X-Nonce 或 X-Signature。 | 补全 4 个请求头;检查网关或代理是否丢弃了自定义头。 |
INVALID_TIMESTAMP | 401 | X-Timestamp 不是秒级 Unix 时间戳。 | 使用秒(10 位)而不是毫秒(13 位)。 |
TIMESTAMP_EXPIRED | 401 | X-Timestamp 与平台时间相差超过 300 秒,message 里写明相差的秒数。 | 校准服务器时间(NTP),每次请求取当前时间重新签名,不要缓存签名。 |
INVALID_NONCE | 401 | X-Nonce 不是 16 到 64 位字母、数字、下划线或横线。 | 用 32 位随机十六进制字符串。 |
INVALID_APP_ID | 401 | app_id 不存在或已停用(商户被关闭时同样返回)。 | 核对 app_id;联系平台确认密钥状态。 |
INVALID_SIGNATURE | 401 | 签名不一致。data.sign_base 是平台算出的待签名串。 | 把 data.sign_base 和你的待签名串逐行对比,见 常见问题。 |
NONCE_REUSED | 401 | X-Nonce 已经使用过。 | 每次请求(包括重试)都生成新的 Nonce。 |
IP_NOT_ALLOWED | 403 | 来源 IP 不在这把密钥的白名单内,message 里写明平台看到的来源 IP。 | 把 message 里的 IP 加入商户后台白名单;如果经过了代理或 NAT,加入实际出口 IP。 |
IP_WHITELIST_REQUIRED | 403 | 调用代付接口,但这把密钥没有设置 IP 白名单。 | 先在商户后台设置 IP 白名单再调用代付。 |
RATE_LIMITED | 429 | 请求太频繁(同一 IP 每秒 200 次,同一 app_id 默认每秒 50 次)。 | 降低频率,稍后重试。 |
BODY_TOO_LARGE | 413 | 请求体超过 65536 字节。 | 精简请求体。 |
SERVICE_UNAVAILABLE | 503 | 服务暂时不可用(例如平台依赖的服务临时故障)。 | 稍后用相同的商户单号重试,不会重复下单。 |
INTERNAL_ERROR | 500 | 平台内部错误。 | 用相同的商户单号查询或重试;持续出现时带上 request_id 联系平台。 |
业务错误
| 错误码 | HTTP | 含义 | 处理建议 |
|---|---|---|---|
INVALID_PARAM | 400 | 参数不合法、缺少必填字段、请求体不是合法 JSON 或有未知字段,message 写明具体原因。 | 按 message 修正请求,不要原样重试。 |
MERCHANT_NOT_FOUND | 403 | 商户不存在。 | 联系平台。 |
MERCHANT_FROZEN | 403 | 商户已被冻结或关闭,不能下单、代付。查询接口仍可使用。 | 联系平台或上级。 |
PAYIN_DISABLED | 403 | 商户未开通代收。 | 联系平台开通。 |
PAYOUT_DISABLED | 403 | 商户未开通代付。 | 联系平台开通。 |
PRODUCT_NOT_ENABLED | 403 | 产品不存在、不可用,或商户未开通该产品。 | 核对 product_code,联系平台开通。 |
RISK_REJECTED | 403 | 下单被风控规则拒绝,message 写明原因。 | 不要原样重试;有疑问联系平台。 |
CHANNEL_UNAVAILABLE | 503 | 没有可用的通道(例如金额超出通道范围、通道暂停),message 写明原因。 | 核对金额;稍后重试或联系平台。 |
FEE_NOT_CONFIGURED | 503 | 该产品没有配置费率。 | 联系平台或上级配置费率。 |
DUPLICATE_OUT_TRADE_NO | 409 | 商户单号已存在,且参数与原单不同(代收比较产品、金额、标题、通知地址、返回地址、附加数据;代付比较产品、金额、收款人、通知地址、附加数据)。 | 要重新下单请换新单号;要查原单用查询接口。 |
ORDER_NOT_FOUND | 404 | 订单(或代付单)不存在或不属于你。 | 核对订单号。 |
ORDER_NOT_CLOSABLE | 409 | 订单已支付不能关闭,或订单正在提交上游。 | 已支付请走退款;正在提交上游请稍后重试。 |
ORDER_NOT_REFUNDABLE | 409 | 订单状态不允许退款(例如未支付),message 写明当前状态。 | 只有已支付或部分退款的订单可以退款。 |
REFUND_NOT_SUPPORTED | 400 | 该订单所用的通道不支持退款。 | 联系平台线下处理。 |
REFUND_IN_PROGRESS | 409 | 这张订单有一笔退款正在处理。 | 等它完成(成功或失败)后再发起。 |
REFUND_AMOUNT_EXCEEDED | 400 | 退款金额超过剩余可退金额,message 写明可退金额。 | 调整退款金额。 |
INSUFFICIENT_BALANCE | 409 | 退款需要冻结的余额不足。 | 等待余额充足后再退款。 |
DUPLICATE_OUT_REFUND_NO | 409 | 退款单号已存在,且参数与原退款单不同。 | 换新的退款单号;查原单用查询接口。 |
REFUND_NOT_FOUND | 404 | 退款单不存在或不属于你。 | 核对退款单号。 |
代付被风控拒绝、余额不足、超过单日上限、没有可用通道不是 HTTP 错误,而是 HTTP 200 加 status: failed,原因见 代付失败原因。
9. SDK 下载
SDK 只依赖各语言标准库,封装了签名、请求、响应验签和通知验签,包内的 README 有下单、查询、处理通知的完整示例。
也可以登录商户后台,在「对接资料」页面下载。
10. 常见问题
签名不一致(INVALID_SIGNATURE)怎么排查?
响应的 data.sign_base 是平台根据你的请求算出的待签名串(不含密钥)。把它和你程序里的待签名串逐行对比(共 6 行:METHOD、PATH、QUERY、TIMESTAMP、NONCE、BODY 的 sha256),也可以把它粘贴到上面调试器的“平台返回的 sign_base”里自动对比。常见原因:
- 第 1 行方法没有大写,或第 2 行 PATH 带了域名、查询串,或漏了路径前缀。
- 第 3 行查询串没有排序,或编码规则不对(空格要用
+,~不编码,*要编成%2A)。 - 第 6 行 BODY 的 sha256 不同:签名用的字符串和实际发送的字节不是同一份(例如签名后又重新序列化、被框架改了格式、编码不是 UTF-8、GET 请求却带了请求体)。
- sign_base 完全一致但签名仍然不对:密钥错误,或者密钥被当成十六进制解码了(应按文本使用),或者结果用了大写。
- 时间戳或 Nonce:签名用的值和请求头里的值不是同一个。
报 TIMESTAMP_EXPIRED 怎么办?
说明你的服务器时间和平台相差超过 300 秒。用 NTP 校准时间;每次请求都取当前时间生成时间戳并重新签名;用的是秒,不是毫秒。
报 NONCE_REUSED 怎么办?
X-Nonce 在 10 分钟内重复了。每次请求,包括超时后的重试,都要生成新的随机 Nonce(建议用安全随机数生成 32 位十六进制)。不要用固定值,也不要用只精确到秒的时间戳当 Nonce。
为什么代付必须设置 IP 白名单?
代付是出款操作,为了避免密钥泄露后被人直接转走资金,平台要求代付所用的密钥必须在商户后台设置 IP 白名单,并且只接受白名单内 IP 的请求。没设置时返回 403 IP_WHITELIST_REQUIRED;IP 不在白名单内返回 403 IP_NOT_ALLOWED。
网络超时了,要不要换单号重新下单?
不要。超时只说明你没收到响应,不代表平台没有收到请求。正确做法:
- 用同一个商户单号调用查询接口。查到了就用查询结果;查不到(404)说明平台没有收到,再用同一个单号和相同的参数重新提交,是安全的,不会重复下单。
- 代付、退款同理:
status为unknown或请求超时时,不要换单号重发,以通知或查询结果为准。 - 换新单号重发可能造成用户重复付款或重复出款。
通知收不到怎么办?
检查:下单时是否填了 notify_url;地址是否是公网可访问的 http/https,端口是否为 80、443、8080、8443;你的防火墙是否放行了 平台出口 IP;服务是否在 10 秒内返回了 2xx;是否返回了 3xx(平台不跟随重定向)。无论如何,都要用 查询接口 兜底。
重复收到同一个通知怎么办?
正常现象(重试、你的响应超时)。按 event_id 去重,已处理的直接返回 2xx。
pay_amount 和 order_amount 为什么不一样?
有立减活动时,用户实付金额 pay_amount 会小于订单金额 order_amount。收款以 pay_amount 为准。