商户 API 对接文档

版本 v1.5 · 最后更新:2026-04-18(文档与代码一致性修正:pay_url 格式、回调字段、错误响应 code 字段、amount 类型)

网关地址: 文档中所有接口地址会自动更新

一、接入准备

1.1 获取密钥

1登录商户后台
2进入「API管理」页面
3获取以下信息:
参数说明
商户号 memberid平台分配的6位商户号
商户密钥 key用于签名的密钥,请妥善保管
网关地址 gatewayAPI请求的基础URL

1.2 对接须知

项目说明
请求方式所有接口均使用 POST
请求格式application/x-www-form-urlencodedmultipart/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_timestamppay_nonce 防重放。v1.4 起已移除此要求,但服务端仍接受这两个字段以兼容旧客户端(带了也会一起参与 HMAC 计算)。新商户对接不需要提供。
回调 URL 限制(SSRF 防护) pay_notifyurl 必须是公网可达httphttps 地址。平台会拒绝私有/保留 IP(127.0.0.0/810.0.0.0/8172.16.0.0/12192.168.0.0/16169.254.0.0/16 云厂商元数据 endpoint)及非 http(s) 协议。若回调域名解析到私有 IP,订单 callback_status 会标记失败,不发 HTTP 请求。

二、签名算法

平台使用 HMAC-SHA256 签名算法:

签名类型安全等级说明
HMAC-SHA25664位签名

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_examplestringA 做 HMAC-SHA256 后转大写得出。商户可在本地用相同参数计算并对照此值,验证签名函数实现是否正确。
注意事项 值为空的参数不参与签名 · 签名字段本身不参与签名 · 中文参数不需要URL编码后再签名 · 参数值前后不要有空格

三、代收 API

3.1 代收下单

商户发起收款请求,平台匹配可用通道并返回收款信息。

POST{网关地址}/api/v1/gateway/pay

请求参数

参数名含义必填签名说明
pay_memberid商户号YY平台分配的商户号
pay_orderid商户订单号YY商户系统唯一订单号,最长50字符
pay_applydate提交时间YY商户系统当前时间,格式:YYYY-MM-DD HH:mm:ss,任意时区均可,仅参与签名
pay_bankcode通道编码NY支付通道编码,不传则使用默认通道,参见通道编码表
pay_notifyurl服务端通知地址YY支付成功后,平台通过POST异步通知此地址,必须为外网可访问的URL
pay_callbackurl页面跳转地址YY支付成功后,收银台页面自动跳转至此URL(5秒倒计时)
pay_amount订单金额YY保留2位小数,如 100.00
pay_ip付款人IPNN付款人的真实IP地址
pay_sign签名Y-签名值,参见签名算法
pay_attach附加数据NN商户自定义透传数据,回调时原样返回(用于商户对账/追踪),不在平台后台展示
pay_userid会员IDNN商户系统内的用户唯一标识
pay_username付款人姓名NN转卡或银联通道必填

请求示例

{
    "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} 由平台全局响应拦截器包装,所有接口一致。
参数说明
codeHTTP状态码,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_idpay_urlremaining_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业务 codemessage原因
4031003auth_failed身份验证失败(商户号/签名/IP 白名单等,统一返回以防枚举)
403403商户已禁用商户被平台禁用
403403商户代收功能未开启商户未开启收款功能
400400金额须在 X - Y 之间金额低于最小或超过最大限额
404404暂无可用通道当前无匹配的收款产品
429429请求过于频繁,请稍后再试超出该商户该接口的频率限制(按商户号独立计数)

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回调时间戳YUnix时间戳(秒),用于防重放校验
nonce随机串YUUID 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. 回调可能重复发送,商户需做好幂等处理

3.3 代收订单查询

主动查询代收订单的支付状态。

POST{网关地址}/api/v1/gateway/query

请求参数

参数名含义必填签名说明
pay_memberid商户号YY平台分配的商户号
pay_orderid商户订单号YY需要查询的商户订单号
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请求状态Y00=查询成功 / 01=未支付。仅表示查询请求状态,不代表已支付
data.trade_state交易状态YNOTPAY=待支付 / 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商户号YY平台分配的商户号
pay_orderid商户订单号YY商户系统唯一订单号,最长50字符
pay_applydate提交时间YY商户系统当前时间,格式:YYYY-MM-DD HH:mm:ss,仅参与签名
pay_bankcode通道编码NY付款通道编码,不传则使用默认通道,参见通道编码表
pay_notifyurl服务端通知YY付款完成后,平台通过POST异步通知此地址,必须为外网可访问的URL
pay_amount付款金额YY保留2位小数
pay_callbackurl页面跳转地址NN付款完成后的前端跳转地址
pay_bank_name收款银行YYVietcombankTechcombankBIDVVPBankPGbank
pay_account_name收款人姓名YY收款方真实姓名
pay_account_no收款账号YY收款方银行卡号
pay_sign签名Y-签名值
pay_attach附加数据NN原样返回

请求示例

{
    "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
}
参数说明
codeHTTP状态码,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_idamountfee 等字段),因此商户可以安全地重试失败的请求。

