Signature
API requests, API responses, and Callbacks use the same signature rules (HMAC-SHA256).
How to compute the signature
- Take all fields in the body except
sign, and exclude fields whose value isnull. Empty strings""are not excluded. - Sort the fields by field name in ascending order of character code (ASCII).
- Write each field as
field_name=field_valueand join them with&into a single string (hereafter the "string to sign"). Insert field values exactly as they are: do not URL-encode them, and do not trim whitespace. - Compute HMAC-SHA256 over the UTF-8 bytes of the string to sign, using your API secret key as the key.
- Convert the result to a lowercase hexadecimal string (64 characters) and put it in the
signfield.
Common mistakes
- Use the secret key directly as a UTF-8 string key. Do not Base64-decode it. (The key looks like Base64, but it does not need to be decoded.)
- The values you sign must be exactly the same as the values actually sent in the JSON. For example, if you sign
1000.00but send"1000", the signature will not match. - Do not use functions such as
http_build_query,URLSearchParams, orurlencodeto build the string; they apply URL encoding. - Compute Chinese field values (such as
payer_name) in UTF-8, not in Big5 or any other encoding. - The signature covers every field in the body, including fields not listed in this document. Do not add any fields after signing.
callback_urlis also signed. Put it into the string to sign exactly as sent; do not encode it, even if its query string contains?,&, or=.timestampis also signed. Take the current time and sign right before sending; do not reuse old requests. A request that differs from the ST Pay server time by more than 5 minutes is rejected (timestamp_expired).
Example
The following uses a sample credential. You can use it to check whether your code produces the same result (timestamp is a sample value; use the current time in real calls):
| Item | Sample value |
|---|---|
api_identity | qp_Xk3vT9aLm2QwE7rB1nZc |
| API secret key | Rk8vN2qXw5LpT9sJc3HmY7bF1dGz6AeU4oKi0Wn-QtE |
Fields to send (before signing):
{
"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"
}
The string to sign after sorting and joining:
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
The HMAC-SHA256 result (that is, sign):
566fac06060d54d7450b41b6858b1ddf33df9d37117ab8a8c495ce748469f2b2
Code samples
All of the following samples have been verified with the sample credential above and produce the same signature as shown above. When verifying a Callback, recompute the signature using all the fields you received, then compare the two values using a constant-time comparison.
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()
# Verify a Callback
def qpay_verify(fields: dict, secret: str) -> bool:
return hmac.compare_digest(qpay_sign(fields, secret), str(fields.get("sign", "")).lower())
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)));
}
/** Verify a 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
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; // Do not use http_build_query; it applies URL encoding
}
return hash_hmac('sha256', implode('&', $parts), $secret);
}
// Verify a Callback
function qpay_verify(array $fields, string $secret): bool
{
return hash_equals(qpay_sign($fields, $secret), strtolower((string) ($fields['sign'] ?? '')));
}
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');
}
// Verify a 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);
}
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"sort"
"strings"
)
// Also use map[string]any when decoding a response or Callback: null values become nil and are not signed
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))
}
// Verify a 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)))
}
Verifying responses
Successful responses (code is OK) include sign in data. ST Pay computes it with the API secret key used for that request, using the same rules as above: all fields in data except sign, excluding fields whose value is null.
- Verify the signature before using the response, especially
account_no(the account the payer transfers to) andpayment_urlreturned when creating a collection order. - If verification fails, the response may have been tampered with in transit. Do not give the account or URL to the payer; check the order with an Order query instead.
- You can use the verification functions above directly by passing in the response's
data. - Error responses have
dataset tonulland carry no signature.
Full request example
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"
}'