签名与商户 API 规范
本文说明 SoPay 商户对接 API 的通用约定、RSA2 签名与异步回调验签,适用于全部国家/地区接入。
当前生产网关基于 REST JSON + Header 签名,不再使用旧版
appId/bizContent信封结构。
基础约定
| 项目 | 说明 |
|---|---|
| API 前缀 | /api/v1/merchant |
| 字符编码 | UTF-8 |
| 金额 | 字符串或 JSON number,最多 2 位小数,必须大于 0 |
| 国家/币种 | countryCode、currency 须与商户开户国家配置一致(如泰国 TH + THB) |
通用响应结构
json
{
"code": 0,
"msg": "ok",
"data": {}
}| 字段 | 类型 | 说明 |
|---|---|---|
code | number | 0 表示成功 |
msg | string | 提示信息 |
data | object | 业务数据 |
失败时 code 非 0,msg 为可读错误说明(见各国附录 错误码)。
鉴权请求头(必填)
所有 /api/v1/merchant/* 接口均需在 HTTP Header 携带:
| Header | 类型 | 说明 |
|---|---|---|
X-Merchant-No | string | 商户号,开户后分配,如 M42 |
X-Timestamp | string | Unix 时间戳,秒 |
X-Nonce | string | 随机唯一串,防重放 |
X-Sign | string | RSA2 签名,Base64 编码 |
POST 请求另需:Content-Type: application/json
安全策略(开户默认)
| 配置项 | 默认值 | 说明 |
|---|---|---|
enableIpWhitelist | false | 为 true 时仅允许白名单 IP 调用 |
enableNonceCheck | true | 同一 nonce 在 TTL 内不可重复使用 |
requestExpireSeconds | 300 | 请求时间戳允许偏差(秒) |
RSA2 签名算法
- 算法:RSA PKCS#1 v1.5 + SHA-256(又称 RSA2)
- 密钥:2048 位及以上 RSA 密钥对
- 商户侧:保管 私钥,用于请求签名;向平台提交 公钥 PEM
- 平台侧:回调时使用 平台私钥 签名;商户使用 平台公钥 验签
待签名字符串
- 收集参与签名的参数:
- GET:URL Query 全部参数
- POST:JSON Body 顶层字段(
FlattenJSONMap:嵌套 object/array 会 JSON 序列化为字符串值)
- 将 Header 中的
timestamp、nonce加入参数 Map(键名小写:timestamp、nonce) - 排除:键名为
sign(不区分大小写)、值为空的字段 - 按参数名 ASCII 升序 排序
- 拼接:
key1=value1&key2=value2&...
示例(创建代付):
amount=1000.00&countryCode=TH¤cy=THB&merchantOrderNo=ORD20260608001&nonce=8f3a2c1b&receiverAccount=1234567890&receiverName=John×tamp=1718198400生成签名
signature = Base64( RSA-SHA256-Sign(merchant_private_key, UTF8(sign_content)) )将结果放入 Header X-Sign。
验签(商户收到平台回调时)
- 读取回调 Body JSON 与 Header
X-Timestamp、X-Nonce、X-Sign - 将 Body 顶层 字段扁平化为
map[string]string,加入timestamp、nonce - 按 key 字典序拼接
k=v&...得到sign_content - 使用 平台公钥 验证
X-Sign(验签,不是解密;Body 为明文 JSON)
数字字段: 回调中 completedAt 等为 JSON number(Unix 秒)。扁平化时为十进制整数字符串(如 1782121882)。金额字段为 string。详见 异步回调接入指南 · Body 扁平化。
重要
处理异步回调时 必须验签,验签通过且业务处理成功后再更新订单状态。
完整流程(幂等、应答、重试、代码示例)见 异步回调接入指南。
异步回调 ACK
平台 POST 到您配置的 notifyUrl(或商户默认 callbackUrl)后,仅在以下条件视为成功:
- HTTP 状态码 200
- 响应 Body 去首尾空白后 严格等于
OK(建议Content-Type: text/plain)
否则会按退避策略重试(最多约 200 次)。详见 异步回调接入指南。
代码示例(Go)
go
package main
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"sort"
"strings"
)
func buildSignContent(params map[string]string) string {
keys := make([]string, 0, len(params))
for k, v := range params {
if strings.EqualFold(k, "sign") || strings.TrimSpace(v) == "" {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, k+"="+params[k])
}
return strings.Join(parts, "&")
}
func signRSA2(privateKeyPEM, content string) (string, error) {
block, _ := pem.Decode([]byte(privateKeyPEM))
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
key2, err2 := x509.ParsePKCS1PrivateKey(block.Bytes)
if err2 != nil {
return "", err
}
key = key2
}
rsaKey := key.(*rsa.PrivateKey)
sum := sha256.Sum256([]byte(content))
sig, err := rsa.SignPKCS1v15(rand.Reader, rsaKey, crypto.SHA256, sum[:])
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(sig), nil
}
func main() {
params := map[string]string{
"merchantOrderNo": "ORD001",
"countryCode": "TH",
"currency": "THB",
"amount": "1000.00",
"receiverName": "John",
"receiverAccount": "1234567890",
"timestamp": "1718198400",
"nonce": "uuid-or-random",
}
content := buildSignContent(params)
sig, _ := signRSA2("-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----", content)
fmt.Println(sig)
}商户接入清单
- 向运营提交商户 RSA 公钥 PEM(2048 位)
- 获取
merchantNo、平台公钥(用于回调验签) - 配置代付默认
callbackUrl、手续费规则(运营后台) - 实现四类接口:代收、代付、查询、钱包(见各国文档)
- 代付前确保商户钱包余额 ≥ 冻结金额(
netAmount = amount + fee) - 回调接口返回
OK
