ST Pay 商户 APIST Pay 商户 API
指南
API
错误码
商户后台
AI 协助对接
  • 繁體中文
  • 简体中文
  • English
指南
API
错误码
商户后台
AI 协助对接
  • 繁體中文
  • 简体中文
  • English
  • 开始

    • 概览
    • 请求与响应格式
    • 身份验证与 IP 白名单
    • 签名
    • 商户后台
    • AI 辅助对接
  • API

    • 代收建单
    • 代付建单
    • 查单
    • Callback 通知
  • 参考

    • 订单状态与金额
    • 重复请求与结果确认
    • 错误码
    • 银行代码
    • 上线前检查清单

签名

API 请求、API 响应与 Callback 使用同一套签名规则(HMAC-SHA256)。

计算步骤

  1. 取请求体中除 sign 以外的所有字段,排除值为 null 的字段。空字符串 "" 不排除。
  2. 按字段名的字符编码(ASCII)从小到大排序。
  3. 每个字段写成 字段名=字段值,用 & 拼接成一个字符串(以下称为“待签名字符串”)。字段值按原样放入,不做 URL 编码,也不去除空白字符。
  4. 以 API 验证密钥为 key,对待签名字符串的 UTF-8 字节计算 HMAC-SHA256。
  5. 将结果转换为小写十六进制字符串(64 个字符),放入 sign 字段。

常见错误

  • 请直接将密钥以 UTF-8 字符串作为 key,不要做 Base64 解码(密钥看起来像 Base64,但不需要解码)。
  • 参与签名的值必须与 JSON 中实际发送的值完全相同。例如签名时用的是 1000.00,发送的却是 "1000",签名就会不一致。
  • 不要用 http_build_query、URLSearchParams、urlencode 之类的函数拼接字符串,它们会做 URL 编码。
  • 中文字段值(例如 payer_name)请以 UTF-8 计算,不要使用 GBK、Big5 等其他编码。
  • 签名覆盖请求体中的所有字段,包括本文档未列出的字段。请不要在签名之后再添加字段。
  • callback_url 也要参与签名,按原样放入待签字符串;即使网址的 query 含 ?、&、= 也不要编码。
  • timestamp 也要参与签名。请在发送请求当下取时间再签名,不要重复使用旧的请求;与 ST Pay 服务器时间相差超过 5 分钟会被拒绝(timestamp_expired)。

示例

以下使用示例凭证,您可以用它验证自己的程序是否计算出相同的结果(timestamp 为示意值,实际调用请使用当下时间):

项目示例值
api_identityqp_Xk3vT9aLm2QwE7rB1nZc
API 验证密钥Rk8vN2qXw5LpT9sJc3HmY7bF1dGz6AeU4oKi0Wn-QtE

要发送的字段(签名前):

{
  "api_identity": "qp_Xk3vT9aLm2QwE7rB1nZc",
  "merchant_order_no": "MO20261013-0001",
  "merchant_user_id": "U10086",
  "payer_name": "王小明",
  "amount": "1000.00",
  "channel_type": "fps",
  "response_mode": "url",
  "callback_url": "https://shop.example.com/qpay/callback",
  "timestamp": "2026-10-13 10:00:00"
}

排序并拼接后的待签名字符串:

amount=1000.00&api_identity=qp_Xk3vT9aLm2QwE7rB1nZc&callback_url=https://shop.example.com/qpay/callback&channel_type=fps&merchant_order_no=MO20261013-0001&merchant_user_id=U10086&payer_name=王小明&response_mode=url&timestamp=2026-10-13 10:00:00

HMAC-SHA256 结果(即 sign):

566fac06060d54d7450b41b6858b1ddf33df9d37117ab8a8c495ce748469f2b2

代码示例

以下示例均已使用上面的示例凭证验证,计算出的签名与上方结果一致。验证 Callback 时,请用收到的全部字段重新计算签名,再进行恒定时间比较。

Python 3
import hmac, hashlib

def qpay_sign(fields: dict, secret: str) -> str:
    items = sorted((k, v) for k, v in fields.items() if k != "sign" and v is not None)
    text = "&".join(f"{k}={v}" for k, v in items)
    return hmac.new(secret.encode("utf-8"), text.encode("utf-8"), hashlib.sha256).hexdigest()

# 验证 Callback
def qpay_verify(fields: dict, secret: str) -> bool:
    return hmac.compare_digest(qpay_sign(fields, secret), str(fields.get("sign", "")).lower())
