支付 API 文件
1. 建立訂單 API
介面資訊
- URL:
/pay/create - 方法:
POST - Content-Type:
application/x-www-form-urlencoded
請求參數
| 參數名 | 類型 | 必填 | 說明 |
|---|---|---|---|
| mid | int | 是 | 商戶 ID |
| orderid | string | 是 | 商戶訂單號,唯一標識 |
| amount | float | 是 | 訂單金額(依系統設定之幣種,預設 TWD 新台幣) |
| notifyurl | string | 是 | 支付結果通知 URL |
| sign | string | 是 | 簽章,請參考文件末尾簽章方法 |
回應格式
成功回應
{
"status": "ok",
"orderid": "ORDER123456",
"address": "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa",
"uamount": "0.12",
"url": "https://demosite.com/pay?id=abc123def456"
}
錯誤回應
{
"status": "error",
"message": "系統繁忙,請稍後再試!"
}
回應欄位說明
| 欄位名 | 類型 | 說明 |
|---|---|---|
| status | string | 回應狀態:ok-成功,error-失敗 |
| orderid | string | 商戶訂單號 |
| address | string | 錢包地址 |
| uamount | string | 需要支付的 USDT 數量 |
| url | string | 支付頁面 URL,請將使用者瀏覽器導向到此地址,或由商戶自行展示上述錢包和金額資訊。 |
| message | string | 錯誤訊息(僅失敗時回傳) |
注意事項
- 訂單號唯一性:每個商戶的訂單號必須唯一。
- 金額精度:支援最多 2 位小數。
- 通知 URL:必須是可訪問的 HTTPS 地址。
- 金額調整:在交易繁忙時,系統會自動微調 USDT 支付金額(最多 +0.20)。
- 來源 IP 白名單:管理後台可為商戶設定收款 API IP 白名單;設定後,僅白名單中的 IP 可建立收款訂單。
2. 查詢訂單狀態 API
介面資訊
- URL:
/pay/checkorder - 方法:
POST - Content-Type:
application/x-www-form-urlencoded
請求參數
| 參數名 | 類型 | 必填 | 說明 |
|---|---|---|---|
| mid | string | 是 | 商戶 ID |
| orderid | string | 是 | 商戶訂單號 |
| sign | string | 是 | 簽章,請參考文件末尾簽章方法 |
回應格式
成功回應
{
"status": "ok",
"orderid": "ORDER123456",
"status_code": 0,
"amount": 100.00,
"uamount": 0.12,
"updatetime": 1640995200
}
錯誤回應
{
"status": "error",
"message": "訂單不存在"
}
回應欄位說明
| 欄位名 | 類型 | 說明 |
|---|---|---|
| orderid | string | 商戶訂單號 |
| status_code | int | 訂單狀態:0-未支付,1-已支付 |
| amount | float | 系統結算幣種訂單金額 |
| uamount | float | USDT 金額 |
| updatetime | int | 更新時間戳(status_code=0 時為建立時間,status_code=1 時為成功時間) |
| status | string | 回應狀態:ok-成功,error-失敗 |
| message | string | 錯誤訊息(僅失敗時回傳) |
注意事項
- 訂單查詢:只能查詢本商戶的訂單。
- 時間戳:updatetime 欄位根據訂單狀態回傳不同時間:
- status=0:回傳訂單建立時間
- status=1:回傳訂單成功時間
3. 支付通知回呼
通知機制說明
當訂單支付成功時,系統會主動向商戶提供的 notifyurl 發送 POST 請求通知支付結果。
通知參數
| 參數名 | 類型 | 說明 |
|---|---|---|
| orderid | string | 商戶訂單號 |
| amount | float | 系統結算幣種訂單金額 |
| uamount | float | USDT 支付金額 |
| created_time | int | 訂單建立時間戳 |
| success_time | int | USDT 轉帳時間 |
| sign | string | 平台依該商戶設定的簽章演算法生成的簽章(RSA-SHA256 或 MD5),詳見文件末尾簽章方法 |
通知回應要求
商戶收到通知後,必須回傳 HTTP 狀態碼 200,表示成功接收。如果回傳非 200 狀態碼,系統會認為通知失敗並進行重試。通知收妥後務請回應成功訊息,避免系統多次重試堵塞通道。
成功回應範例:
success
通知失敗重試機制
- 重試次數:最多重試 9 次
- 重試間隔:採用指數退避演算法
- 第 1 次失敗:30 秒後重試
- 第 2 次失敗:60 秒後重試
- 第 3 次失敗:120 秒後重試
- 第 4 次及以後:2^(失敗次數-1) * 60 秒
- 逾時處理:超過 9 次失敗後,系統會放棄此訂單的通知。
4. 簽章規則
商戶的簽章演算法由管理後台「商戶管理」中該商戶的設定決定,支援 RSA-SHA256 與 MD5 兩種;同一商戶所有請求與通知都使用相同的演算法。
RSA-SHA256 簽章
當商戶簽章演算法為 RSA 時:商戶在後台建立商戶資料時上傳 RSA 公鑰(PEM / PKIX),並自行安全保管配對私鑰。所有 API 請求由商戶私鑰使用 RSA-SHA256(PKCS#1 v1.5 + SHA-256)簽章;平台用該商戶公鑰驗簽。平台會自動生成自身金鑰對,後台「平台公鑰」介面回傳公鑰;商戶必須儲存該公鑰並用它驗證通知。
- 將除
sign外的參數按參數名 ASCII 升序排序。 - 拼為
key=value&key=value,原始值不做 URL 編碼。 - 對 UTF-8 原文計算 SHA-256,並以 RSA PKCS#1 v1.5 簽章。
- 將簽章位元組作標準 Base64 編碼,放入
sign參數。
簽章範例
原始參數:
mid=1001
orderid=ORDER123456
amount=100.00
notifyurl=https://example.com/notify
排序後:
amount=100.00&mid=1001¬ifyurl=https://example.com/notify&orderid=ORDER123456
以上排序後的字串即簽章原文;用商戶 RSA 私鑰簽章並 Base64 編碼。
注意事項
- 所有 API 介面都需要使用相同的簽章規則。
- 簽章驗證失敗會導致請求被拒絕。
- 請妥善保管商戶 RSA 私鑰,絕不可上傳或洩露;後台只儲存公鑰。
MD5 簽章
當商戶簽章演算法為 MD5 時:後台「商戶管理」會為商戶產生一組 商戶金鑰(app_secret),商戶與平台共享此金鑰。所有 API 請求與通知都使用該金鑰計算 MD5 簽章;商戶端不需生成或上傳任何金鑰。
- 將除
sign外的參數按參數名 ASCII 升序排序。 - 拼為
key=value&key=value,原始值不做 URL 編碼。 - 於字串末尾直接串接商戶金鑰,對 UTF-8 全文計算
MD5。 - 將 MD5 結果以大寫十六進位字串放入
sign參數(驗證端大小寫不區分)。
MD5 簽章範例
原始參數:
mid=1001
orderid=ORDER123456
amount=100.00
notifyurl=https://example.com/notify
排序後:
amount=100.00&mid=1001¬ifyurl=https://example.com/notify&orderid=ORDER123456
商戶金鑰 app_secret:ABC123...
簽章原文 = amount=100.00&mid=1001¬ifyurl=https://example.com/notify&orderid=ORDER123456ABC123...
sign = strtoupper(md5(簽章原文))
MD5 簽章核心邏輯
$params = ['mid'=>'1001', 'orderid'=>'ORDER123456', 'amount'=>'100.00', 'notifyurl'=>'https://example.com/notify'];
ksort($params, SORT_STRING);
$source = implode('&', array_map(fn($k) => $k.'='.$params[$k], array_keys($params)));
$params['sign'] = strtoupper(md5($source . $appSecret));
params := map[string]string{"mid":"1001", "orderid":"ORDER123456", "amount":"100.00", "notifyurl":"https://example.com/notify"}
keys := make([]string, 0, len(params)); for k := range params { keys = append(keys, k) }; sort.Strings(keys)
parts := make([]string, 0, len(keys)); for _, k := range keys { parts = append(parts, k+"="+params[k]) }
source := strings.Join(parts, "&") + appSecret
params["sign"] = strings.ToUpper(fmt.Sprintf("%x", md5.Sum([]byte(source))))
Map<String,String> params = new TreeMap<>();
params.put("mid", "1001"); params.put("orderid", "ORDER123456"); params.put("amount", "100.00"); params.put("notifyurl", "https://example.com/notify");
String source = params.entrySet().stream().map(e -> e.getKey()+"="+e.getValue()).collect(Collectors.joining("&")) + appSecret;
MessageDigest md = MessageDigest.getInstance("MD5");
StringBuilder sb = new StringBuilder(); for (byte b : md.digest(source.getBytes(StandardCharsets.UTF_8))) sb.append(String.format("%02X", b));
params.put("sign", sb.toString());
MD5 通知驗簽核心邏輯
通知收到後,將 sign 取出並從參數集合移除;其餘參數按同樣規則排序拼接後串接商戶金鑰計算 MD5,與回傳的 sign(不分大小寫)比對。
$signature = strtoupper($params['sign']); unset($params['sign']); ksort($params, SORT_STRING);
$source = implode('&', array_map(fn($k) => $k.'='.$params[$k], array_keys($params)));
$valid = strtoupper(md5($source . $appSecret)) === $signature;
signature := strings.ToUpper(params["sign"]); delete(params, "sign")
keys := make([]string, 0, len(params)); for k := range params { keys = append(keys, k) }; sort.Strings(keys)
parts := make([]string, 0, len(keys)); for _, k := range keys { parts = append(parts, k+"="+params[k]) }
source := strings.Join(parts, "&") + appSecret
valid := strings.ToUpper(fmt.Sprintf("%x", md5.Sum([]byte(source)))) == signature
String signature = params.remove("sign").toUpperCase();
String source = params.entrySet().stream().sorted(Map.Entry.comparingByKey()).map(e -> e.getKey()+"="+e.getValue()).collect(Collectors.joining("&")) + appSecret;
MessageDigest md = MessageDigest.getInstance("MD5");
StringBuilder sb = new StringBuilder(); for (byte b : md.digest(source.getBytes(StandardCharsets.UTF_8))) sb.append(String.format("%02X", b));
boolean valid = sb.toString().equals(signature);
注意:MD5 為共享金鑰簽章,商戶金鑰等同密碼,請透過安全管道傳遞並妥善保管,切勿於前端或公開場所洩露;後台重新產生金鑰後,舊金鑰立即失效。
RSA 簽章核心邏輯
以下範例先用實際業務欄位構建請求參數與簽章原文,privateKey 為商戶私鑰;將得到的 sign 放回參數集合後送出。通知驗簽時改用平台公鑰和對應語言的驗簽 API。
$params = ['mid'=>'1001', 'orderid'=>'ORDER123456', 'amount'=>'100.00', 'notifyurl'=>'https://example.com/notify'];
ksort($params, SORT_STRING);
$source = implode('&', array_map(fn($k) => $k.'='.$params[$k], array_keys($params)));
openssl_sign($source, $raw, $privateKey, OPENSSL_ALGO_SHA256);
$params['sign'] = base64_encode($raw);
params := map[string]string{"mid":"1001", "orderid":"ORDER123456", "amount":"100.00", "notifyurl":"https://example.com/notify"}
keys := make([]string, 0, len(params)); for k := range params { keys = append(keys, k) }; sort.Strings(keys)
parts := make([]string, 0, len(keys)); for _, k := range keys { parts = append(parts, k+"="+params[k]) }; source := strings.Join(parts, "&")
hash := sha256.Sum256([]byte(source))
raw, _ := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, hash[:])
params["sign"] = base64.StdEncoding.EncodeToString(raw)
Map<String,String> params = new TreeMap<>();
params.put("mid", "1001"); params.put("orderid", "ORDER123456"); params.put("amount", "100.00"); params.put("notifyurl", "https://example.com/notify");
String source = params.entrySet().stream().map(e -> e.getKey()+"="+e.getValue()).collect(Collectors.joining("&"));
Signature signer = Signature.getInstance("SHA256withRSA");
signer.initSign(privateKey); signer.update(source.getBytes(StandardCharsets.UTF_8));
params.put("sign", Base64.getEncoder().encodeToString(signer.sign()));
RSA 通知驗簽核心邏輯
通知收到後,將 sign 取出並從參數集合移除;其餘參數按同樣規則排序拼接。platformPublicKey 為後台展示的平台公鑰。
$signature = base64_decode($params['sign']); unset($params['sign']); ksort($params, SORT_STRING);
$source = implode('&', array_map(fn($k) => $k.'='.$params[$k], array_keys($params)));
$valid = openssl_verify($source, $signature, $platformPublicKey, OPENSSL_ALGO_SHA256) === 1;
signature, _ := base64.StdEncoding.DecodeString(params["sign"]); delete(params, "sign")
keys := make([]string, 0, len(params)); for k := range params { keys = append(keys, k) }; sort.Strings(keys)
parts := make([]string, 0, len(keys)); for _, k := range keys { parts = append(parts, k+"="+params[k]) }; digest := sha256.Sum256([]byte(strings.Join(parts, "&")))
err := rsa.VerifyPKCS1v15(platformPublicKey, crypto.SHA256, digest[:], signature)
byte[] signature = Base64.getDecoder().decode(params.remove("sign"));
String source = params.entrySet().stream().sorted(Map.Entry.comparingByKey()).map(e -> e.getKey()+"="+e.getValue()).collect(Collectors.joining("&"));
Signature verifier = Signature.getInstance("SHA256withRSA");
verifier.initVerify(platformPublicKey); verifier.update(source.getBytes(StandardCharsets.UTF_8));
boolean valid = verifier.verify(signature);