常见错误

HTTP 状态码message原因
400商户余额不足代付下单时商户可用余额 < (付款金额 + 手续费);通过 /api/v1/gateway/balance 查当前余额
404no_channel_available找不到金额范围匹配的银行卡(代付无可用收款卡)
404no_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回调时间戳YUnix时间戳(秒),用于防重放校验
nonce随机串YUUID 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商户号YY
pay_orderid商户订单号YY
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请求状态Y00=查询成功 / 01=未完成
data.trade_state交易状态YNOTPAY=待付款 / SUCCESS=已付款 / CANCELLED=已取消 / EXPIRED=已过期
data.proof_image_url付款凭证图片 URLY代付成功后运营上传的转账凭证截图;未上传时为空字符串。
当平台未配置 PUBLIC_ASSETS_URL 时,此字段可能是相对路径(如 /uploads/xxx.png),商户需要自行拼接 {网关域名} 前缀。配置完整时是绝对 URL(如 https://romapay.cc/uploads/xxx.png
data.sign签名-平台对响应参数的签名,商户可验签确认合法性

五、余额查询

POST{网关地址}/api/v1/gateway/balance

请求参数

参数名含义必填签名
pay_memberid商户号YY
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
}
参数说明
codeHTTP状态码,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: 签名一直不通过?

  1. 确认参与签名的字段是否正确(参见各接口的「参与签名」列)
  2. 确认排序是否按参数名 ASCII 码升序(不是按值排序)
  3. 确认空值参数是否已排除
  4. HMAC-SHA256:密钥是 HMAC 的 key,不拼在字符串里
  5. 确认结果已转为大写
  6. 打印签名前的拼接字符串,逐字符核对

Q2: 回调一直收不到?

  1. 检查 pay_notifyurl 是否可从外网访问
  2. 检查服务器防火墙是否放行了平台IP
  3. 确认回调接口返回的是纯文本 OK,而非JSON或HTML
  4. 主动调用订单查询接口确认订单状态

Q3: 下单返回"暂无可用通道"?

  1. 当前通道可能繁忙,请稍后重试
  2. 如传入了 pay_bankcode,确认编码是否正确
  3. 确认该通道是否已在商户后台启用
  4. 联系平台确认通道状态

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 状态码业务 codemessage原因
400400金额无效金额为0、负数或非数字
400400金额须在 X - Y 之间金额超出商户配置的范围
400400xxx 必须是字符串 / 不能为空必填参数缺失或类型错误
400400不允许的字段: xxx传入了接口未定义的参数
400400商户余额不足代付下单时商户可用余额 < (付款金额 + 手续费);通过 /api/v1/gateway/balance 查当前余额
4031003auth_failed身份验证失败(商户号不存在、缺少商户号、签名错误、IP 不在白名单等统一返回此消息)
403403商户已禁用商户被平台禁用
403403商户代收功能未开启商户后台未开启代收
403403商户代付功能未开启商户后台未开启代付
404404订单不存在查询的订单号不存在
404404暂无可用通道无匹配的收/付款产品
404404no_channel_available找不到金额范围匹配的收款通道(代收)或银行卡(代付)
404404no_test_channel_available测试模式商户专属错误,测试环境没有可用通道
429429请求过于频繁,请稍后再试超出该商户该接口的频率限制(按商户号独立计数)
500500服务器内部错误平台异常,请联系客服
身份验证错误统一化 为防止商户号枚举攻击,身份验证类错误统一返回 auth_failed(业务 code = 1003),不区分具体原因(商户不存在 / 缺少商户号 / 签名错误 / IP 白名单不通过等)。如需排查请提供 request_id 联系客服。
对接建议 商户对接时应优先判断响应体 code(业务码),200 为成功,其他均为失败。message 中的错误代码(如 auth_failed)为稳定标识符,可作为程序判断依据;中文错误描述内容可能调整。