Skip to content

异步回调接入指南

平台在代收/代付订单进入 终态 completed 后,会向商户配置的地址发起 HTTP POST 通知。本文说明商户服务端收到回调后应完成的校验、业务处理与应答规范。

不是解密,是验签。 回调 Body 为明文 JSON,平台使用 RSA2 签名 保证来源与完整性。商户使用 平台公钥 验证 X-Sign无需、也无法 用公钥「解密」Body。

密钥分工

方向谁持有私钥谁验签
商户 → 平台(调 Open API)商户平台用 商户公钥
平台 → 商户(异步回调)平台商户用 平台公钥

平台公钥 获取方式:

  • 商户门户:商户资料 → 安全与密钥 → 平台 RSA 公钥(或 GET /portal/profileplatformPublicKey
  • 商户门户 API 调试 → 回调验签:自动加载平台公钥,可粘贴回调 Header + Body 本地验签
  • 或向 SoPay 运营索取 PEM

请使用完整 PEM

验签须使用门户返回的 整段 PEM(含 BEGIN/END 行),不要仅凭指纹或截断内容验签。平台轮换公钥后请重新拉取 profile。

商户向平台提交的是 商户公钥(可在 商户资料 → API 配置 自行更新);商户私钥仅用于签名出站请求,不要 交给平台。

何时会收到回调

订单类型触发条件回调地址
代收 payinstatus 变为 completed创建订单时的 notifyUrl;为空则不推送
代付 payoutstatus 变为 completed创建订单时的 notifyUrl;为空则用商户默认 callbackUrl

notifyUrl 与上游无关

商户传入的 notifyUrl 用于接收 SoPay 平台 的完成通知,平台 不会 将该地址转发给上游渠道。上游只回调平台;平台验签后再通知商户。

以下情况 不会 推送完成回调(请用查询接口轮询或主动查询):

  • 订单 failedcancelled 等非 completed 终态
  • 代付任务取消/过期导致订单取消
  • 未配置有效回调 URL

同一笔订单可能 重复通知(网络超时、商户未及时返回 OK 等),必须按 merchantOrderNoorderNo 幂等 处理。

回调 HTTP 格式

项目说明
方法POST
Content-Typeapplication/json
BodyUTF-8 JSON,字段见下文

请求头

Header说明
X-Merchant-No商户号,须与本商户一致
X-TimestampUnix 时间戳,
X-Nonce随机唯一串(每次回调不同)
X-Sign平台 RSA2 签名,Base64

Body 字段(代收 / 代付通用)

字段类型说明
orderTypestringpayinpayout
orderNostring平台订单号
merchantOrderNostring商户订单号
statusstring完成回调固定为 completed
amountstring订单金额
feeAmountstring手续费
netAmountstring代收:入账净额;代付:扣款总额(含费)
completedAtnumber完成时间,Unix (时间戳)

商户 Open API 查询/创建响应中的 createdAtcompletedAt,以及管理后台/门户订单列表中的时间字段,均使用 Unix 秒时间戳(JSON number)。

代收示例:

json
{
  "orderType": "payin",
  "orderNo": "PI20260608120000123456",
  "merchantOrderNo": "PAYIN20260608001",
  "status": "completed",
  "amount": "2000.00",
  "feeAmount": "20.00",
  "netAmount": "1980.00",
  "completedAt": 1718198400
}

代付示例:

json
{
  "orderType": "payout",
  "orderNo": "PO20260608120000987654",
  "merchantOrderNo": "PAYOUT20260608001",
  "status": "completed",
  "amount": "1000.00",
  "feeAmount": "10.00",
  "netAmount": "1010.00",
  "completedAt": 1718198400
}

各国字段含义与订单状态见对应文档:泰国代收泰国代付 等。

商户处理流程(必做)

建议按以下顺序实现回调接口:

mermaid
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-NoX-TimestampX-NonceX-Sign

2. 校验商户号

X-Merchant-No 必须等于本商户的 merchantNo,否则直接拒绝。

3. 校验时间戳(建议)

与调 API 时相同:|now - timestamp| 不应超过商户配置的 requestExpireSeconds(默认 300 秒),防止重放旧回调。

4. Nonce 去重(建议)

同一 nonce 在有效期内只处理一次(可用 Redis 或本地缓存,TTL 建议 ≥ 10 分钟)。

5. 验签(必须)

规则与 签名与商户 API 规范 完全一致

  1. 将 Body 顶层 JSON 字段扁平化为 map[string]string(规则见下节 「Body 扁平化」
  2. 加入 timestampnonce(来自 Header,键名小写)
  3. 排除键名 sign 与空值字段
  4. 按 key ASCII 升序拼接:key1=value1&key2=value2&...
  5. 使用 平台公钥X-Sign 做 RSA2 验签(SHA256withRSA + Base64)

常见误解

  • ❌ 用平台公钥「解密」Body — 回调未加密,无此步骤
  • ❌ 用商户公钥验签 — 应使用 平台公钥
  • ❌ 验签失败仍更新订单 — 必须先验签通过

Body 扁平化(与平台一致)

JSON 值类型扁平化结果
string原样;空字符串 不参与 签名
number(如 completedAt十进制整数字符串,如 1782121882(不使用科学计数法)
object / arrayJSON.stringify 后的字符串
null / 缺失不参与签名

amountfeeAmountnetAmount 等为 字符串,原样参与签名。

联调建议

使用门户 API 调试 → 回调验签 对比「待验签字符串」。

完整验签示例(代收完成,含 completedAt

Body:

json
{
  "amount": "2000",
  "completedAt": 1782121882,
  "feeAmount": "6",
  "merchantOrderNo": "h7z3jskx7h7j8j",
  "netAmount": "1994",
  "orderNo": "PI202606220949069037e0",
  "orderType": "payin",
  "status": "completed"
}

Header:

Header
X-Timestamp1782121883
X-Noncee63b411b-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&timestamp=1782121883

完整验签示例(无 completedAt

Body:

json
{
  "amount": "2000",
  "feeAmount": "6",
  "merchantOrderNo": "73v4w3hzcrmgxn",
  "netAmount": "1994",
  "orderNo": "PI202606221004165831b8",
  "orderType": "payin",
  "status": "created"
}

Header: timestamp=1782123952nonce=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&timestamp=1782123952

此例无 completedAt。若仍验签失败,请检查 平台公钥 PEM 是否为 profile 最新值。

6. 幂等业务处理(必须)

验签通过后:

  1. 根据 merchantOrderNo(或 orderNo)查找本地订单
  2. 若已为 completed,直接返回成功(幂等)
  3. 校验 amountcurrencyorderType 与本地一致
  4. 更新本地订单状态为完成,触发发货/记账等后续逻辑

7. 应答平台(必须)

仅当 验签通过且业务处理成功 后:

项目要求
HTTP 状态码200
响应 Body纯文本 OK(首尾无多余字符,大小写敏感)
建议 Content-Typetext/plain; charset=utf-8

示例(伪代码):

http
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 一致)

go
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)

javascript
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

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 查询/创建响应中的 createdAtcompletedAt 同样为 Unix 秒时间戳。

安全与运维建议

建议
HTTPS生产 notifyUrl / callbackUrl 必须使用 HTTPS
防火墙若回调服务有 IP 限制,向运营索取 SoPay 回调出口 IP 并加白
日志记录 orderNomerchantOrderNo、验签结果;勿记录完整私钥
幂等数据库对 merchantOrderNo 建唯一约束或状态机防重复入账
查询兜底回调丢失时定时调用「查询订单」接口对账

常见错误

现象可能原因
验签始终失败用了商户公钥而非 平台公钥timestamp/nonce 未参与拼串;数字字段格式不一致
completedAt 仍失败平台公钥 PEM 过期或与签名私钥不配对;请重新拉取 /portal/profile
平台持续重试未返回精确 OK;HTTP 非 200;业务处理抛错
收不到回调notifyUrl 为空或不可达;订单未到 completed
重复通知正常现象,幂等处理即可

更多错误码见各国 附录 · 错误码

相关文档

基于 MIT 许可证发布。

2-1-2 Nihonbashi-Hongokucho,Chuo-ku,Tokyo