Authentication
Every request under /v1 carries three headers. There are no sessions, cookies or tokens.
| Header | Value |
|---|---|
| X-Agg-Key-Id | The key_id the request is signed with |
| X-Agg-Timestamp | Unix time in seconds, UTC. Accepted within ±5 minutes of our clock |
| X-Agg-Signature | Lowercase hex of HMAC_SHA256(secret, timestamp + "." + payload) |
- 1Request
- 2X-Agg-Timestamp
- 3Canonical string: timestamp + "." + payload
- 4HMAC-SHA256 with your secret
- 5X-Agg-Signature (lowercase hex)
What is signed
- A request with a body (POST): the raw body, the exact bytes you send. Sign the string you send, not an object you serialise again; whitespace, key order and escaped slashes all change the digest without changing the JSON.
- A request without a body (GET, and a POST with an empty body): the request line, method, a space, then path and query exactly as sent:
GET /v1/games?limit=50. - The comparison is constant-time and ignores the case of the hex digits.
Code samples
KEY_ID=ak_5f2c9e01a34b7d8e2f0c1b6a
SECRET=… # never in a URL, never in a log
BASE=https://sandbox-api.example.com
# GET: sign the request line — method, path and query exactly as sent.
TS=$(date +%s)
PAYLOAD='GET /v1/games?limit=50'
SIG=$(printf '%s.%s' "$TS" "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')
curl "$BASE/v1/games?limit=50" \
-H "X-Agg-Key-Id: $KEY_ID" -H "X-Agg-Timestamp: $TS" -H "X-Agg-Signature: $SIG"
# POST: sign the raw body — the exact bytes you send.
TS=$(date +%s)
BODY='{"player_id":"p-1","currency":"EUR","game_id":"6ddd5f30-0b1c-4c52-9f3e-2a7c1b8e4d10"}'
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')
curl "$BASE/v1/sessions" -H "Content-Type: application/json" --data-binary "$BODY" \
-H "X-Agg-Key-Id: $KEY_ID" -H "X-Agg-Timestamp: $TS" -H "X-Agg-Signature: $SIG"GET /v1/ping. It has no side effects and tells you which of the three is wrong: the headers, the clock or the digest. A timestamp outside the window answers UNAUTHORIZED with retryable: true; every other signature failure answers retryable: false.Test vectors
Run your signing function on these inputs before anything else. Secret sandbox_test_secret_not_for_use, timestamp 1790000000. The same three are checked by the API's own tests.
| Case | Payload | X-Agg-Signature |
|---|---|---|
| GET without a query | GET /v1/ping | 0387fadb9dd2317c2341b3a7dd751d444ae519125b4bcee70bd97d0b1b442356 |
| GET with a query | GET /v1/games?limit=50&provider_code=pragmatic | 6c5e583527feed45d179c6ea05db9c916bc1c290df7fcd402000e704db0f69c3 |
| POST with a body | {"player_id":"p-1","currency":"EUR","game_id":"g-1"} | bdb3dd91f3dd07fc2839b2e4196fb4eaeea3065fd4f155c1b629339908d01c1d |
Server-side check
When a real request is refused, let the sandbox look at it: it checks your signature with your secret and tells you which step fails and what we signed. It never returns the signature it expected. In the Operator Portal this is Validation → Signature check; the same check is an unsigned sandbox endpoint:
/v1/debug/signature
Sandbox only: check a signature step by step
{
"key_id": "ak_…",
"timestamp": "1790000000",
"signature": "…",
"method": "GET",
"path": "/v1/games?limit=50",
"body": null
}The answer carries valid, a one-sentence diagnosis, each step (key, timestamp with its drift from our clock, payload, signature), the canonical_string we signed and hints: the common mistakes your signature turned out to match, such as a path signed without its query, a missing timestamp prefix, a lowercase method, a timestamp in milliseconds or a trailing newline. 30 checks a minute per key and 60 per caller address; not available in production.
Signature tester
Paste the values of a failing request and compare the canonical string and signature with what your code produced.
Runs in your browser with Web Crypto. Nothing you type is sent anywhere. Use sandbox secrets only.
1759150800.{"player_id":"p-1","currency":"EUR","game_id":"6ddd5f30-0b1c-4c52-9f3e-2a7c1b8e4d10"}enter a secret
