签名
API 请求、API 响应与 Callback 使用同一套签名规则(HMAC-SHA256)。
计算步骤
- 取请求体中除
sign以外的所有字段,排除值为null的字段。空字符串""不排除。 - 按字段名的字符编码(ASCII)从小到大排序。
- 每个字段写成
字段名=字段值,用&拼接成一个字符串(以下称为“待签名字符串”)。字段值按原样放入,不做 URL 编码,也不去除空白字符。 - 以 API 验证密钥为 key,对待签名字符串的 UTF-8 字节计算 HMAC-SHA256。
- 将结果转换为小写十六进制字符串(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_identity | qp_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×tamp=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"
}'