簽章
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 計算,不要用 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"
}'