异步回调接入指南
平台在代收/代付订单进入 终态 completed 后,会向商户配置的地址发起 HTTP POST 通知。本文说明商户服务端收到回调后应完成的校验、业务处理与应答规范。
不是解密,是验签。 回调 Body 为明文 JSON,平台使用 RSA2 签名 保证来源与完整性。商户使用 平台公钥 验证
X-Sign,无需、也无法 用公钥「解密」Body。
密钥分工
| 方向 | 谁持有私钥 | 谁验签 |
|---|---|---|
| 商户 → 平台(调 Open API) | 商户 | 平台用 商户公钥 |
| 平台 → 商户(异步回调) | 平台 | 商户用 平台公钥 |
平台公钥 获取方式:
- 商户门户:商户资料 → 安全与密钥 → 平台 RSA 公钥(或
GET /portal/profile的platformPublicKey) - 商户门户 API 调试 → 回调验签:自动加载平台公钥,可粘贴回调 Header + Body 本地验签
- 或向 SoPay 运营索取 PEM
请使用完整 PEM
验签须使用门户返回的 整段 PEM(含 BEGIN/END 行),不要仅凭指纹或截断内容验签。平台轮换公钥后请重新拉取 profile。
商户向平台提交的是 商户公钥(可在 商户资料 → API 配置 自行更新);商户私钥仅用于签名出站请求,不要 交给平台。
何时会收到回调
| 订单类型 | 触发条件 | 回调地址 |
|---|---|---|
| 代收 payin | status 变为 completed | 创建订单时的 notifyUrl;为空则不推送 |
| 代付 payout | status 变为 completed | 创建订单时的 notifyUrl;为空则用商户默认 callbackUrl |
notifyUrl 与上游无关
商户传入的 notifyUrl 仅 用于接收 SoPay 平台 的完成通知,平台 不会 将该地址转发给上游渠道。上游只回调平台;平台验签后再通知商户。
以下情况 不会 推送完成回调(请用查询接口轮询或主动查询):
- 订单
failed、cancelled等非completed终态 - 代付任务取消/过期导致订单取消
- 未配置有效回调 URL
同一笔订单可能 重复通知(网络超时、商户未及时返回 OK 等),必须按 merchantOrderNo 或 orderNo 幂等 处理。
回调 HTTP 格式
| 项目 | 说明 |
|---|---|
| 方法 | POST |
Content-Type | application/json |
| Body | UTF-8 JSON,字段见下文 |
请求头
| Header | 说明 |
|---|---|
X-Merchant-No | 商户号,须与本商户一致 |
X-Timestamp | Unix 时间戳,秒 |
X-Nonce | 随机唯一串(每次回调不同) |
X-Sign | 平台 RSA2 签名,Base64 |
Body 字段(代收 / 代付通用)
| 字段 | 类型 | 说明 |
|---|---|---|
orderType | string | payin 或 payout |
orderNo | string | 平台订单号 |
merchantOrderNo | string | 商户订单号 |
status | string | 完成回调固定为 completed |
amount | string | 订单金额 |
feeAmount | string | 手续费 |
netAmount | string | 代收:入账净额;代付:扣款总额(含费) |
completedAt | number | 完成时间,Unix 秒(时间戳) |
商户 Open API 查询/创建响应中的 createdAt、completedAt,以及管理后台/门户订单列表中的时间字段,均使用 Unix 秒时间戳(JSON number)。
代收示例:
{
"orderType": "payin",
"orderNo": "PI20260608120000123456",
"merchantOrderNo": "PAYIN20260608001",
"status": "completed",
"amount": "2000.00",
"feeAmount": "20.00",
"netAmount": "1980.00",
"completedAt": 1718198400
}代付示例:
{
"orderType": "payout",
"orderNo": "PO20260608120000987654",
"merchantOrderNo": "PAYOUT20260608001",
"status": "completed",
"amount": "1000.00",
"feeAmount": "10.00",
"netAmount": "1010.00",
"completedAt": 1718198400
}商户处理流程(必做)
建议按以下顺序实现回调接口:
flowchart TD
A[收到 POST 回调] --> B[读取 Header 与 JSON Body]
B --> C{X-Merchant-No 是否为本商户?}
C -->|否| R1[返回非 OK / 记录告警]
C -->|是| D[可选: 校验 timestamp 时效]
D --> E[可选: nonce 去重]
E --> F[扁平化 Body + timestamp + nonce 拼待签名字符串]
F --> G{平台公钥验签 X-Sign}
G -->|失败| R2[拒绝处理 返回非 OK]
G -->|通过| H[按 orderNo/merchantOrderNo 幂等更新订单]
H --> I{业务处理成功?}
I -->|是| J[HTTP 200 + Body: OK]
I -->|否| R3[返回非 OK 等待平台重试]1. 读取请求
- 保留原始 Body 字节流用于日志(注意脱敏)
- 解析 JSON 得到业务字段
- 读取
X-Merchant-No、X-Timestamp、X-Nonce、X-Sign
2. 校验商户号
X-Merchant-No 必须等于本商户的 merchantNo,否则直接拒绝。
3. 校验时间戳(建议)
与调 API 时相同:|now - timestamp| 不应超过商户配置的 requestExpireSeconds(默认 300 秒),防止重放旧回调。
4. Nonce 去重(建议)
同一 nonce 在有效期内只处理一次(可用 Redis 或本地缓存,TTL 建议 ≥ 10 分钟)。
5. 验签(必须)
规则与 签名与商户 API 规范 完全一致:
- 将 Body 顶层 JSON 字段扁平化为
map[string]string(规则见下节 「Body 扁平化」) - 加入
timestamp、nonce(来自 Header,键名小写) - 排除键名
sign与空值字段 - 按 key ASCII 升序拼接:
key1=value1&key2=value2&... - 使用 平台公钥 对
X-Sign做 RSA2 验签(SHA256withRSA+ Base64)
常见误解
- ❌ 用平台公钥「解密」Body — 回调未加密,无此步骤
- ❌ 用商户公钥验签 — 应使用 平台公钥
- ❌ 验签失败仍更新订单 — 必须先验签通过
Body 扁平化(与平台一致)
| JSON 值类型 | 扁平化结果 |
|---|---|
string | 原样;空字符串 不参与 签名 |
number(如 completedAt) | 十进制整数字符串,如 1782121882(不使用科学计数法) |
object / array | JSON.stringify 后的字符串 |
null / 缺失 | 不参与签名 |
amount、feeAmount、netAmount 等为 字符串,原样参与签名。
联调建议
使用门户 API 调试 → 回调验签 对比「待验签字符串」。
完整验签示例(代收完成,含 completedAt)
Body:
{
"amount": "2000",
"completedAt": 1782121882,
"feeAmount": "6",
"merchantOrderNo": "h7z3jskx7h7j8j",
"netAmount": "1994",
"orderNo": "PI202606220949069037e0",
"orderType": "payin",
"status": "completed"
}Header:
| Header | 值 |
|---|---|
X-Timestamp | 1782121883 |
X-Nonce | e63b411b-f379-4e69-9de8-d58c192d3e90 |
待签名字符串:
amount=2000&completedAt=1782121882&feeAmount=6&merchantOrderNo=h7z3jskx7h7j8j&netAmount=1994&nonce=e63b411b-f379-4e69-9de8-d58c192d3e90&orderNo=PI202606220949069037e0&orderType=payin&status=completed×tamp=1782121883完整验签示例(无 completedAt)
Body:
{
"amount": "2000",
"feeAmount": "6",
"merchantOrderNo": "73v4w3hzcrmgxn",
"netAmount": "1994",
"orderNo": "PI202606221004165831b8",
"orderType": "payin",
"status": "created"
}Header: timestamp=1782123952,nonce=285df7f0-282e-451f-ad9a-41e978194ef7
待签名字符串:
amount=2000&feeAmount=6&merchantOrderNo=73v4w3hzcrmgxn&netAmount=1994&nonce=285df7f0-282e-451f-ad9a-41e978194ef7&orderNo=PI202606221004165831b8&orderType=payin&status=created×tamp=1782123952此例无 completedAt。若仍验签失败,请检查 平台公钥 PEM 是否为 profile 最新值。
6. 幂等业务处理(必须)
验签通过后:
- 根据
merchantOrderNo(或orderNo)查找本地订单 - 若已为
completed,直接返回成功(幂等) - 校验
amount、currency、orderType与本地一致 - 更新本地订单状态为完成,触发发货/记账等后续逻辑
7. 应答平台(必须)
仅当 验签通过且业务处理成功 后:
| 项目 | 要求 |
|---|---|
| HTTP 状态码 | 200 |
| 响应 Body | 纯文本 OK(首尾无多余字符,大小写敏感) |
建议 Content-Type | text/plain; charset=utf-8 |
示例(伪代码):
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
OK以下均视为 失败,平台将 重试:
- 非 200 状态码
- Body 不是精确的
OK(如ok、{"code":0}、OK\n均不合格) - 连接超时、5xx 等
重试策略
验签失败或业务未成功时,不要 返回 OK。平台按退避重试,直至成功或达到上限:
| 已尝试次数 | 下次重试间隔 |
|---|---|
| 1–10 次 | 约 30 秒 |
| 11–50 次 | 约 5 分钟 |
| 51 次及以上 | 约 30 分钟 |
最多约 200 次。长期未 ACK 的任务可在运营后台查看回调任务与日志。
验签代码示例
Go(与平台 FlattenJSONMap 一致)
import (
"crypto"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"math"
"sort"
"strconv"
"strings"
)
// flattenCallbackBody 与平台 outbound 回调签名使用的扁平化规则一致。
func flattenCallbackBody(body map[string]any) map[string]string {
out := make(map[string]string)
for k, v := range body {
if v == nil {
continue
}
switch x := v.(type) {
case string:
if strings.TrimSpace(x) != "" {
out[k] = x
}
case json.Number:
out[k] = x.String()
case float64:
out[k] = formatFlattenNumber(x)
case float32:
out[k] = formatFlattenNumber(float64(x))
case int, int8, int16, int32, int64:
out[k] = fmt.Sprint(x)
case uint, uint8, uint16, uint32, uint64:
out[k] = fmt.Sprint(x)
case bool:
out[k] = strconv.FormatBool(x)
case map[string]any, []any:
b, err := json.Marshal(x)
if err == nil && len(b) > 0 && string(b) != "null" {
out[k] = string(b)
}
default:
s := strings.TrimSpace(fmt.Sprint(v))
if s != "" && s != "<nil>" {
out[k] = s
}
}
}
return out
}
func formatFlattenNumber(n float64) string {
if math.IsNaN(n) || math.IsInf(n, 0) {
return strconv.FormatFloat(n, 'f', -1, 64)
}
if n == math.Trunc(n) {
return strconv.FormatInt(int64(n), 10)
}
return strconv.FormatFloat(n, 'f', -1, 64)
}
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 verifyPlatformSign(platformPublicKeyPEM, signBase64, content string) error {
block, _ := pem.Decode([]byte(platformPublicKeyPEM))
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return err
}
rsaPub := pub.(*rsa.PublicKey)
sig, err := base64.StdEncoding.DecodeString(strings.TrimSpace(signBase64))
if err != nil {
return err
}
sum := sha256.Sum256([]byte(content))
return rsa.VerifyPKCS1v15(rsaPub, crypto.SHA256, sum[:], sig)
}
func verifyCallback(platformPublicKeyPEM, timestamp, nonce, sign string, body map[string]any) error {
params := flattenCallbackBody(body)
params["timestamp"] = timestamp
params["nonce"] = nonce
content := buildSignContent(params)
return verifyPlatformSign(platformPublicKeyPEM, sign, content)
}JavaScript(Node / 浏览器 Web Crypto)
function flattenCallbackBody(raw) {
const out = {}
for (const [k, v] of Object.entries(raw)) {
if (v === null || v === undefined) continue
if (typeof v === 'string') {
if (v.trim()) out[k] = v
} else if (typeof v === 'number') {
out[k] = Number.isInteger(v) ? String(v) : String(v)
} else if (typeof v === 'boolean') {
out[k] = v ? 'true' : 'false'
} else {
const json = JSON.stringify(v)
if (json && json !== 'null') out[k] = json
}
}
return out
}
function buildSignContent(params) {
return Object.keys(params)
.filter((k) => k.toLowerCase() !== 'sign' && String(params[k] ?? '').trim() !== '')
.sort()
.map((k) => `${k}=${params[k]}`)
.join('&')
}
async function verifyCallback(platformPublicKeyPem, timestamp, nonce, signBase64, bodyJson) {
const flat = flattenCallbackBody(bodyJson)
flat.timestamp = timestamp
flat.nonce = nonce
const content = buildSignContent(flat)
const key = await crypto.subtle.importKey(
'spki',
pemToDer(platformPublicKeyPem),
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
false,
['verify'],
)
const sig = Uint8Array.from(atob(signBase64.replace(/\s+/g, '')), (c) => c.charCodeAt(0))
const data = new TextEncoder().encode(content)
return crypto.subtle.verify('RSASSA-PKCS1-v1_5', key, sig, data)
}Python
import base64, json
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
def fmt_sprint_float(n: float) -> str:
"""与 Go fmt.Sprint(float64) 对齐,用于 completedAt 等大整数。"""
if n == 0:
return '0'
if float(n).is_integer() and abs(n) >= 1_000_000:
if abs(n) == 1_000_000:
return '1e+06'
s = f'{n:.7e}'
mantissa, exp = s.split('e')
m = str(float(mantissa))
e = int(exp)
exp_part = f'+{e:02d}' if e >= 0 else str(e)
sign = '-' if n < 0 else ''
return f'{sign}{m}e{exp_part}'
return str(int(n)) if float(n).is_integer() else str(n)
def flatten_callback_body(raw: dict) -> dict:
out = {}
for k, v in raw.items():
if v is None:
continue
if isinstance(v, str):
if v.strip():
out[k] = v
elif isinstance(v, bool):
out[k] = 'true' if v else 'false'
elif isinstance(v, (int, float)):
out[k] = fmt_sprint_float(float(v))
else:
out[k] = json.dumps(v, separators=(',', ':'), ensure_ascii=False)
return out
def build_sign_content(params: dict) -> str:
items = [(k, params[k]) for k in sorted(params)
if k.lower() != 'sign' and str(params[k]).strip()]
return '&'.join(f'{k}={v}' for k, v in items)
def verify_callback(public_pem: str, timestamp: str, nonce: str, sign_b64: str, body: dict) -> bool:
params = flatten_callback_body(body)
params['timestamp'] = timestamp
params['nonce'] = nonce
content = build_sign_content(params)
pub = serialization.load_pem_public_key(public_pem.encode())
sig = base64.b64decode(sign_b64.strip())
pub.verify(sig, content.encode(), padding.PKCS1v15(), hashes.SHA256())
return True数字字段
completedAt 为 Unix 秒(JSON number)。验签时扁平化为十进制整数字符串,如 1782121882。Open API 查询/创建响应中的 createdAt、completedAt 同样为 Unix 秒时间戳。
安全与运维建议
| 项 | 建议 |
|---|---|
| HTTPS | 生产 notifyUrl / callbackUrl 必须使用 HTTPS |
| 防火墙 | 若回调服务有 IP 限制,向运营索取 SoPay 回调出口 IP 并加白 |
| 日志 | 记录 orderNo、merchantOrderNo、验签结果;勿记录完整私钥 |
| 幂等 | 数据库对 merchantOrderNo 建唯一约束或状态机防重复入账 |
| 查询兜底 | 回调丢失时定时调用「查询订单」接口对账 |
常见错误
| 现象 | 可能原因 |
|---|---|
| 验签始终失败 | 用了商户公钥而非 平台公钥;timestamp/nonce 未参与拼串;数字字段格式不一致 |
无 completedAt 仍失败 | 平台公钥 PEM 过期或与签名私钥不配对;请重新拉取 /portal/profile |
| 平台持续重试 | 未返回精确 OK;HTTP 非 200;业务处理抛错 |
| 收不到回调 | notifyUrl 为空或不可达;订单未到 completed |
| 重复通知 | 正常现象,幂等处理即可 |
更多错误码见各国 附录 · 错误码。
相关文档
- 签名与商户 API 规范 — RSA2 算法与出站请求签名
- 快速开始 — 接入清单
- 泰国 · 代收回调字段
- 泰国 · 代付回调字段
