商户 API 对接文档
版本 v1.5 · 最后更新:2026-04-18(文档与代码一致性修正:pay_url 格式、回调字段、错误响应 code 字段、amount 类型)
网关地址:
文档中所有接口地址会自动更新
一、接入准备
1.1 获取密钥
1登录商户后台
2进入「API管理」页面
3获取以下信息:
| 参数 | 说明 |
|---|---|
商户号 memberid | 平台分配的6位商户号 |
商户密钥 key | 用于签名的密钥,请妥善保管 |
网关地址 gateway | API请求的基础URL |
1.2 对接须知
| 项目 | 说明 |
|---|---|
| 请求方式 | 所有接口均使用 POST |
| 请求格式 | application/x-www-form-urlencoded 或 multipart/form-data |
| 字符编码 | UTF-8 |
| 签名方式 | HMAC-SHA256 |
| 入站请求 IP 白名单 | 在商户后台配置后,限制哪些 IP 可以调用本平台的商户 API(不是平台回调商户的 IP)。未配置时允许所有 IP。不通过时响应 auth_failed |
| 金额格式 | 保留2位小数,如 100.00 |
| 时间格式 | YYYY-MM-DD HH:mm:ss,如 2026-04-04 14:30:00 |
| 时间说明 | pay_applydate 为商户系统当前时间,任意时区均可,仅参与签名计算 |
| 时区 | 所有时间字段使用平台时区(默认 Asia/Shanghai,管理员可在后台系统设置 platform_timezone 切换),格式 YYYY-MM-DD HH:mm:ss。商户对账时需按此时区解析 |
| 频率限制 | 按商户号独立限流,各接口分开计数:代收/代付下单 200次/分钟,订单查询/余额查询 300次/分钟。超出返回 429 |
兼容说明(pay_timestamp / pay_nonce)
v1.3 及更早版本要求签名包含
pay_timestamp 和 pay_nonce 防重放。v1.4 起已移除此要求,但服务端仍接受这两个字段以兼容旧客户端(带了也会一起参与 HMAC 计算)。新商户对接不需要提供。
回调 URL 限制(SSRF 防护)
pay_notifyurl 必须是公网可达的 http 或 https 地址。平台会拒绝私有/保留 IP(127.0.0.0/8、10.0.0.0/8、172.16.0.0/12、192.168.0.0/16、169.254.0.0/16 云厂商元数据 endpoint)及非 http(s) 协议。若回调域名解析到私有 IP,订单 callback_status 会标记失败,不发 HTTP 请求。
二、签名算法
平台使用 HMAC-SHA256 签名算法:
| 签名类型 | 安全等级 | 说明 |
|---|---|---|
| HMAC-SHA256 | 高 | 64位签名 |
2.1 通用步骤
1筛选参与签名的字段,去除值为空的参数和签名字段(
pay_sign)本身。2将剩余参数按照 参数名 ASCII 码从小到大排序(字典序),使用 URL 键值对的格式拼接。
3使用 HMAC-SHA256 算法计算签名:
HMAC-SHA256
sign = HMAC_SHA256(stringA, 商户密钥).toUpperCase()密钥作为 HMAC 的 key 参数,不拼接在字符串末尾。
2.2 签名示例
假设参数如下:
pay_memberid = "100001"
pay_orderid = "20260404143000001"
pay_applydate = "2026-04-04 14:30:00"
pay_notifyurl = "https://merchant.com/notify"
pay_callbackurl = "https://merchant.com/callback"
pay_amount = "500.00"
商户密钥 = "my_secret_key_example"拼接后(按参数名ASCII排序):
stringA = "pay_amount=500.00&pay_applydate=2026-04-04 14:30:00&pay_callbackurl=https://merchant.com/callback&pay_memberid=100001&pay_notifyurl=https://merchant.com/notify&pay_orderid=20260404143000001"
sign = "83F91B87B627E6637375FD4D5C1D5A5FB61D43C2F67EC9A2545FB0BC89C2F2F1"
本地复核
上述签名由示例密钥
my_secret_key_example 对 stringA 做 HMAC-SHA256 后转大写得出。商户可在本地用相同参数计算并对照此值,验证签名函数实现是否正确。
注意事项
值为空的参数不参与签名 · 签名字段本身不参与签名 · 中文参数不需要URL编码后再签名 · 参数值前后不要有空格
三、代收 API
3.1 代收下单
商户发起收款请求,平台匹配可用通道并返回收款信息。
POST{网关地址}/api/v1/gateway/pay
请求参数
| 参数名 | 含义 | 必填 | 签名 | 说明 |
|---|---|---|---|---|
pay_memberid | 商户号 | Y | Y | 平台分配的商户号 |
pay_orderid | 商户订单号 | Y | Y | 商户系统唯一订单号,最长50字符 |
pay_applydate | 提交时间 | Y | Y | 商户系统当前时间,格式:YYYY-MM-DD HH:mm:ss,任意时区均可,仅参与签名 |
pay_bankcode | 通道编码 | N | Y | 支付通道编码,不传则使用默认通道,参见通道编码表 |
pay_notifyurl | 服务端通知地址 | Y | Y | 支付成功后,平台通过POST异步通知此地址,必须为外网可访问的URL |
pay_callbackurl | 页面跳转地址 | Y | Y | 支付成功后,收银台页面自动跳转至此URL(5秒倒计时) |
pay_amount | 订单金额 | Y | Y | 保留2位小数,如 100.00 |
pay_ip | 付款人IP | N | N | 付款人的真实IP地址 |
pay_sign | 签名 | Y | - | 签名值,参见签名算法 |
pay_attach | 附加数据 | N | N | 商户自定义透传数据,回调时原样返回(用于商户对账/追踪),不在平台后台展示 |
pay_userid | 会员ID | N | N | 商户系统内的用户唯一标识 |
pay_username | 付款人姓名 | N | N | 转卡或银联通道必填 |
请求示例
{
"pay_memberid": "100001",
"pay_orderid": "20260414100000001",
"pay_applydate": "2026-04-14 10:00:00",
"pay_notifyurl": "https://merchant.com/notify",
"pay_callbackurl": "https://merchant.com/success",
"pay_amount": "500.00",
"pay_username": "张三",
"pay_sign": "A1B2C3D4E5F6..."
}成功响应
{
"code": 200,
"message": "操作成功",
"data": {
"status": "1",
"msg": "下单成功",
"order_id": "C20260417100005ABC123",
"mch_order_id": "20260404143000001",
"pay_url": "https://cashier.romapay.cc/cashier/eyJhbGciOiJIUzI1NiIs...",
"bank_name": "PGbank",
"real_name": "NGUYỄN THÁI KHIÊM",
"account_no": "5396385059628",
"qr_code_link": "00020101021138570010A00000072701270006970430011353963850596280208QRIBFTTA53037045802VN6304685A",
"amount": "500.00",
"created_at": "2026-04-17 18:40:00",
"remaining_seconds": 1799,
"pay_remark": "3F2A7B"
},
"timestamp": 1776425678123
}响应外层统一结构
{code, message, data, timestamp} 由平台全局响应拦截器包装,所有接口一致。| 参数 | 说明 |
|---|---|
code | HTTP状态码,200 为成功 |
message | 操作结果描述 |
data.status | "1" 为成功 |
data.msg | 状态描述信息 |
data.order_id | 平台系统订单号(C开头=代收) |
data.mch_order_id | 商户订单号(原样返回 pay_orderid) |
data.pay_url | 收银台完整 URL,格式为 {域名}/cashier/{jwt_token}(路径形式,不是查询串)。商户应将该 URL 直接发给付款人(跳转而非转发)。IP 绑定:该 URL 对应的 token 会记住首次访问的 IP,后续其他 IP 打开将被拒;请避免 URL 在代理/日志中泄漏给第三方 |
data.bank_name | 收款银行名 |
data.real_name | 收款人姓名 |
data.account_no | 收款卡号 |
data.qr_code_link | 二维码字符串数据(越南 VietQR 格式原文);没有时为空串 |
data.amount | 订单金额(2 位小数字符串) |
data.created_at | 订单创建时间(平台时区,格式 YYYY-MM-DD HH:mm:ss) |
data.remaining_seconds | 距离订单失效还剩多少秒,服务器实时计算。客户端直接用这个做倒计时即可,避免时区/时钟偏差问题。0 表示已过期 |
data.pay_remark | 平台生成的 6 位转账备注,由大写英文字母 A-Z 与数字 0-9 随机组成(如 3F2A7B)。付款人银行转账时须在备注栏填写此码,平台据此识别入账;订单期间不变,查询接口返回同一值 |
timestamp | 服务端时间戳(毫秒) |
幂等保证
使用同一个
pay_orderid 重复调用本接口不会创建重复订单,会返回首次创建的订单信息(包括 order_id、pay_url、remaining_seconds 等字段),因此商户可以安全地重试失败的请求。
常见错误
{
"code": 1003,
"message": "auth_failed",
"data": null,
"timestamp": 1776425678123,
"request_id": "b6554358-fd6e-4205-bdf9-ed085e3d2303"
}
业务码 vs HTTP 状态码
响应体里的
code 是业务码(此处为 1003),HTTP status 才是 403。两者不一定相等。商户程序应判断响应体 code === 1003 识别身份验证失败,不要用 HTTP status 判断。
| HTTP status | 业务 code | message | 原因 |
|---|---|---|---|
| 403 | 1003 | auth_failed | 身份验证失败(商户号/签名/IP 白名单等,统一返回以防枚举) |
| 403 | 403 | 商户已禁用 | 商户被平台禁用 |
| 403 | 403 | 商户代收功能未开启 | 商户未开启收款功能 |
| 400 | 400 | 金额须在 X - Y 之间 | 金额低于最小或超过最大限额 |
| 404 | 404 | 暂无可用通道 | 当前无匹配的收款产品 |
| 429 | 429 | 请求过于频繁,请稍后再试 | 超出该商户该接口的频率限制(按商户号独立计数) |
3.2 代收回调通知
付款人完成支付后,平台会向商户的 pay_notifyurl 发送POST异步通知。
POST商户的 pay_notifyurl 地址
回调参数
| 参数名 | 含义 | 签名 | 说明 |
|---|---|---|---|
memberid | 商户号 | Y | 平台分配的商户号 |
orderid | 商户订单号 | Y | 商户系统的订单号 |
amount | 订单金额 | Y | 实际支付金额,保留2位小数 |
transaction_id | 平台流水号 | Y | 平台生成的唯一交易流水号 |
datetime | 交易时间 | Y | 平台时区时间,格式 YYYY-MM-DD HH:mm:ss,如 2026-04-04 14:35:00。成功回调( returncode=00)时为订单实际支付时间 paid_at;失败回调时为回调发送时刻(并非交易时间)。对账时应结合 transaction_id 查订单明细确认 |
returncode | 交易状态 | Y | 见下方状态说明 |
timestamp | 回调时间戳 | Y | Unix时间戳(秒),用于防重放校验 |
nonce | 随机串 | Y | UUID v4 格式(36 字符含连字符,例 a1b2c3d4-e5f6-7890-abcd-ef1234567890),每次回调唯一 |
sign | 签名 | - | 签名值,商户需验签确认通知合法性 |
returncode 状态说明
| returncode | 含义 | 商户处理建议 |
|---|---|---|
00 | 收款成功 | 标记订单已支付,发货/发放服务 |
01 | 收款失败 | 订单过期或未完成,不可视为成功 |
商户响应要求
验签通过并处理业务逻辑后,商户需在HTTP响应体中返回纯文本:
OK
必须返回大写的 OK
无引号、无空格、无其他字符。如果平台未收到
OK 响应,将按 首次请求 + 最多 3 次重试,总共最多 4 次,间隔 30 秒 → 60 秒 → 120 秒。
安全校验建议
1. 检查 timestamp:当前时间 - timestamp > 300秒 → 拒绝(可能是重放攻击)
2. 检查 nonce:是否已处理过 → 已处理则直接返回OK
3. 回调可能重复发送,商户需做好幂等处理
2. 检查 nonce:是否已处理过 → 已处理则直接返回OK
3. 回调可能重复发送,商户需做好幂等处理
3.3 代收订单查询
主动查询代收订单的支付状态。
POST{网关地址}/api/v1/gateway/query
请求参数
| 参数名 | 含义 | 必填 | 签名 | 说明 |
|---|---|---|---|---|
pay_memberid | 商户号 | Y | Y | 平台分配的商户号 |
pay_orderid | 商户订单号 | Y | Y | 需要查询的商户订单号 |
pay_sign | 签名 | Y | - | 签名值 |
请求示例
{
"pay_memberid": "100001",
"pay_orderid": "20260414100000001",
"pay_sign": "A1B2C3D4E5F6..."
}响应参数
{
"code": 200,
"message": "操作成功",
"data": {
"memberid": "100001",
"orderid": "20260404143000001",
"amount": "500.00",
"time_end": "2026-04-04 14:35:00",
"transaction_id": "C20260404143000123456",
"returncode": "00",
"trade_state": "SUCCESS",
"pay_remark": "3F2A7B",
"sign": "A1B2C3D4..."
},
"timestamp": 1775551800000
}| 参数名 | 含义 | 签名 | 说明 |
|---|---|---|---|
data.memberid | 商户号 | Y | 平台分配的商户号 |
data.orderid | 商户订单号 | Y | 原样返回下单时的 pay_orderid |
data.amount | 订单金额 | Y | 保留2位小数,如 500.00 |
data.time_end | 支付完成时间 | Y | 平台时区格式 YYYY-MM-DD HH:mm:ss,未支付时为空字符串 |
data.transaction_id | 平台流水号 | Y | 平台系统订单号(C开头=代收) |
data.returncode | 请求状态 | Y | 00=查询成功 / 01=未支付。仅表示查询请求状态,不代表已支付 |
data.trade_state | 交易状态 | Y | NOTPAY=待支付 / SUCCESS=已支付 / EXPIRED=已过期 |
data.pay_remark | 转账备注 | Y | 平台生成的 6 位转账备注(A-Z 与 0-9 混合,如 3F2A7B),与下单响应中的 pay_remark 一致 |
data.sign | 签名 | - | 平台对响应参数的签名,商户可验签确认合法性 |
判断支付状态请使用
trade_state 字段,不要用 returncode
四、代付 API
4.1 代付下单
商户发起付款请求,平台将资金通过渠道转账至指定收款账户。
POST{网关地址}/api/v1/gateway/payout
请求参数
| 参数名 | 含义 | 必填 | 签名 | 说明 |
|---|---|---|---|---|
pay_memberid | 商户号 | Y | Y | 平台分配的商户号 |
pay_orderid | 商户订单号 | Y | Y | 商户系统唯一订单号,最长50字符 |
pay_applydate | 提交时间 | Y | Y | 商户系统当前时间,格式:YYYY-MM-DD HH:mm:ss,仅参与签名 |
pay_bankcode | 通道编码 | N | Y | 付款通道编码,不传则使用默认通道,参见通道编码表 |
pay_notifyurl | 服务端通知 | Y | Y | 付款完成后,平台通过POST异步通知此地址,必须为外网可访问的URL |
pay_amount | 付款金额 | Y | Y | 保留2位小数 |
pay_callbackurl | 页面跳转地址 | N | N | 付款完成后的前端跳转地址 |
pay_bank_name | 收款银行 | Y | Y | 如 Vietcombank、Techcombank、BIDV、VPBank、PGbank |
pay_account_name | 收款人姓名 | Y | Y | 收款方真实姓名 |
pay_account_no | 收款账号 | Y | Y | 收款方银行卡号 |
pay_sign | 签名 | Y | - | 签名值 |
pay_attach | 附加数据 | N | N | 原样返回 |
请求示例
{
"pay_memberid": "100001",
"pay_orderid": "20260414150000001",
"pay_applydate": "2026-04-14 15:00:00",
"pay_notifyurl": "https://merchant.com/notify",
"pay_amount": "1000.00",
"pay_bank_name": "Vietcombank",
"pay_account_name": "NGUYEN VAN A",
"pay_account_no": "9704366812345678",
"pay_sign": "A1B2C3D4E5F6..."
}成功响应
{
"code": 200,
"message": "操作成功",
"data": {
"status": "1",
"msg": "下单成功",
"order_id": "P20260404150000654321",
"mch_order_id": "20260404150000001",
"amount": "1000.00",
"fee": "32.00"
},
"timestamp": 1775551800000
}| 参数 | 说明 |
|---|---|
code | HTTP状态码,200 为成功 |
data.status | "1" 为成功 |
data.msg | 状态描述信息 |
data.order_id | 平台系统订单号(P开头=代付) |
data.mch_order_id | 商户订单号(原样返回 pay_orderid) |
data.amount | 付款金额(字符串,格式 X.XX,2 位小数,与代收响应保持一致) |
data.fee | 手续费金额(百分比费用 + 固定费用)(字符串,格式 X.XX,2 位小数) |
timestamp | 服务端时间戳(毫秒) |
余额要求
代付需要商户预先充值。所需余额 = 付款金额 + 手续费。例如:付款 1000,费率 3%+2,则需余额 ≥ 1032。
幂等保证
使用同一个
pay_orderid 重复调用本接口不会创建重复订单,会返回首次创建的订单信息(order_id、amount、fee 等字段),因此商户可以安全地重试失败的请求。
常见错误
| HTTP 状态码 | message | 原因 |
|---|---|---|
| 400 | 商户余额不足 | 代付下单时商户可用余额 < (付款金额 + 手续费);通过 /api/v1/gateway/balance 查当前余额 |
| 404 | no_channel_available | 找不到金额范围匹配的银行卡(代付无可用收款卡) |
| 404 | no_test_channel_available | 测试模式商户专属错误,测试环境没有可用通道 |
| 403 | 商户代付功能未开启 | 商户后台未开启代付 |
4.2 代付回调通知
付款完成后,平台会向商户的 pay_notifyurl 发送POST异步通知。
POST商户的 pay_notifyurl 地址
回调参数
| 参数名 | 含义 | 签名 | 说明 |
|---|---|---|---|
memberid | 商户号 | Y | 平台分配的商户号 |
orderid | 商户订单号 | Y | 商户系统的订单号 |
amount | 付款金额 | Y | 实际付款金额,保留2位小数 |
transaction_id | 平台流水号 | Y | 平台生成的唯一交易流水号(P开头) |
datetime | 交易时间 | Y | 平台时区时间,格式 YYYY-MM-DD HH:mm:ss。成功回调( returncode=00)时为订单实际出款时间 paid_at;失败/取消回调时为回调发送时刻(并非交易时间)。对账时应结合 transaction_id 查订单明细确认 |
returncode | 交易状态 | Y | 见下方状态说明 |
timestamp | 回调时间戳 | Y | Unix时间戳(秒),用于防重放校验 |
nonce | 随机串 | Y | UUID v4 格式(36 字符含连字符,例 a1b2c3d4-e5f6-7890-abcd-ef1234567890),每次回调唯一 |
sign | 签名 | - | 签名值,商户需验签确认通知合法性 |
returncode 状态说明
| returncode | 含义 | 商户处理建议 |
|---|---|---|
00 | 付款成功 | 标记订单为付款完成 |
01 | 付款失败 | 联系平台客服排查原因 |
02 | 已取消 | 付款被取消,款项不会到账 |
商户响应要求 与代收回调相同,验签通过后需返回纯文本
OK(大写)。如果平台未收到 OK,将按 首次请求 + 最多 3 次重试,总共最多 4 次,间隔 30 秒 → 60 秒 → 120 秒。
4.3 代付订单查询
POST{网关地址}/api/v1/gateway/payout/query
请求参数
| 参数名 | 含义 | 必填 | 签名 |
|---|---|---|---|
pay_memberid | 商户号 | Y | Y |
pay_orderid | 商户订单号 | Y | Y |
pay_sign | 签名 | Y | - |
请求示例
{
"pay_memberid": "100001",
"pay_orderid": "20260414150000001",
"pay_sign": "A1B2C3D4E5F6..."
}响应示例
{
"code": 200,
"message": "操作成功",
"data": {
"memberid": "100001",
"orderid": "20260404150000001",
"amount": "1000.00",
"time_end": "2026-04-04 15:10:00",
"transaction_id": "P20260404150000654321",
"returncode": "00",
"trade_state": "SUCCESS",
"proof_image_url": "https://romapay.cc/uploads/a1b2c3d4e5f6.png",
"sign": "A1B2C3D4..."
},
"timestamp": 1775551800000
}| 参数名 | 含义 | 签名 | 说明 |
|---|---|---|---|
data.memberid | 商户号 | Y | 平台分配的商户号 |
data.orderid | 商户订单号 | Y | 原样返回下单时的 pay_orderid |
data.amount | 付款金额 | Y | 字符串,格式 X.XX(2 位小数),如 1000.00 |
data.time_end | 付款完成时间 | Y | 平台时区格式 YYYY-MM-DD HH:mm:ss,未完成时为空字符串 |
data.transaction_id | 平台流水号 | Y | 平台系统订单号(P开头=代付) |
data.returncode | 请求状态 | Y | 00=查询成功 / 01=未完成 |
data.trade_state | 交易状态 | Y | NOTPAY=待付款 / SUCCESS=已付款 / CANCELLED=已取消 / EXPIRED=已过期 |
data.proof_image_url | 付款凭证图片 URL | Y | 代付成功后运营上传的转账凭证截图;未上传时为空字符串。 当平台未配置 PUBLIC_ASSETS_URL 时,此字段可能是相对路径(如 /uploads/xxx.png),商户需要自行拼接 {网关域名} 前缀。配置完整时是绝对 URL(如 https://romapay.cc/uploads/xxx.png) |
data.sign | 签名 | - | 平台对响应参数的签名,商户可验签确认合法性 |
五、余额查询
POST{网关地址}/api/v1/gateway/balance
请求参数
| 参数名 | 含义 | 必填 | 签名 |
|---|---|---|---|
pay_memberid | 商户号 | Y | Y |
pay_sign | 签名 | Y | - |
余额接口仅需
pay_memberid + pay_sign。请求示例
{
"pay_memberid": "100001",
"pay_sign": "A1B2C3D4E5F6..."
}响应示例
{
"code": 200,
"message": "操作成功",
"data": {
"status": "1",
"memberid": "100001",
"balance": "50000.00",
"frozen_balance": "3000.00",
"timestamp": "1775551800",
"sign": "A1B2C3D4E5F6..."
},
"timestamp": 1775551800000
}| 参数 | 说明 |
|---|---|
code | HTTP状态码,200 为成功 |
data.status | "1" 为成功 |
data.memberid | 商户号 |
data.balance | 可用余额(2 位小数字符串) |
data.frozen_balance | 冻结余额(进行中订单占用,2 位小数字符串) |
data.timestamp | 响应生成时间戳(Unix 秒)— 参与签名;防止重放已返回的旧余额 |
data.sign | 签名 — 平台对响应参数的签名,商户可用自己的密钥验签 |
响应包含
sign 字段,使用商户密钥对 data 内除 sign 外的字段按签名算法计算。商户可验签确认响应未被篡改。六、支付通道编码
| 通道编码 | 通道名称 | 类型 | 说明 |
|---|---|---|---|
BANK_CARD | 银行卡转卡 | 代收/代付 | 默认通道,代收时需填写 pay_username |
当前版本仅支持银行卡转卡通道。通道编码为可选参数,不传时默认使用银行卡通道。
七、签名示例代码
PHP
<?php
function createSign($params, $key) {
unset($params['pay_sign'], $params['sign']);
$params = array_filter($params, function($v) { return $v !== ''; });
ksort($params);
$stringA = '';
foreach ($params as $k => $v) {
$stringA .= $k . '=' . $v . '&';
}
$stringA = rtrim($stringA, '&');
return strtoupper(hash_hmac('sha256', $stringA, $key));
}
$params = [
'pay_memberid' => '100001',
'pay_orderid' => '20260404143000001',
'pay_applydate' => '2026-04-04 14:30:00',
'pay_notifyurl' => 'https://merchant.com/notify',
'pay_callbackurl' => 'https://merchant.com/callback',
'pay_amount' => '500.00',
];
$params['pay_sign'] = createSign($params, 'your_secret_key');
$ch = curl_init('%%BASE_URL%%/api/v1/gateway/pay');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($params));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;Java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.util.*;
public class PaySign {
public static String createSign(Map<String, String> params, String key) {
TreeMap<String, String> sorted = new TreeMap<>();
for (Map.Entry<String, String> e : params.entrySet()) {
if (!"pay_sign".equals(e.getKey()) && !"sign".equals(e.getKey())
&& e.getValue() != null && !e.getValue().isEmpty()) {
sorted.put(e.getKey(), e.getValue());
}
}
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, String> e : sorted.entrySet()) {
if (sb.length() > 0) sb.append("&");
sb.append(e.getKey()).append("=").append(e.getValue());
}
return hmacSha256(sb.toString(), key).toUpperCase();
}
private static String hmacSha256(String data, String key) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key.getBytes("UTF-8"), "HmacSHA256"));
byte[] bytes = mac.doFinal(data.getBytes("UTF-8"));
StringBuilder sb = new StringBuilder();
for (byte b : bytes) sb.append(String.format("%02x", b));
return sb.toString();
} catch (Exception e) { throw new RuntimeException(e); }
}
public static void main(String[] args) {
Map<String, String> params = new HashMap<>();
params.put("pay_memberid", "100001");
params.put("pay_orderid", "20260404143000001");
params.put("pay_applydate", "2026-04-04 14:30:00");
params.put("pay_notifyurl", "https://merchant.com/notify");
params.put("pay_callbackurl", "https://merchant.com/callback");
params.put("pay_amount", "500.00");
params.put("pay_sign", createSign(params, "your_secret_key"));
}
}Python
import hashlib, hmac, requests
def create_sign(params: dict, key: str) -> str:
filtered = {k: v for k, v in params.items()
if k not in ('pay_sign', 'sign') and v}
sorted_keys = sorted(filtered.keys())
string_a = '&'.join(f'{k}={filtered[k]}' for k in sorted_keys)
return hmac.new(key.encode(), string_a.encode(), hashlib.sha256).hexdigest().upper()
params = {
'pay_memberid': '100001',
'pay_orderid': '20260404143000001',
'pay_applydate': '2026-04-04 14:30:00',
'pay_notifyurl': 'https://merchant.com/notify',
'pay_callbackurl': 'https://merchant.com/callback',
'pay_amount': '500.00',
}
params['pay_sign'] = create_sign(params, 'your_secret_key')
resp = requests.post('%%BASE_URL%%/api/v1/gateway/pay', data=params)
print(resp.json())Node.js
const crypto = require('crypto');
const axios = require('axios');
function createSign(params, key) {
const filtered = Object.entries(params)
.filter(([k, v]) => !['pay_sign', 'sign'].includes(k) && v !== '')
.sort(([a], [b]) => a.localeCompare(b));
const stringA = filtered.map(([k, v]) => `${k}=${v}`).join('&');
return crypto.createHmac('sha256', key)
.update(stringA, 'utf8').digest('hex').toUpperCase();
}
const params = {
pay_memberid: '100001',
pay_orderid: '20260404143000001',
pay_applydate: '2026-04-04 14:30:00',
pay_notifyurl: 'https://merchant.com/notify',
pay_callbackurl: 'https://merchant.com/callback',
pay_amount: '500.00',
};
params.pay_sign = createSign(params, 'your_secret_key');
axios.post('%%BASE_URL%%/api/v1/gateway/pay', new URLSearchParams(params))
.then(res => console.log(res.data));Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"net/url"
"sort"
"strings"
)
// CreateSign 生成签名
func CreateSign(params map[string]string, key string) string {
// 1. 过滤空值和签名字段,按 key 排序
keys := make([]string, 0)
for k, v := range params {
if k == "pay_sign" || k == "sign" || v == "" {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
// 2. 拼接参数
pairs := make([]string, 0, len(keys))
for _, k := range keys {
pairs = append(pairs, k+"="+params[k])
}
stringA := strings.Join(pairs, "&")
// 3. 计算签名
mac := hmac.New(sha256.New, []byte(key))
mac.Write([]byte(stringA))
return strings.ToUpper(hex.EncodeToString(mac.Sum(nil)))
}
func main() {
params := map[string]string{
"pay_memberid": "100001",
"pay_orderid": "20260404143000001",
"pay_applydate": "2026-04-04 14:30:00",
"pay_notifyurl": "https://merchant.com/notify",
"pay_callbackurl": "https://merchant.com/callback",
"pay_amount": "500.00",
}
secretKey := "your_secret_key"
params["pay_sign"] = CreateSign(params, secretKey)
// 发送请求
form := url.Values{}
for k, v := range params {
form.Set(k, v)
}
resp, err := http.PostForm("%%BASE_URL%%/api/v1/gateway/pay", form)
if err != nil {
fmt.Println("请求失败:", err)
return
}
defer resp.Body.Close()
buf := make([]byte, 4096)
n, _ := resp.Body.Read(buf)
fmt.Println("响应:", string(buf[:n]))
}回调验签示例 (Node.js)
// 生产环境用 Redis 存储已处理的 nonce,TTL 24小时
const processedNonces = new Set();
app.post('/notify', async (req, res) => {
const { sign, ...signParams } = req.body;
// 1. 防重放:校验时间戳
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(signParams.timestamp)) > 300) {
return res.send('FAIL'); // 超过5分钟,可能是重放
}
// 2. 防重复:校验 nonce
if (processedNonces.has(signParams.nonce)) {
return res.send('OK'); // 已处理过,直接返回OK
}
// 3. 验签
const calculated = createSign(signParams, MERCHANT_KEY, 'HMAC-SHA256');
if (calculated !== sign) {
return res.send('FAIL'); // 签名不匹配
}
// 4. 验证交易状态
if (signParams.returncode !== '00') {
return res.send('FAIL');
}
// 5. 幂等:检查订单是否已处理
const order = await getOrder(signParams.orderid);
if (order.status === 'paid') {
processedNonces.add(signParams.nonce);
return res.send('OK');
}
// 6. 处理业务逻辑
await updateOrderStatus(signParams.orderid, 'paid', {
transactionId: signParams.transaction_id,
amount: signParams.amount,
paidAt: signParams.datetime,
});
// 7. 记录 nonce + 返回OK
processedNonces.add(signParams.nonce);
res.send('OK');
});八、常见问题
Q1: 签名一直不通过?
- 确认参与签名的字段是否正确(参见各接口的「参与签名」列)
- 确认排序是否按参数名 ASCII 码升序(不是按值排序)
- 确认空值参数是否已排除
- HMAC-SHA256:密钥是 HMAC 的 key,不拼在字符串里
- 确认结果已转为大写
- 打印签名前的拼接字符串,逐字符核对
Q2: 回调一直收不到?
- 检查
pay_notifyurl是否可从外网访问 - 检查服务器防火墙是否放行了平台IP
- 确认回调接口返回的是纯文本
OK,而非JSON或HTML - 主动调用订单查询接口确认订单状态
Q3: 下单返回"暂无可用通道"?
- 当前通道可能繁忙,请稍后重试
- 如传入了
pay_bankcode,确认编码是否正确 - 确认该通道是否已在商户后台启用
- 联系平台确认通道状态
Q4: 代付下单返回"余额不足"?
代付需要商户预先充值。所需余额 = 付款金额 + 手续费(百分比 + 固定费用)。
例如:付款 1000,费率 3%+2,则需余额 ≥ 1000 + 30 + 2 = 1032。
Q5: 订单超时了但用户已付款怎么办?
联系平台客服进行补单操作,平台确认到账后会重新回调通知。
附录:错误响应说明
所有错误响应格式统一为:
{
"code": 1003,
"message": "auth_failed",
"data": null,
"timestamp": 1776425678123,
"request_id": "b6554358-fd6e-4205-bdf9-ed085e3d2303"
}
业务码 vs HTTP 状态码
响应体里的
code 是业务码,HTTP status 才是真实的 HTTP 状态码。两者不一定相等(例如身份验证失败:HTTP status = 403,但业务 code = 1003)。商户程序应判断响应体 code 识别业务错误类型,不要用 HTTP status 判断细分业务错误。
message 为错误代码,request_id 为每个请求的唯一追踪 ID。商户遇到问题时提供此 request_id 给客服可以快速定位。常见错误如下:
| HTTP 状态码 | 业务 code | message | 原因 |
|---|---|---|---|
| 400 | 400 | 金额无效 | 金额为0、负数或非数字 |
| 400 | 400 | 金额须在 X - Y 之间 | 金额超出商户配置的范围 |
| 400 | 400 | xxx 必须是字符串 / 不能为空 | 必填参数缺失或类型错误 |
| 400 | 400 | 不允许的字段: xxx | 传入了接口未定义的参数 |
| 400 | 400 | 商户余额不足 | 代付下单时商户可用余额 < (付款金额 + 手续费);通过 /api/v1/gateway/balance 查当前余额 |
| 403 | 1003 | auth_failed | 身份验证失败(商户号不存在、缺少商户号、签名错误、IP 不在白名单等统一返回此消息) |
| 403 | 403 | 商户已禁用 | 商户被平台禁用 |
| 403 | 403 | 商户代收功能未开启 | 商户后台未开启代收 |
| 403 | 403 | 商户代付功能未开启 | 商户后台未开启代付 |
| 404 | 404 | 订单不存在 | 查询的订单号不存在 |
| 404 | 404 | 暂无可用通道 | 无匹配的收/付款产品 |
| 404 | 404 | no_channel_available | 找不到金额范围匹配的收款通道(代收)或银行卡(代付) |
| 404 | 404 | no_test_channel_available | 测试模式商户专属错误,测试环境没有可用通道 |
| 429 | 429 | 请求过于频繁,请稍后再试 | 超出该商户该接口的频率限制(按商户号独立计数) |
| 500 | 500 | 服务器内部错误 | 平台异常,请联系客服 |
身份验证错误统一化
为防止商户号枚举攻击,身份验证类错误统一返回
auth_failed(业务 code = 1003),不区分具体原因(商户不存在 / 缺少商户号 / 签名错误 / IP 白名单不通过等)。如需排查请提供 request_id 联系客服。
对接建议
商户对接时应优先判断响应体
code(业务码),200 为成功,其他均为失败。message 中的错误代码(如 auth_failed)为稳定标识符,可作为程序判断依据;中文错误描述内容可能调整。