Skip to content

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 ​

HeaderOperator API (sent by operator)Wallet callback (sent by platform)Value
Content-TypeSentSentapplication/json; charset=utf-8
X-OperatorRequiredSentOperator code (e.g. acme)
X-Api-KeyAlways sendNot sentThe issued API key (ak_…). If the value is wrong: 401 INVALID_SIGNATURE
X-TimestampRequiredSentRequest time as a decimal string of Unix epoch milliseconds (e.g. 1760000000000)
X-NonceRequiredSent (24 characters)A random string newly generated for each request, 1–128 characters
X-SignatureRequiredSentHMAC-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 BODY
X-Signature = lowercase_hex( HMAC-SHA256( key = API secret, message = string to sign ) )
PartRule
X-TimestampThe same string as the header value
X-NonceThe same string as the header value
METHODUppercase HTTP method (GET, POST, PUT, PATCH, DELETE)
PATH_WITH_QUERYThe 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
BODYThe exact bytes of the body actually sent. If there is no body (GET, etc.), an empty string
KeyThe 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.
  • POST and DELETE requests 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, and PATH_WITH_QUERY is the path of the registered wallet URL with the endpoint appended. If the wallet URL is https://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, and X-Signature.

Timestamp and nonce ​

RuleValue
Tolerance±5 minutes relative to the receiving server's clock. Beyond that: 401 TIMESTAMP_OUT_OF_RANGE
Nonce length1–128 characters. If empty or too long: 401 INVALID_SIGNATURE
Nonce reuseThe 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.

OrderCheckOn failure
1Whether the operator in X-Operator exists401 INVALID_SIGNATURE
2Whether the operator is active403 OPERATOR_DISABLED
3Whether the request IP is in the registered IP allowlist403 IP_NOT_ALLOWED
4Whether X-Api-Key matches the issued key401 INVALID_SIGNATURE
5Reading the body400 INVALID_REQUEST
6Timestamp format and ±5 minutes401 TIMESTAMP_OUT_OF_RANGE
7Signature comparison401 INVALID_SIGNATURE
8Nonce length401 INVALID_SIGNATURE
9Nonce reuse401 NONCE_REUSED
10Rate 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.

  1. Confirm that X-Operator equals your operator code.
  2. Confirm that X-Timestamp is an integer and within ±5 minutes of your server clock.
  3. Compute the signature over the raw body bytes and compare it with X-Signature using a constant-time comparison (hmac.Equal, crypto.timingSafeEqual, hmac.compare_digest).
  4. 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).
  5. Only after passing these checks, parse the JSON and proceed to tx_id idempotency 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.

go
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")))
}
js
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);
}
python
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: 4f1c2a9e7b3d5f8061a2c4e6b8d0f1a3c5e7092b4d6f8a1c3e5072b4d6f8a9c1

1. POST request (game launch) ​

ItemValue
X-Timestamp1760000000000
X-Nonce9b1f0c7e2d4a6b8c0e1f2a3b
METHODPOST
PATH_WITH_QUERY/operator/v1/launch

Body (a single line, no whitespace):

json
{"player":{"external_id":"user_1001","nickname":"Lucky7","currency":"PHP"},"game_code":"holdem_nlh","lang":"en"}
X-Signature = 376a774f812e82f8e63532237da598e05f229c2c8caf1cc858d12eb949c255e5

2. GET request (encoded query) ​

ItemValue
X-Timestamp1760000000000
X-Nonce1a2b3c4d5e6f7a8b9c0d1e2f
METHODGET
PATH_WITH_QUERY/operator/v1/rounds?from=2026-10-01T00%3A00%3A00%2B08%3A00&limit=50
Body(empty string)
X-Signature = 2d61631759075b2049547a65929805acf090e9088f94e4a83dbfb896b4d53914

3. Wallet callback (debit) ​

This is the case where the wallet URL is https://api.acme.example/wallet.

ItemValue
X-Timestamp1760000000000
X-Noncec0ffee00c0ffee00c0ffee00
METHODPOST
PATH_WITH_QUERY/wallet/debit

Body (a single line, no whitespace):

json
{"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 = 3f54f0c9c193f8234fd5bc296c48a6ed41d7221a9af0a5bf94b5d5aba43d6600

Check 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 ​

SymptomCause
Every request returns INVALID_SIGNATUREThe secret was hex-decoded and used as the key. It must be used as the 64-character string, as is.
Only POST returns INVALID_SIGNATUREThe 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_SIGNATUREThe 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 rejectedSignatures are compared as lowercase hex.
TIMESTAMP_OUT_OF_RANGEA timestamp in seconds was sent, or the server clock is off. Send milliseconds and synchronize with NTP.
A retried request returns NONCE_REUSEDThe same nonce was reused. Generate a new nonce, timestamp, and signature on retries as well.
Wallet callback verification fails only in productionA 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 intermittentlyThe parsed JSON was re-serialized for verification. Verify with the raw bytes.
Only large bodies return INVALID_SIGNATUREThe body exceeded the size limit (64KB) and was verified truncated. See Conventions

Docs v1.0.0 · Operator API v1