# Request & signing

## Headers and signature: GA → your wallet

Every call GA sends to your wallet looks like this:

```http
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-Id` names the wallet signing key. It's the key id you see in the Operator Portal.
- `X-Request-Id` is this one network attempt. It equals `request_id` in the body and `payload.meta.request_id`, and changes on every retry.
- `Idempotency-Key` equals `payload.meta.op_id`, the business operation. It stays the same across retries. Use it, or the body field, as your idempotency key.
- `X-GA-Signature` carries the timestamp `t` (unix seconds, the same instant as the body's `ts`) and the signature `v1`.

Verify in this order, **before** you parse the body:

1. Find your secret by `X-API-Key-Id`.
2. Split `X-GA-Signature` into `t` and `v1`.
3. Reject if `t` is more than 300 seconds away from your clock.
4. Compute `SHA256_HEX` of the **raw body bytes** exactly as received. Don't re-serialize.
5. Compute `HMAC-SHA256(secret, t + "." + request_id + "." + body_sha256)` where `request_id` is the top-level `request_id` field of the body (identical to the `X-Request-Id` header). Compare with `v1` using a constant-time compare.
6. Check that `operator_id` in 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:

```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:

```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:

```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:

```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:

```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:

```text
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:

```http
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.
