認證與簽章
租戶 API 的每個請求都以 HMAC-SHA256 簽章。伺服器依序檢查金鑰、時間戳、nonce、簽章、租戶狀態、IP 白名單與限流,全部通過才會處理請求。
請求標頭
| 標頭 | 內容 |
|---|---|
X-Api-Key | 金鑰 ID:沙箱 ek_s_…、正式 ek_l_…,例如 ek_s_1a_XXXXXXXXXXXXXXXX |
X-Timestamp | 目前的 Unix 時間,秒(9–11 位數字);與伺服器時間差須在 ±300 秒內 |
X-Nonce | 每個請求都不同的隨機字串,16–64 個英數字;10 分鐘內不可重複 |
X-Signature | 簽章,64 個十六進位字元(建議小寫;大小寫都接受) |
Content-Type | 有本文時為 application/json |
金鑰
- 金鑰 ID(
X-Api-Key)格式是ek_<s|l>_<租戶代號>_<16 碼>:s為沙箱、l為正式。金鑰 ID 本身就指出是哪個租戶,所以請求不需要另外帶公司代碼。 - Secret 是
es_加 40 個英數字。整個字串(包含es_)以 UTF-8 位元組作為 HMAC 金鑰。 - 金鑰在 Console 上線與串接 → API 金鑰 管理:
- 每個環境(沙箱、正式)最多 10 把有效金鑰。
- 「顯示」「複製」Secret 需要先驗證雙因子(TOTP),每次都會留下稽核紀錄。
- 輪替:產生新金鑰,舊金鑰轉為「輪替中」,24 小時後失效;請在期限內把新 Secret 部署到你的系統。
- 停用:立即失效,無法復原。
- 沙箱金鑰連到你的沙箱租戶(測試幣、模擬器桌),正式金鑰連到正式租戶。兩者使用同一個主機與同一套 API。
簽章演算法
text
string_to_sign = METHOD + "\n" +
PATH_AND_QUERY + "\n" +
X-Timestamp + "\n" +
X-Nonce + "\n" +
hex(SHA-256(body))
X-Signature = hex(HMAC-SHA256(key = secret, message = string_to_sign))| 部分 | 規則 |
|---|---|
METHOD | 大寫的 HTTP 方法:GET 或 POST |
PATH_AND_QUERY | 從 /api/tenant/v1/… 開始的完整路徑,有查詢字串時加上 ? 與查詢字串。不含通訊協定、主機與 # 之後的部分。查詢字串必須和實際送出的完全相同(參數順序、百分比編碼都一樣) |
X-Timestamp、X-Nonce | 與標頭中的字串完全相同 |
hex(SHA-256(body)) | 實際送出的本文位元組(UTF-8)的 SHA-256,小寫十六進位。沒有本文時是空字串的雜湊:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
| 換行 | 只用 \n(LF),最後一行後面沒有換行 |
GET 請求
伺服器處理 GET 請求時一律忽略本文,本文雜湊固定是空字串的雜湊。GET 的參數全部放在查詢字串,並且會被簽進 PATH_AND_QUERY。
範例
用下列(僅供示範的)金鑰與輸入計算:
| 項目 | 值 |
|---|---|
| 金鑰 ID | ek_s_1a_XXXXXXXXXXXXXXXX |
| Secret | es_0123456789abcdefghijABCDEFGHIJ0123456789 |
| 方法與路徑 | POST /api/tenant/v1/player/launch |
| X-Timestamp | 1790218800(2026-09-24T03:00:00Z) |
| X-Nonce | n0nce8H2kQ9xYz4LmP0aBcDe |
| 本文 | {"username":"alice","lang":"ENG","device":"mobile"} |
本文的 SHA-256:
texta0c1cfba44962853266340b6610d542620041fae49e6bcb10e6446ca0f73ad6a簽章字串(五行):
textPOST /api/tenant/v1/player/launch 1790218800 n0nce8H2kQ9xYz4LmP0aBcDe a0c1cfba44962853266340b6610d542620041fae49e6bcb10e6446ca0f73ad6aX-Signature:text6e4885c5d9f191b2e4ee5f4cb40ae148b13b277e606181bf853e040a06a0ac80
完整的請求:
http
POST /api/tenant/v1/player/launch HTTP/1.1
Host: elite.ewin888.com
X-Api-Key: ek_s_1a_XXXXXXXXXXXXXXXX
X-Timestamp: 1790218800
X-Nonce: n0nce8H2kQ9xYz4LmP0aBcDe
X-Signature: 6e4885c5d9f191b2e4ee5f4cb40ae148b13b277e606181bf853e040a06a0ac80
Content-Type: application/json
{"username":"alice","lang":"ENG","device":"mobile"}GET 的範例:GET /api/tenant/v1/wallet/balance?username=alice,時間戳相同、nonce 為 n0nce8H2kQ9xYz4LmP0aBcDf、沒有本文,簽章為 273b761bda9ca095a8c7ae22908844bc1ce728212fceda3d0b2129fc9bb03032。
這兩組數字可以直接貼到簽章除錯器驗算。
程式碼範例
每個範例都包含 sign 函式與一個送出請求的小工具,直接執行檔案會印出上面範例的簽章 6e4885c5…0ac80。
js
// elite tenant API: request signing (Node.js 18+, ES module, no dependencies)
import { createHash, createHmac, randomBytes } from 'node:crypto';
import { pathToFileURL } from 'node:url';
/**
* X-Signature = hex(HMAC-SHA256(secret,
* METHOD + "\n" + PATH_AND_QUERY + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + hex(SHA-256(body))))
*/
export function sign(secret, method, pathAndQuery, timestamp, nonce, body = '') {
const bodyHash = createHash('sha256').update(body, 'utf8').digest('hex');
const stringToSign = [method.toUpperCase(), pathAndQuery, timestamp, nonce, bodyHash].join('\n');
return createHmac('sha256', secret).update(stringToSign, 'utf8').digest('hex');
}
/** 32 hex characters; use a new nonce for every request, including retries */
export function newNonce() {
return randomBytes(16).toString('hex');
}
/** Sends a signed request. Returns `data`, or throws { status, code, message }. */
export async function call(method, pathAndQuery, payload, { keyId, secret, baseUrl = 'https://elite.ewin-soft.com' }) {
// Sign exactly the bytes you send: serialize once and reuse the string.
const body = payload === undefined ? '' : JSON.stringify(payload);
const timestamp = String(Math.floor(Date.now() / 1000)); // seconds, not milliseconds
const nonce = newNonce();
const headers = {
'X-Api-Key': keyId,
'X-Timestamp': timestamp,
'X-Nonce': nonce,
'X-Signature': sign(secret, method, pathAndQuery, timestamp, nonce, body),
};
if (body) headers['Content-Type'] = 'application/json';
const res = await fetch(baseUrl + pathAndQuery, { method, headers, body: body || undefined, signal: AbortSignal.timeout(10_000) });
const json = await res.json().catch(() => null);
if (!res.ok || !json?.ok) {
throw { status: res.status, code: json?.error?.code ?? `HTTP_${res.status}`, message: json?.error?.message ?? res.statusText };
}
return json.data;
}
// Example:
// const auth = { keyId: process.env.ELITE_KEY_ID, secret: process.env.ELITE_SECRET };
// const { url } = await call('POST', '/api/tenant/v1/player/launch', { username: 'alice' }, auth);
// const { balance } = await call('GET', '/api/tenant/v1/wallet/balance?username=alice', undefined, auth);
// Worked example from the docs: prints 6e4885c5d9f191b2e4ee5f4cb40ae148b13b277e606181bf853e040a06a0ac80
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
console.log(
sign(
'es_0123456789abcdefghijABCDEFGHIJ0123456789',
'POST',
'/api/tenant/v1/player/launch',
'1790218800',
'n0nce8H2kQ9xYz4LmP0aBcDe',
'{"username":"alice","lang":"ENG","device":"mobile"}',
),
);
}php
<?php
// elite tenant API: request signing (PHP 7.2+; the request helper needs ext-curl)
/**
* hex(HMAC-SHA256(secret, METHOD \n PATH_AND_QUERY \n TIMESTAMP \n NONCE \n hex(SHA-256(body))))
*/
function elite_sign(string $secret, string $method, string $pathAndQuery, string $timestamp, string $nonce, string $body = ''): string
{
$stringToSign = strtoupper($method) . "\n" . $pathAndQuery . "\n" . $timestamp . "\n" . $nonce . "\n" . hash('sha256', $body);
return hash_hmac('sha256', $stringToSign, $secret);
}
/**
* Sends a signed request and returns `data`. Throws RuntimeException with the error code on failure.
*/
function elite_call(string $method, string $pathAndQuery, ?array $payload, string $keyId, string $secret, string $baseUrl = 'https://elite.ewin-soft.com'): array
{
// Sign exactly the bytes you send: encode once and reuse the string.
$body = $payload === null ? '' : json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$timestamp = (string) time(); // seconds
$nonce = bin2hex(random_bytes(16)); // new nonce for every request, including retries
$headers = [
'X-Api-Key: ' . $keyId,
'X-Timestamp: ' . $timestamp,
'X-Nonce: ' . $nonce,
'X-Signature: ' . elite_sign($secret, $method, $pathAndQuery, $timestamp, $nonce, $body),
];
if ($body !== '') {
$headers[] = 'Content-Type: application/json';
}
$ch = curl_init($baseUrl . $pathAndQuery);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
if ($body !== '') {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$raw = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$json = json_decode((string) $raw, true);
if ($status < 200 || $status >= 300 || empty($json['ok'])) {
$code = $json['error']['code'] ?? ('HTTP_' . $status);
throw new RuntimeException($code . ': ' . ($json['error']['message'] ?? ''), $status);
}
return $json['data'];
}
// Example:
// $url = elite_call('POST', '/api/tenant/v1/player/launch', ['username' => 'alice'], $keyId, $secret)['url'];
// $balance = elite_call('GET', '/api/tenant/v1/wallet/balance?username=alice', null, $keyId, $secret)['balance'];
// Worked example from the docs: prints 6e4885c5d9f191b2e4ee5f4cb40ae148b13b277e606181bf853e040a06a0ac80
if (PHP_SAPI === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) {
echo elite_sign('es_0123456789abcdefghijABCDEFGHIJ0123456789', 'POST', '/api/tenant/v1/player/launch',
'1790218800', 'n0nce8H2kQ9xYz4LmP0aBcDe', '{"username":"alice","lang":"ENG","device":"mobile"}'), PHP_EOL;
}cs
// elite tenant API: request signing (.NET 6+)
using System;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
// Worked example from the docs: prints 6e4885c5d9f191b2e4ee5f4cb40ae148b13b277e606181bf853e040a06a0ac80
Console.WriteLine(EliteSigner.Sign("es_0123456789abcdefghijABCDEFGHIJ0123456789", "POST", "/api/tenant/v1/player/launch",
"1790218800", "n0nce8H2kQ9xYz4LmP0aBcDe", "{\"username\":\"alice\",\"lang\":\"ENG\",\"device\":\"mobile\"}"));
public static class EliteSigner
{
/// hex(HMAC-SHA256(secret, METHOD \n PATH_AND_QUERY \n TIMESTAMP \n NONCE \n hex(SHA-256(body))))
public static string Sign(string secret, string method, string pathAndQuery, string timestamp, string nonce, string body)
{
var bodyHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(body))).ToLowerInvariant();
var stringToSign = $"{method.ToUpperInvariant()}\n{pathAndQuery}\n{timestamp}\n{nonce}\n{bodyHash}";
var mac = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes(stringToSign));
return Convert.ToHexString(mac).ToLowerInvariant();
}
/// Sends a signed request and returns `data`. Throws HttpRequestException on failure.
public static async Task<JsonElement> CallAsync(HttpClient http, string method, string pathAndQuery, object? payload,
string keyId, string secret, string baseUrl = "https://elite.ewin-soft.com")
{
// Sign exactly the bytes you send: serialize once and reuse the string.
var body = payload is null ? "" : JsonSerializer.Serialize(payload);
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); // seconds
var nonce = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(); // new for every request
using var request = new HttpRequestMessage(new HttpMethod(method), baseUrl + pathAndQuery);
request.Headers.Add("X-Api-Key", keyId);
request.Headers.Add("X-Timestamp", timestamp);
request.Headers.Add("X-Nonce", nonce);
request.Headers.Add("X-Signature", Sign(secret, method, pathAndQuery, timestamp, nonce, body));
if (body.Length > 0) request.Content = new StringContent(body, Encoding.UTF8, "application/json");
using var response = await http.SendAsync(request);
var text = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(text);
var root = doc.RootElement;
if (!response.IsSuccessStatusCode || !root.GetProperty("ok").GetBoolean())
{
var error = root.GetProperty("error");
throw new HttpRequestException($"{error.GetProperty("code").GetString()}: {error.GetProperty("message").GetString()}");
}
return root.GetProperty("data").Clone();
}
}java
// elite tenant API: request signing (Java 11+, no dependencies)
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Duration;
import java.util.Locale;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public final class EliteSigner {
private static final SecureRandom RANDOM = new SecureRandom();
/** hex(HMAC-SHA256(secret, METHOD \n PATH_AND_QUERY \n TIMESTAMP \n NONCE \n hex(SHA-256(body)))) */
public static String sign(String secret, String method, String pathAndQuery, String timestamp, String nonce, String body) throws Exception {
String bodyHash = hex(MessageDigest.getInstance("SHA-256").digest(body.getBytes(StandardCharsets.UTF_8)));
String stringToSign = method.toUpperCase(Locale.ROOT) + "\n" + pathAndQuery + "\n" + timestamp + "\n" + nonce + "\n" + bodyHash;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return hex(mac.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8)));
}
/** 32 hex characters; use a new nonce for every request, including retries */
public static String newNonce() {
byte[] bytes = new byte[16];
RANDOM.nextBytes(bytes);
return hex(bytes);
}
/** Sends a signed request and returns the JSON response body; throws on a non-2xx status. */
public static String call(HttpClient http, String method, String pathAndQuery, String jsonBody,
String keyId, String secret, String baseUrl) throws Exception {
// Sign exactly the bytes you send: build the JSON string once and reuse it.
String body = jsonBody == null ? "" : jsonBody;
String timestamp = Long.toString(System.currentTimeMillis() / 1000); // seconds
String nonce = newNonce();
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(baseUrl + pathAndQuery))
.timeout(Duration.ofSeconds(10))
.header("X-Api-Key", keyId)
.header("X-Timestamp", timestamp)
.header("X-Nonce", nonce)
.header("X-Signature", sign(secret, method, pathAndQuery, timestamp, nonce, body));
if (body.isEmpty()) {
builder.method(method, HttpRequest.BodyPublishers.noBody());
} else {
builder.header("Content-Type", "application/json")
.method(method, HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8));
}
HttpResponse<String> response = http.send(builder.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() / 100 != 2) {
throw new RuntimeException("HTTP " + response.statusCode() + ": " + response.body());
}
return response.body();
}
private static String hex(byte[] bytes) {
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
sb.append(Character.forDigit((b >> 4) & 0xf, 16)).append(Character.forDigit(b & 0xf, 16));
}
return sb.toString();
}
// Worked example from the docs: prints 6e4885c5d9f191b2e4ee5f4cb40ae148b13b277e606181bf853e040a06a0ac80
public static void main(String[] args) throws Exception {
System.out.println(sign("es_0123456789abcdefghijABCDEFGHIJ0123456789", "POST", "/api/tenant/v1/player/launch",
"1790218800", "n0nce8H2kQ9xYz4LmP0aBcDe", "{\"username\":\"alice\",\"lang\":\"ENG\",\"device\":\"mobile\"}"));
}
}py
"""elite tenant API: request signing (Python 3.8+, standard library only)."""
import hashlib
import hmac
import json
import secrets
import time
import urllib.request
def sign(secret: str, method: str, path_and_query: str, timestamp: str, nonce: str, body: bytes = b"") -> str:
"""hex(HMAC-SHA256(secret, METHOD \\n PATH_AND_QUERY \\n TIMESTAMP \\n NONCE \\n hex(SHA-256(body))))"""
body_hash = hashlib.sha256(body).hexdigest()
string_to_sign = "\n".join([method.upper(), path_and_query, timestamp, nonce, body_hash])
return hmac.new(secret.encode("utf-8"), string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
def call(method, path_and_query, payload, key_id, secret, base_url="https://elite.ewin-soft.com"):
"""Sends a signed request and returns `data`. Raises urllib.error.HTTPError on 4xx/5xx."""
# Sign exactly the bytes you send: serialize once and reuse them.
body = b"" if payload is None else json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
timestamp = str(int(time.time())) # seconds
nonce = secrets.token_hex(16) # new nonce for every request, including retries
headers = {
"X-Api-Key": key_id,
"X-Timestamp": timestamp,
"X-Nonce": nonce,
"X-Signature": sign(secret, method, path_and_query, timestamp, nonce, body),
"User-Agent": "my-backend/1.0",
}
if body:
headers["Content-Type"] = "application/json"
request = urllib.request.Request(base_url + path_and_query, data=body or None, method=method, headers=headers)
with urllib.request.urlopen(request, timeout=10) as response:
return json.loads(response.read())["data"]
# Example:
# url = call("POST", "/api/tenant/v1/player/launch", {"username": "alice"}, KEY_ID, SECRET)["url"]
# balance = call("GET", "/api/tenant/v1/wallet/balance?username=alice", None, KEY_ID, SECRET)["balance"]
if __name__ == "__main__":
# Worked example from the docs: prints 6e4885c5d9f191b2e4ee5f4cb40ae148b13b277e606181bf853e040a06a0ac80
print(sign("es_0123456789abcdefghijABCDEFGHIJ0123456789", "POST", "/api/tenant/v1/player/launch",
"1790218800", "n0nce8H2kQ9xYz4LmP0aBcDe", b'{"username":"alice","lang":"ENG","device":"mobile"}'))go
// elite tenant API: request signing (Go 1.20+, standard library only)
package main
import (
"bytes"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
)
// Sign returns hex(HMAC-SHA256(secret, METHOD \n PATH_AND_QUERY \n TIMESTAMP \n NONCE \n hex(SHA-256(body)))).
func Sign(secret, method, pathAndQuery, timestamp, nonce string, body []byte) string {
sum := sha256.Sum256(body)
stringToSign := strings.Join([]string{strings.ToUpper(method), pathAndQuery, timestamp, nonce, hex.EncodeToString(sum[:])}, "\n")
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(stringToSign))
return hex.EncodeToString(mac.Sum(nil))
}
// NewNonce returns 32 hex characters; use a new nonce for every request, including retries.
func NewNonce() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return hex.EncodeToString(b)
}
// Call sends a signed request and returns the JSON response body; it returns an error on a non-2xx status.
// Sign exactly the bytes you send: marshal the JSON once and pass the same slice here.
func Call(client *http.Client, method, pathAndQuery string, body []byte, keyID, secret, baseURL string) ([]byte, error) {
timestamp := strconv.FormatInt(time.Now().Unix(), 10) // seconds
nonce := NewNonce()
req, err := http.NewRequest(method, baseURL+pathAndQuery, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("X-Api-Key", keyID)
req.Header.Set("X-Timestamp", timestamp)
req.Header.Set("X-Nonce", nonce)
req.Header.Set("X-Signature", Sign(secret, method, pathAndQuery, timestamp, nonce, body))
if len(body) > 0 {
req.Header.Set("Content-Type", "application/json")
}
res, err := client.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
data, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
if res.StatusCode/100 != 2 {
return data, fmt.Errorf("HTTP %d: %s", res.StatusCode, data)
}
return data, nil
}
// Worked example from the docs: prints 6e4885c5d9f191b2e4ee5f4cb40ae148b13b277e606181bf853e040a06a0ac80
func main() {
fmt.Println(Sign("es_0123456789abcdefghijABCDEFGHIJ0123456789", "POST", "/api/tenant/v1/player/launch",
"1790218800", "n0nce8H2kQ9xYz4LmP0aBcDe", []byte(`{"username":"alice","lang":"ENG","device":"mobile"}`)))
}伺服器的檢查順序
| 順序 | 檢查 | 失敗時 |
|---|---|---|
| 1 | X-Api-Key 的格式 | 401 UNAUTHORIZED(invalid credentials) |
| 2 | 本文不超過 64 KB | 413 PAYLOAD_TOO_LARGE |
| 3 | 金鑰存在、未停用(輪替中的舊金鑰在 24 小時內仍有效) | 401 UNAUTHORIZED(invalid credentials) |
| 4 | X-Timestamp 格式正確、與伺服器時間差 ≤ 300 秒 | 401 UNAUTHORIZED(timestamp outside the ±300 s window) |
| 5 | X-Nonce 格式(16–64 英數字) | 401 UNAUTHORIZED(invalid credentials) |
| 6 | 簽章以固定時間比對 | 401 UNAUTHORIZED(invalid credentials) |
| 7 | 租戶未停用、未停權 | 403 TENANT_SUSPENDED |
| 8 | 來源 IP 在白名單內(有設定時) | 403 IP_NOT_ALLOWED |
| 9 | 租戶限流 | 429 RATE_LIMITED |
| 10 | nonce 在 10 分鐘內沒有用過 | 401 UNAUTHORIZED(nonce already used) |
- 金鑰不存在與簽章錯誤回同一個訊息,避免被用來探測金鑰。
- nonce 只有在前面全部通過時才會被記錄,所以因簽章錯誤、限流等被拒絕的請求不會占用 nonce。即使如此,每次重試都請產生新的 nonce 與時間戳。
- 收到
nonce already used表示這個請求沒有被處理(例如網路層自動重送了同一個請求)。
IP 白名單
在 Console 上線與串接 → IP 白名單與 Webhook 設定,最多 100 筆 IPv4、IPv6 位址或 CIDR(例如 203.0.113.10、203.0.113.0/24、2001:db8::/32)。空白代表不限制。
- 判斷依據是 Cloudflare 看到的來源 IP(
CF-Connecting-IP),也就是你的伺服器的對外 IP。 - 伺服器同時有 IPv4 與 IPv6 時,請兩種都加入,或讓 HTTP 用戶端固定使用其中一種。
- 白名單設定在正式租戶上;沙箱金鑰不受限制。
常見錯誤
| 狀況 | 原因與解法 |
|---|---|
一直收到 invalid credentials | 用簽章除錯器輸入同樣的值,逐行比對簽章字串 |
| 路徑少了前綴 | PATH_AND_QUERY 必須從 /api/tenant/v1 開始,不是相對於 Base URL 的 /player/launch |
| GET 簽章錯誤 | 忘了把查詢字串簽進去,或簽章用的查詢字串與實際送出的編碼、順序不同 |
| POST 簽章錯誤 | 簽章用的本文和送出的本文不是同一個字串:只序列化一次,簽章與送出都用那個字串;不要讓 HTTP 函式庫重新序列化 |
| 非英文字元 | 以 UTF-8 位元組計算本文雜湊(例如中文暱稱) |
timestamp outside the ±300 s window | 伺服器時鐘偏移(請開啟 NTP),或送了毫秒而不是秒 |
nonce already used | 重試時沿用了舊的 nonce;每個請求都要產生新的 nonce |
| Secret 不對 | Secret 要包含 es_ 前綴;金鑰 ID 與 Secret 必須是同一把、同一個環境 |
IP_NOT_ALLOWED | 對外 IP 不在白名單,或伺服器改走 IPv6 |