Authentication & signing
Every tenant API request is signed with HMAC-SHA256. The server checks the key, timestamp, nonce, signature, tenant state, IP allowlist and rate limit, in that order, and only then handles the request.
Request headers
| Header | Content |
|---|---|
X-Api-Key | Key ID: sandbox ek_s_…, live ek_l_…, for example ek_s_1a_XXXXXXXXXXXXXXXX |
X-Timestamp | Current Unix time in seconds (9–11 digits); must be within ±300 seconds of server time |
X-Nonce | A random string that differs on every request, 16–64 letters and digits; must not repeat within 10 minutes |
X-Signature | The signature, 64 hexadecimal characters (lower case recommended; either case is accepted) |
Content-Type | application/json when there is a body |
Keys
- The key ID (
X-Api-Key) has the formek_<s|l>_<tenant>_<16 chars>:sfor sandbox,lfor live. The key ID identifies your tenant, so requests never carry a separate company code. - The secret is
es_followed by 40 letters and digits. The whole string (includinges_), as UTF-8 bytes, is the HMAC key. - Keys are managed in the Console under Go-live & integration → API keys:
- At most 10 active keys per environment (sandbox, live).
- "Show" and "Copy" require two-factor verification (TOTP), and every reveal is audited.
- Rotate creates a new key and marks the old one as rotating; the old key stops working 24 hours later. Deploy the new secret within that window.
- Disable revokes the key immediately and cannot be undone.
- A sandbox key belongs to your sandbox tenant (test coins, simulator tables); a live key belongs to your live tenant. Both use the same host and the same API.
Algorithm
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))| Part | Rule |
|---|---|
METHOD | The HTTP method in upper case: GET or POST |
PATH_AND_QUERY | The full path starting with /api/tenant/v1/…, plus ? and the query string when there is one. No scheme, host or # fragment. The query string must be exactly what you send (same parameter order, same percent-encoding) |
X-Timestamp, X-Nonce | Exactly the header strings |
hex(SHA-256(body)) | SHA-256 of the body bytes you send (UTF-8), lower-case hex. With no body it is the hash of the empty string: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
| Line breaks | \n (LF) only, and no newline after the last line |
GET requests
The server ignores the body of GET requests, so their body hash is always the hash of the empty string. GET parameters all go in the query string, which is part of PATH_AND_QUERY.
Worked example
With this (example-only) key and input:
| Item | Value |
|---|---|
| Key ID | ek_s_1a_XXXXXXXXXXXXXXXX |
| Secret | es_0123456789abcdefghijABCDEFGHIJ0123456789 |
| Method and path | POST /api/tenant/v1/player/launch |
| X-Timestamp | 1790218800 (2026-09-24T03:00:00Z) |
| X-Nonce | n0nce8H2kQ9xYz4LmP0aBcDe |
| Body | {"username":"alice","lang":"ENG","device":"mobile"} |
SHA-256 of the body:
texta0c1cfba44962853266340b6610d542620041fae49e6bcb10e6446ca0f73ad6aString to sign (five lines):
textPOST /api/tenant/v1/player/launch 1790218800 n0nce8H2kQ9xYz4LmP0aBcDe a0c1cfba44962853266340b6610d542620041fae49e6bcb10e6446ca0f73ad6aX-Signature:text6e4885c5d9f191b2e4ee5f4cb40ae148b13b277e606181bf853e040a06a0ac80
The complete request:
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"}A GET example: GET /api/tenant/v1/wallet/balance?username=alice with the same timestamp, nonce n0nce8H2kQ9xYz4LmP0aBcDf and no body signs to 273b761bda9ca095a8c7ae22908844bc1ce728212fceda3d0b2129fc9bb03032.
Paste either example into the signature debugger to check your own work.
Code samples
Each sample has a sign function and a small request helper. Running the file prints the signature of the worked example above, 6e4885c5…0ac80.
// 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
// 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;
}// 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();
}
}// 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\"}"));
}
}"""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"}'))// 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"}`)))
}What the server checks
| Order | Check | On failure |
|---|---|---|
| 1 | Format of X-Api-Key | 401 UNAUTHORIZED (invalid credentials) |
| 2 | Body at most 64 KB | 413 PAYLOAD_TOO_LARGE |
| 3 | Key exists and is not revoked (a rotated key keeps working for 24 hours) | 401 UNAUTHORIZED (invalid credentials) |
| 4 | X-Timestamp is well-formed and within 300 seconds of server time | 401 UNAUTHORIZED (timestamp outside the ±300 s window) |
| 5 | X-Nonce format (16–64 letters and digits) | 401 UNAUTHORIZED (invalid credentials) |
| 6 | Signature, compared in constant time | 401 UNAUTHORIZED (invalid credentials) |
| 7 | Tenant is not disabled or suspended | 403 TENANT_SUSPENDED |
| 8 | Source IP is on the allowlist (when you have one) | 403 IP_NOT_ALLOWED |
| 9 | Tenant rate limit | 429 RATE_LIMITED |
| 10 | Nonce not used in the last 10 minutes | 401 UNAUTHORIZED (nonce already used) |
- An unknown key and a bad signature return the same message, so keys cannot be probed.
- A nonce is only recorded once every other check has passed, so requests rejected for a bad signature, rate limiting and so on do not use up their nonce. Even so, generate a new nonce and timestamp for every retry.
nonce already usedmeans the request was not processed (for example a network layer retried the exact same request).
IP allowlist
Configure it in the Console under Go-live & integration → IP allowlist and Webhook: up to 100 IPv4 or IPv6 addresses or CIDR ranges (for example 203.0.113.10, 203.0.113.0/24, 2001:db8::/32). Empty means no restriction.
- The check uses the source IP Cloudflare sees (
CF-Connecting-IP), that is, your server's outbound IP. - If your server has both IPv4 and IPv6, add both, or pin your HTTP client to one of them.
- The allowlist is set on your live tenant; sandbox keys are not restricted.
Common mistakes
| Symptom | Cause and fix |
|---|---|
Always invalid credentials | Enter the same values in the signature debugger and compare the string to sign line by line |
| Path prefix missing | PATH_AND_QUERY starts with /api/tenant/v1, not the /player/launch relative to the base URL |
| GET signatures fail | The query string was not signed, or the signed query differs from the sent one in encoding or order |
| POST signatures fail | The signed body and the sent body are different strings: serialize once and use that string for both; do not let your HTTP library re-serialize |
| Non-English characters | Hash the body as UTF-8 bytes (for example a Chinese nickname) |
timestamp outside the ±300 s window | Server clock drift (enable NTP), or milliseconds sent instead of seconds |
nonce already used | A retry reused the old nonce; every request needs a new one |
| Wrong secret | The secret includes the es_ prefix; the key ID and secret must be the same key, same environment |
IP_NOT_ALLOWED | The outbound IP is not on the allowlist, or the server switched to IPv6 |