Headers and signature: GA → your wallet
Every call GA sends to your wallet looks like this:
POST /v2/wallet/debit HTTP/1.1
Host: wallet.operator.example
Content-Type: application/json
X-API-Key-Id: key-live-01
X-Request-Id: 0198a1d0-9a30-7f08-a7dd-713e4fd33db0
Idempotency-Key: op-bet-48912
X-GA-Signature: t=1783944000,v1=8a75f608c2a8e178d89a055198050e9a2d5341c2f390ac1d2c2c68e433eb5f2d
X-API-Key-Idnames the wallet signing key. It’s the key id you see in the Operator Portal.X-Request-Idis this one network attempt. It equalsrequest_idin the body andpayload.meta.request_id, and changes on every retry.Idempotency-Keyequalspayload.meta.op_id, the business operation. It stays the same across retries. Use it, or the body field, as your idempotency key.X-GA-Signaturecarries the timestampt(unix seconds, the same instant as the body’sts) and the signaturev1.
Verify in this order, before you parse the body:
- Find your secret by
X-API-Key-Id. - Split
X-GA-Signatureintotandv1. - Reject if
tis more than 300 seconds away from your clock. - Compute
SHA256_HEXof the raw body bytes exactly as received. Don’t re-serialize. - Compute
HMAC-SHA256(secret, t + "." + request_id + "." + body_sha256)whererequest_idis the top-levelrequest_idfield of the body (identical to theX-Request-Idheader). Compare withv1using a constant-time compare. - Check that
operator_idin the body is your operator id.
If any step fails, answer HTTP 401 or 403 with no body. GA treats that as “no answer” and retries. A bet that never verifies ends in a rollback.
Worked example
Use these values to test your verification code before GA sends anything. Secret, body, and timestamp are fixed, so your output must match to the byte.
| Input | Value |
|---|---|
| Wallet HMAC secret | sec-cert-01 |
t | 1783944000 (the body’s ts 2026-07-13T12:00:00Z) |
request_id | 0198a1d0-9a30-7f08-a7dd-713e4fd33db0 |
| Raw body (one line, no trailing newline) | {"request_id":"0198a1d0-9a30-7f08-a7dd-713e4fd33db0","ts":"2026-07-13T12:00:00Z","operator_id":"0197aaaa-0000-7000-a000-000000000002","action":"get_balance","payload":{"meta":{"request_id":"0198a1d0-9a30-7f08-a7dd-713e4fd33db0","op_id":"op-bal-1001","operator_id":"0197aaaa-0000-7000-a000-000000000002","player_ref":"player-10428"},"currency":"EUR"}} |
SHA256_HEX(body) | f6ebf9ad22209d716b34fff30cb66d7232e96a2d403e86ed0dc23e5da9f8ad6b |
| Signing input | 1783944000.0198a1d0-9a30-7f08-a7dd-713e4fd33db0.f6ebf9ad22209d716b34fff30cb66d7232e96a2d403e86ed0dc23e5da9f8ad6b |
Expected v1 | 8a75f608c2a8e178d89a055198050e9a2d5341c2f390ac1d2c2c68e433eb5f2d |
Node.js:
const crypto = require("crypto");
function verify(rawBody, headers, secret) {
const m = /t=(\d+),v1=([0-9a-f]{64})/.exec(headers["x-ga-signature"] || "");
if (!m) return false;
const [, t, v1] = m;
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const requestId = JSON.parse(rawBody).request_id;
const bodySha = crypto.createHash("sha256").update(rawBody).digest("hex");
const expected = crypto.createHmac("sha256", secret).update(`${t}.${requestId}.${bodySha}`).digest();
return crypto.timingSafeEqual(expected, Buffer.from(v1, "hex"));
}
PHP:
function verify(string $rawBody, array $headers, string $secret): bool {
if (!preg_match('/t=(\d+),v1=([0-9a-f]{64})/', $headers['X-GA-Signature'] ?? '', $m)) return false;
[, $t, $v1] = $m;
if (abs(time() - (int)$t) > 300) return false;
$requestId = json_decode($rawBody, true)['request_id'] ?? '';
$input = $t . '.' . $requestId . '.' . hash('sha256', $rawBody);
return hash_equals(hash_hmac('sha256', $input, $secret), $v1);
}
Go:
func verify(rawBody []byte, sigHeader, secret string) bool {
var t int64; var v1 string
if _, err := fmt.Sscanf(sigHeader, "t=%d,v1=%s", &t, &v1); err != nil { return false }
if d := time.Now().Unix() - t; d > 300 || d < -300 { return false }
var env struct{ RequestID string `json:"request_id"` }
_ = json.Unmarshal(rawBody, &env)
sum := sha256.Sum256(rawBody)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(fmt.Sprintf("%d.%s.%s", t, env.RequestID, hex.EncodeToString(sum[:]))))
want, _ := hex.DecodeString(v1)
return hmac.Equal(mac.Sum(nil), want)
}
Python:
import hashlib, hmac, json, re, time
def verify(raw_body: bytes, sig_header: str, secret: str) -> bool:
m = re.fullmatch(r"t=(\d+),v1=([0-9a-f]{64})", sig_header or "")
if not m: return False
t, v1 = m.groups()
if abs(time.time() - int(t)) > 300: return False
request_id = json.loads(raw_body)["request_id"]
signing_input = f"{t}.{request_id}.{hashlib.sha256(raw_body).hexdigest()}"
expected = hmac.new(secret.encode(), signing_input.encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
Java:
static boolean verify(byte[] rawBody, String sigHeader, String secret) throws Exception {
var m = java.util.regex.Pattern.compile("t=(\\d+),v1=([0-9a-f]{64})").matcher(java.util.Objects.requireNonNullElse(sigHeader, ""));
if (!m.matches()) return false;
long t = Long.parseLong(m.group(1)); String v1 = m.group(2);
if (Math.abs(System.currentTimeMillis() / 1000 - t) > 300) return false;
String requestId = new com.fasterxml.jackson.databind.ObjectMapper().readTree(rawBody).get("request_id").asText();
String bodySha = java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(rawBody));
var mac = javax.crypto.Mac.getInstance("HmacSHA256");
mac.init(new javax.crypto.spec.SecretKeySpec(secret.getBytes(java.nio.charset.StandardCharsets.UTF_8), "HmacSHA256"));
byte[] expected = mac.doFinal((t + "." + requestId + "." + bodySha).getBytes(java.nio.charset.StandardCharsets.UTF_8));
return java.security.MessageDigest.isEqual(expected, java.util.HexFormat.of().parseHex(v1));
}
Signature: your calls to GA
Same header names (X-API-Key-Id, X-GA-Signature: t=…,v1=…), your Operator API key pair, the same 300-second window, but a different signing input:
SIGNING_INPUT = METHOD + "\n" + PATH + "\n" + t + "\n" + SHA256_HEX(raw_body)
v1 = HMAC-SHA256(operator_api_secret, SIGNING_INPUT)
METHOD is POST, PATH is the URL path only (/v2/aggregator/launch_game), t is unix seconds. The Postman collections GA_Aggregator_API_v2 and GA_Features_API_v2 compute this for you. Set ga_api_key_id and ga_hmac_secret.
Use these values to test your signing code before you call GA. Secret, body, and timestamp are fixed, so your output must match to the byte.
| Input | Value |
|---|---|
| Operator API HMAC secret | sec-op-cert-01 |
METHOD | POST |
PATH | /v2/aggregator/launch_game |
t | 1783944000 |
| Raw body (one line, no trailing newline) | {"player_ref":"player-10428","game_id":"pragmatic-vs20olympgate","currency":"EUR","token":"lt-cert-556677","return_url":"https://operator.example/lobby"} |
SHA256_HEX(body) | fd1f9b4dff076382033df7e7f9e575f62cebefb6cdc28276fc6e95ec4f411eb0 |
| Signing input | POST\n/v2/aggregator/launch_game\n1783944000\nfd1f9b4dff076382033df7e7f9e575f62cebefb6cdc28276fc6e95ec4f411eb0 |
Expected v1 | d1de5557c4a004e53eec6e34930e331638497c15884d17e2a649a75ce82d5283 |
The header you send is:
X-API-Key-Id: key-cert-op-01
X-GA-Signature: t=1783944000,v1=d1de5557c4a004e53eec6e34930e331638497c15884d17e2a649a75ce82d5283
Note the separator. Unlike §2.1’s GA→wallet signature (which joins
t, request_id, and the body hash with .), the signing input here joins
METHOD, PATH, t, and the body hash with newlines (\n). There is no trailing newline after the hash.