Authentication and Signing
Operator API requests (operator → platform) and wallet callbacks (platform → operator) use the same HMAC-SHA256 signing rules. Once you build a single signing function, you can use it both to sign requests and to verify callbacks.
Headers
| Header | Operator API (sent by operator) | Wallet callback (sent by platform) | Value |
|---|---|---|---|
Content-Type | Sent | Sent | application/json; charset=utf-8 |
X-Operator | Required | Sent | Operator code (e.g. acme) |
X-Api-Key | Always send | Not sent | The issued API key (ak_…). If the value is wrong: 401 INVALID_SIGNATURE |
X-Timestamp | Required | Sent | Request time as a decimal string of Unix epoch milliseconds (e.g. 1760000000000) |
X-Nonce | Required | Sent (24 characters) | A random string newly generated for each request, 1–128 characters |
X-Signature | Required | Sent | HMAC-SHA256 of the string to sign below, 64 characters of lowercase hex |
Header names are case-insensitive.
String to sign
The string to sign is a byte sequence made of five parts joined by newlines (\n, a single LF character). No newline is appended after the final body.
X-Timestamp \n X-Nonce \n METHOD \n PATH_WITH_QUERY \n BODYX-Signature = lowercase_hex( HMAC-SHA256( key = API secret, message = string to sign ) )| Part | Rule |
|---|---|
X-Timestamp | The same string as the header value |
X-Nonce | The same string as the header value |
METHOD | Uppercase HTTP method (GET, POST, PUT, PATCH, DELETE) |
PATH_WITH_QUERY | The raw path + query as it appears in the request line. Exclude the scheme and host; if there is a query, append it as is starting from ?. Percent-encoding is also exactly as sent. Example: /operator/v1/rounds?from=2026-10-01T00%3A00%3A00%2B08%3A00&limit=50 |
BODY | The exact bytes of the body actually sent. If there is no body (GET, etc.), an empty string |
| Key | The bytes of the issued 64-character hex secret string, as is. Do not hex-decode it. |
For the Operator API
- Sign the exact bytes you serialized, and send those same bytes. If you serialize the object once to sign it and again to send it, whitespace or key order can differ and break the signature.
- Send GET requests without a body and sign them with an empty string.
POSTandDELETErequests that need no body may be sent without a body (signed with an empty string), or you may send{}and sign with{}.- Put a query string that you percent-encoded yourself into the URL, and sign with that same string. In particular, the
+in a time zone offset must be encoded as%2B. If your HTTP library re-encodes or reorders the query, the signature will not match.
For wallet callbacks
- The platform always sends
POST, andPATH_WITH_QUERYis the path of the registered wallet URL with the endpoint appended. If the wallet URL ishttps://api.acme.example/games-wallet, the signing path for debit is/games-wallet/debit. - When verifying, compute the signature over the raw body bytes before parsing the JSON. Re-serializing the parsed object changes the bytes. The platform serializes JSON keys in lexicographic order, but do not rely on that order — use the bytes you received as is.
- When the platform retries the same transaction, it resends the body with the same bytes as the first attempt and regenerates only
X-Timestamp,X-Nonce, andX-Signature.
Timestamp and nonce
| Rule | Value |
|---|---|
| Tolerance | ±5 minutes relative to the receiving server's clock. Beyond that: 401 TIMESTAMP_OUT_OF_RANGE |
| Nonce length | 1–128 characters. If empty or too long: 401 INVALID_SIGNATURE |
| Nonce reuse | The same nonce for the same operator is accepted only once within 5 minutes. From the second time on: 401 NONCE_REUSED |
- Keep your server clock synchronized with NTP. If the clock drifts by more than 5 minutes, every request will be rejected.
- Generate a new nonce for each request. We recommend a cryptographically secure random value of at least 16 bytes, encoded as hex.
- Even when resending the same request (e.g. after a timeout), generate a new timestamp, nonce, and signature. If the first request reached the platform and its nonce was already used, a resend with the same nonce is rejected with
NONCE_REUSED.
Platform verification order (Operator API)
The platform checks Operator API requests in the order below and returns the error of the first step that fails.
| Order | Check | On failure |
|---|---|---|
| 1 | Whether the operator in X-Operator exists | 401 INVALID_SIGNATURE |
| 2 | Whether the operator is active | 403 OPERATOR_DISABLED |
| 3 | Whether the request IP is in the registered IP allowlist | 403 IP_NOT_ALLOWED |
| 4 | Whether X-Api-Key matches the issued key | 401 INVALID_SIGNATURE |
| 5 | Reading the body | 400 INVALID_REQUEST |
| 6 | Timestamp format and ±5 minutes | 401 TIMESTAMP_OUT_OF_RANGE |
| 7 | Signature comparison | 401 INVALID_SIGNATURE |
| 8 | Nonce length | 401 INVALID_SIGNATURE |
| 9 | Nonce reuse | 401 NONCE_REUSED |
| 10 | Rate limit (200 requests per second per operator) | 429 RATE_LIMITED |
For the error response shape, see Conventions; for the full list of codes, see Error Codes.
Wallet callback verification order (operator implementation)
When you receive a wallet callback, we recommend checking it in the order below before any business processing.
- Confirm that
X-Operatorequals your operator code. - Confirm that
X-Timestampis an integer and within ±5 minutes of your server clock. - Compute the signature over the raw body bytes and compare it with
X-Signatureusing a constant-time comparison (hmac.Equal,crypto.timingSafeEqual,hmac.compare_digest). - If the signature matches, keep the nonce for at least 5 minutes and reject any nonce you have already seen. If you run multiple servers or your servers may restart, store nonces in shared storage such as Redis or a database (using only process memory weakens replay protection).
- Only after passing these checks, parse the JSON and proceed to
tx_ididempotency handling and balance processing.
If verification fails, respond with HTTP 200 + {"status":"INVALID_SIGNATURE"}. For how the platform handles each status, see the Wallet Callbacks overview.
Example code
The signing function, Operator API calls, and wallet callback verification are shown for each language. The examples assume the environment variables GAMES_API_BASE ({API_BASE} issued for your environment — Onboarding), GAMES_OPERATOR_CODE, GAMES_API_KEY, and GAMES_API_SECRET. The callback nonce duplicate check is omitted because it depends on your storage.
package games
import (
"bytes"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
"strconv"
"time"
)
// sign returns the lowercase-hex HMAC-SHA256 of the string to sign = ts \n nonce \n METHOD \n pathWithQuery \n body.
func sign(secret, ts, nonce, method, pathWithQuery string, body []byte) string {
mac := hmac.New(sha256.New, []byte(secret)) // the secret string's bytes, as is
mac.Write([]byte(ts + "\n" + nonce + "\n" + method + "\n" + pathWithQuery + "\n"))
mac.Write(body)
return hex.EncodeToString(mac.Sum(nil))
}
// Call signs and calls the Operator API.
// pathWithQuery is an already percent-encoded string (e.g. "/operator/v1/rounds?limit=50").
func Call(method, pathWithQuery string, body []byte) (*http.Response, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return nil, err
}
nonce := hex.EncodeToString(b)
ts := strconv.FormatInt(time.Now().UnixMilli(), 10)
if body == nil {
body = []byte{} // GET is signed with an empty body
}
req, err := http.NewRequest(method, os.Getenv("GAMES_API_BASE")+pathWithQuery, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
req.Header.Set("X-Operator", os.Getenv("GAMES_OPERATOR_CODE"))
req.Header.Set("X-Api-Key", os.Getenv("GAMES_API_KEY"))
req.Header.Set("X-Timestamp", ts)
req.Header.Set("X-Nonce", nonce)
req.Header.Set("X-Signature", sign(os.Getenv("GAMES_API_SECRET"), ts, nonce, method, pathWithQuery, body))
return http.DefaultClient.Do(req)
}
// VerifyCallback verifies a wallet callback's signature and returns the raw body (nonce duplicate check is separate).
func VerifyCallback(r *http.Request) ([]byte, bool) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil || r.Header.Get("X-Operator") != os.Getenv("GAMES_OPERATOR_CODE") {
return nil, false
}
ts := r.Header.Get("X-Timestamp")
ms, err := strconv.ParseInt(ts, 10, 64)
if err != nil || time.Since(time.UnixMilli(ms)).Abs() > 5*time.Minute {
return nil, false
}
// r.URL.RequestURI() = the raw path + query of the request line
want := sign(os.Getenv("GAMES_API_SECRET"), ts, r.Header.Get("X-Nonce"), r.Method, r.URL.RequestURI(), body)
return body, hmac.Equal([]byte(want), []byte(r.Header.Get("X-Signature")))
}import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
const SECRET = process.env.GAMES_API_SECRET; // the 64-character hex string, as is
// string to sign = ts \n nonce \n METHOD \n pathWithQuery \n body
export function sign(ts, nonce, method, pathWithQuery, body) {
return createHmac('sha256', SECRET)
.update(`${ts}\n${nonce}\n${method}\n${pathWithQuery}\n`)
.update(body) // strings as UTF-8, Buffers as raw bytes
.digest('hex');
}
// Operator API call. pathWithQuery is an already percent-encoded string.
export async function callApi(method, pathWithQuery, payload) {
const ts = String(Date.now());
const nonce = randomBytes(16).toString('hex');
const raw = payload === undefined ? '' : JSON.stringify(payload); // bytes sent = bytes signed
const res = await fetch(process.env.GAMES_API_BASE + pathWithQuery, {
method,
body: method === 'GET' ? undefined : raw,
headers: {
'Content-Type': 'application/json; charset=utf-8',
'X-Operator': process.env.GAMES_OPERATOR_CODE,
'X-Api-Key': process.env.GAMES_API_KEY,
'X-Timestamp': ts,
'X-Nonce': nonce,
'X-Signature': sign(ts, nonce, method, pathWithQuery, raw),
},
});
return res.json();
}
// Wallet callback signature verification. req is a node:http request, rawBody is the received body Buffer.
// (With Express, receive the raw body via express.raw({ type: 'application/json' }).) Nonce duplicate check is separate.
export function verifyCallback(req, rawBody) {
const ts = req.headers['x-timestamp'] ?? '';
const nonce = req.headers['x-nonce'] ?? '';
const sig = req.headers['x-signature'] ?? '';
if (req.headers['x-operator'] !== process.env.GAMES_OPERATOR_CODE) return false;
if (!/^\d+$/.test(ts) || Math.abs(Date.now() - Number(ts)) > 5 * 60 * 1000) return false;
// req.url = the raw path + query of the request line (in Express it changes inside sub-routers, so use req.originalUrl)
const want = Buffer.from(sign(ts, nonce, req.method, req.url, rawBody));
const got = Buffer.from(String(sig));
return want.length === got.length && timingSafeEqual(want, got);
}import hashlib, hmac, json, os, secrets, time
import urllib.request
SECRET = os.environ["GAMES_API_SECRET"].encode() # the 64-character hex string, as is
def sign(ts: str, nonce: str, method: str, path_with_query: str, body: bytes) -> str:
"""string to sign = ts \\n nonce \\n METHOD \\n path_with_query \\n body"""
msg = f"{ts}\n{nonce}\n{method}\n{path_with_query}\n".encode() + body
return hmac.new(SECRET, msg, hashlib.sha256).hexdigest()
def call_api(method: str, path_with_query: str, payload=None):
"""Operator API call. path_with_query is an already percent-encoded string."""
ts = str(int(time.time() * 1000))
nonce = secrets.token_hex(16)
raw = b"" if payload is None else json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode()
req = urllib.request.Request(
os.environ["GAMES_API_BASE"] + path_with_query,
data=None if method == "GET" else raw,
method=method,
headers={
"Content-Type": "application/json; charset=utf-8",
"X-Operator": os.environ["GAMES_OPERATOR_CODE"],
"X-Api-Key": os.environ["GAMES_API_KEY"],
"X-Timestamp": ts,
"X-Nonce": nonce,
"X-Signature": sign(ts, nonce, method, path_with_query, raw),
},
)
with urllib.request.urlopen(req, timeout=10) as res:
return json.load(res)
def verify_callback(method: str, path_with_query: str, headers: dict, raw_body: bytes) -> bool:
"""Wallet callback signature verification. headers has lowercase keys, raw_body is the received bytes as is. Nonce duplicate check is separate."""
ts = headers.get("x-timestamp", "")
if headers.get("x-operator") != os.environ["GAMES_OPERATOR_CODE"]:
return False
if not ts.isdigit() or abs(time.time() * 1000 - int(ts)) > 5 * 60 * 1000:
return False
want = sign(ts, headers.get("x-nonce", ""), method, path_with_query, raw_body)
return hmac.compare_digest(want, headers.get("x-signature", ""))Test vectors
Fixed inputs and expected signatures for checking your implementation. The secret below is a fake value for illustration only. The sign functions above in all three languages produce the same results.
Secret: 4f1c2a9e7b3d5f8061a2c4e6b8d0f1a3c5e7092b4d6f8a1c3e5072b4d6f8a9c11. POST request (game launch)
| Item | Value |
|---|---|
X-Timestamp | 1760000000000 |
X-Nonce | 9b1f0c7e2d4a6b8c0e1f2a3b |
| METHOD | POST |
| PATH_WITH_QUERY | /operator/v1/launch |
Body (a single line, no whitespace):
{"player":{"external_id":"user_1001","nickname":"Lucky7","currency":"PHP"},"game_code":"holdem_nlh","lang":"en"}X-Signature = 376a774f812e82f8e63532237da598e05f229c2c8caf1cc858d12eb949c255e52. GET request (encoded query)
| Item | Value |
|---|---|
X-Timestamp | 1760000000000 |
X-Nonce | 1a2b3c4d5e6f7a8b9c0d1e2f |
| METHOD | GET |
| PATH_WITH_QUERY | /operator/v1/rounds?from=2026-10-01T00%3A00%3A00%2B08%3A00&limit=50 |
| Body | (empty string) |
X-Signature = 2d61631759075b2049547a65929805acf090e9088f94e4a83dbfb896b4d539143. Wallet callback (debit)
This is the case where the wallet URL is https://api.acme.example/wallet.
| Item | Value |
|---|---|
X-Timestamp | 1760000000000 |
X-Nonce | c0ffee00c0ffee00c0ffee00 |
| METHOD | POST |
| PATH_WITH_QUERY | /wallet/debit |
Body (a single line, no whitespace):
{"amount":5000,"created_at":"2025-10-09T08:53:20.000Z","currency":"PHP","external_id":"user_1001","game_code":"live_baccarat","operator_session_id":null,"player_id":"pl_0192f3a1c4e87b2c9d0e1f2a3b4c5d6f","reason":"bet","round_id":"rnd_0192f3a1c4e87b2c9d0e1f2a3b4c5d71","table_id":"tbl_0192f3a1c4e87b2c9d0e1f2a3b4c5d70","tx_id":"tx_0192f3a1c4e87b2c9d0e1f2a3b4c5d6e"}X-Signature = 3f54f0c9c193f8234fd5bc296c48a6ed41d7221a9af0a5bf94b5d5aba43d6600Check the string to sign directly
The string to sign for example 1 consists of the five lines below (no newline after the last line). If your result differs, check the newline character first (\n, not \r\n) and then the body bytes.
1760000000000
9b1f0c7e2d4a6b8c0e1f2a3b
POST
/operator/v1/launch
{"player":{"external_id":"user_1001","nickname":"Lucky7","currency":"PHP"},"game_code":"holdem_nlh","lang":"en"}Common mistakes
| Symptom | Cause |
|---|---|
Every request returns INVALID_SIGNATURE | The secret was hex-decoded and used as the key. It must be used as the 64-character string, as is. |
Only POST returns INVALID_SIGNATURE | The signed body differs from the sent body (serialized twice, or the library re-serialized the body). Sign and send one and the same serialized byte sequence. |
Only GET with a query returns INVALID_SIGNATURE | The signed path differs from the actual request line. If you pass the query to the library as an object, its encoding or order may change. Use the same already-encoded string for both the URL and the signature. |
| A date-range query silently falls back to the default (last 24 hours) | The + in from=…+08:00 was not encoded and was read as a space. Send it as %2B. |
| A signature in uppercase hex is rejected | Signatures are compared as lowercase hex. |
TIMESTAMP_OUT_OF_RANGE | A timestamp in seconds was sent, or the server clock is off. Send milliseconds and synchronize with NTP. |
A retried request returns NONCE_REUSED | The same nonce was reused. Generate a new nonce, timestamp, and signature on retries as well. |
| Wallet callback verification fails only in production | A reverse proxy strips the leading part of the path before passing it to the app. The platform signs with the original path of the registered wallet URL, so if the path your app receives differs, restore the original path before verifying. |
| Wallet callback verification fails intermittently | The parsed JSON was re-serialized for verification. Verify with the raw bytes. |
Only large bodies return INVALID_SIGNATURE | The body exceeded the size limit (64KB) and was verified truncated. See Conventions |