Java 17+
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public final class QpaySigner {

    public static String sign(Map<String, String> fields, String secret) throws Exception {
        String text = new TreeMap<>(fields).entrySet().stream()
                .filter(e -> !"sign".equals(e.getKey()) && e.getValue() != null)
                .map(e -> e.getKey() + "=" + e.getValue())
                .collect(Collectors.joining("&"));
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        return HexFormat.of().formatHex(mac.doFinal(text.getBytes(StandardCharsets.UTF_8)));
    }

    /** 验证 Callback */
    public static boolean verify(Map<String, String> fields, String secret) throws Exception {
        String sign = fields.getOrDefault("sign", "");
        return MessageDigest.isEqual(sign(fields, secret).getBytes(StandardCharsets.UTF_8),
                sign.toLowerCase().getBytes(StandardCharsets.UTF_8));
    }
}
PHP 7.4+
<?php
function qpay_sign(array $fields, string $secret): string
{
    unset($fields['sign']);
    $fields = array_filter($fields, function ($v) { return $v !== null; });
    ksort($fields, SORT_STRING);
    $parts = [];
    foreach ($fields as $k => $v) {
        $parts[] = $k . '=' . $v;   // 不要用 http_build_query,它会做 URL 编码
    }
    return hash_hmac('sha256', implode('&', $parts), $secret);
}

// 验证 Callback
function qpay_verify(array $fields, string $secret): bool
{
    return hash_equals(qpay_sign($fields, $secret), strtolower((string) ($fields['sign'] ?? '')));
}
Node.js
const crypto = require('crypto');

function qpaySign(fields, secret) {
  const text = Object.keys(fields)
    .filter((k) => k !== 'sign' && fields[k] !== null && fields[k] !== undefined)
    .sort()
    .map((k) => `${k}=${fields[k]}`)
    .join('&');
  return crypto.createHmac('sha256', secret).update(text, 'utf8').digest('hex');
}

// 验证 Callback
function qpayVerify(fields, secret) {
  const expected = Buffer.from(qpaySign(fields, secret));
  const actual = Buffer.from(String(fields.sign || '').toLowerCase());
  return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
}
Go
import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"sort"
	"strings"
)

// 解析响应或 Callback 的 JSON 时也用 map[string]any:值为 null 的字段会是 nil,不纳入签名
func QpaySign(fields map[string]any, secret string) string {
	keys := make([]string, 0, len(fields))
	for k, v := range fields {
		if k != "sign" && v != nil {
			keys = append(keys, k)
		}
	}
	sort.Strings(keys)
	parts := make([]string, len(keys))
	for i, k := range keys {
		parts[i] = k + "=" + fields[k].(string)
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(strings.Join(parts, "&")))
	return hex.EncodeToString(mac.Sum(nil))
}

// 验证 Callback
func QpayVerify(fields map[string]any, secret string) bool {
	sign, _ := fields["sign"].(string)
	return hmac.Equal([]byte(QpaySign(fields, secret)), []byte(strings.ToLower(sign)))
}

验证响应

成功响应(code 为 OK)的 data 带有 sign,ST Pay 以本次请求使用的 API 验证密钥计算,规则同上:取 data 中 sign 以外、值不为 null 的所有字段。

  • 请先验签,通过后才使用响应内容,特别是代收建单返回的 account_no(付款人要转入的账号)与 payment_url。
  • 验签不通过表示响应可能在传输途中被篡改:不要把账号或网址交给付款人,改以查单确认。
  • 验签可直接使用上面的验证函数,传入响应的 data。
  • 错误响应的 data 为 null,不带签名。

完整调用示例

curl -X POST 'https://merchant.vincoterra.com/api/v1/collect/orders' \
  -H 'Content-Type: application/json' \
  -d '{
    "api_identity": "qp_Xk3vT9aLm2QwE7rB1nZc",
    "merchant_order_no": "MO20261013-0001",
    "merchant_user_id": "U10086",
    "payer_name": "王小明",
    "amount": "1000.00",
    "channel_type": "fps",
    "response_mode": "url",
    "callback_url": "https://shop.example.com/qpay/callback",
    "timestamp": "2026-10-13 10:00:00",
    "sign": "566fac06060d54d7450b41b6858b1ddf33df9d37117ab8a8c495ce748469f2b2"
  }'
Prev
身份验证与 IP 白名单
Next
商户后台