ST Pay Merchant APIST Pay Merchant API
Guide
API
Error codes
Back office
AI-assisted integration
  • 繁體中文
  • 简体中文
  • English
Guide
API
Error codes
Back office
AI-assisted integration
  • 繁體中文
  • 简体中文
  • English
  • Getting started

    • Overview
    • Request and response format
    • Authentication and IP whitelist
    • Signature
    • Merchant back office
    • AI-assisted integration
  • API

    • Create a collection order
    • Create a payout order
    • Order query
    • Callback notifications
  • Reference

    • Order status and amounts
    • Duplicate requests and confirming results
    • Error codes
    • Bank codes
    • Go-live checklist

Signature

API requests, API responses, and Callbacks use the same signature rules (HMAC-SHA256).

How to compute the signature

  1. Take all fields in the body except sign, and exclude fields whose value is null. Empty strings "" are not excluded.
  2. Sort the fields by field name in ascending order of character code (ASCII).
  3. Write each field as field_name=field_value and 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.
  4. Compute HMAC-SHA256 over the UTF-8 bytes of the string to sign, using your API secret key as the key.
  5. Convert the result to a lowercase hexadecimal string (64 characters) and put it in the sign field.

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.00 but send "1000", the signature will not match.
  • Do not use functions such as http_build_query, URLSearchParams, or urlencode to 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_url is also signed. Put it into the string to sign exactly as sent; do not encode it, even if its query string contains ?, &, or =.
  • timestamp is 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):

ItemSample value
api_identityqp_Xk3vT9aLm2QwE7rB1nZc
API secret keyRk8vN2qXw5LpT9sJc3HmY7bF1dGz6AeU4oKi0Wn-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&timestamp=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.

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

# Verify a 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)));
    }

    /** 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 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;   // 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'] ?? '')));
}
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');
}

// 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);
}
Go
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) and payment_url returned 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 data set to null and 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"
  }'
Prev
Authentication and IP whitelist
Next
Merchant back office