Request signatures
Every wallet callback delivered by Aggregator.gg includes an HMAC-SHA256 signature in the X-SIGNATURE header. Verify the signature before debiting or crediting any player wallet. Without verification, an attacker who learns your callback URL could submit forged wallet movements.
Signature model
Section titled “Signature model”What arrives at your endpoint:
POST /your/callback-url HTTP/1.1Host: your-server.example.comContent-Type: application/jsonX-SIGNATURE: f166802d56c97d2666489cd600eb60c2442b1c62b5fc584cd3f213a935d04f46
{"action":"BET","transaction_type":"bet","is_free":false,"free_round_grant_id":"","transaction_id":"tx_bet_001","amount":500,"currency":"EUR","player_id":"player_42","provider_code":"truelabs","game":"book-of-sun","game_id":"d4f7a2b1-3c8e-4f5a-9b6d-1e2f3a4b5c6d","round_id":"round_abc","finished":false,"session_id":"8c8e8c8e-8c8e-4c8e-8c8e-8c8e8c8e8c8e"}The value of X-SIGNATURE is a lowercase hex-encoded HMAC-SHA256 digest: the raw 64-character hex string with no sha256= prefix, no t= timestamp, no v1= scheme marker, and no separators.
Test vector. With the test secret test-callback-secret-0123456789 (never use it for real traffic), the 355-byte body above, exactly as shown with no trailing newline, signs to the X-SIGNATURE value above. Change "amount":500 to "amount":501 and verification must fail.
Required inputs
Section titled “Required inputs”Two inputs produce the signature, and both must be exact:
- The raw request body bytes. Do not re-serialise the JSON: re-serialisation changes whitespace and breaks the signature.
- Your operator’s
callback_secret, which you set yourself in the cabinet under API -> Callbacks (the integration setup wizard writes the same setting and can generate a value for you). The cabinet stores it encrypted and never displays it again, so keep it in your secret manager from the moment you set it. It is a separate credential; youragg_API key is never the signing secret.
For platform accounts the signer is currently shared at the parent platform; see Platform accounts for configuration and rotation limits.
Load the signing secret from a protected environment variable or secret manager. Missing or empty configuration must fail closed. Keep the secret, raw body and signature out of command arguments and diagnostic logs.
Verification flow
Section titled “Verification flow”- Read the raw body bytes before any JSON parsing.
- Require a nonempty configured
callback_secretand exactly 64 lowercase hexadecimal characters inX-SIGNATURE; reject invalid inputs before decoding the header. - Compute HMAC-SHA256 with that secret and hex-encode the digest (lowercase).
- Constant-time compare with the
X-SIGNATUREheader value. - Reject the request with a
4xxwhen the header is missing or the comparison fails, and only then parse the JSON and touch the wallet.
Step check: verify a legitimate callback locally using its original raw bytes. Change one byte in the body, supply a malformed or non-ASCII header, or remove the configured secret: each case must fail verification without moving money. Record only the check outcome.
Examples
Section titled “Examples”import crypto from "node:crypto";
export function verifyAggregatorSignature( rawBody: Buffer, signatureHeader: string | undefined,): boolean { // The wire format is exactly 64 lowercase hexadecimal characters. if (typeof signatureHeader !== "string" || signatureHeader.length !== 64 || !/^[0-9a-f]+$/.test(signatureHeader)) return false;
const secret = process.env.AGGREGATOR_CALLBACK_SECRET; if (!secret) return false;
const expected = crypto .createHmac("sha256", secret) .update(rawBody) .digest("hex");
return crypto.timingSafeEqual( Buffer.from(expected, "hex"), Buffer.from(signatureHeader, "hex"), );}<?php
function verifyAggregatorSignature(string $rawBody, ?string $signatureHeader): bool { if ($signatureHeader === null || preg_match('/\A[0-9a-f]{64}\z/', $signatureHeader) !== 1) { return false; } $secret = getenv('AGGREGATOR_CALLBACK_SECRET'); if ($secret === false || $secret === '') return false; $expected = hash_hmac('sha256', $rawBody, $secret); return hash_equals($expected, $signatureHeader);}import hmacimport hashlibimport osimport re
def verify_aggregator_signature(raw_body: bytes, signature_header: str | None) -> bool: if not isinstance(signature_header, str) or re.fullmatch(r"[0-9a-f]{64}", signature_header) is None: return False secret = os.environ.get("AGGREGATOR_CALLBACK_SECRET") if not secret: return False expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature_header)These functions implement signature verification only. Your HTTP handler must preserve raw bytes and still validate the payload, trusted wallet context and atomic settlement boundary.
Common mistakes
Section titled “Common mistakes”| Mistake | Symptom | Fix |
|---|---|---|
| Re-serialising the JSON before HMAC | Signature always invalid even when the secret is correct | Use the raw bytes of the request body; never parse then re-serialise |
| String comparison instead of constant-time | Timing-attack vulnerability | Use crypto.timingSafeEqual (with a byte-length check), hash_equals, or hmac.compare_digest |
| Accepting malformed signature text | Permissive hex decoding can accept trailing garbage; non-ASCII text can make a comparison throw | Require exactly 64 lowercase hexadecimal characters before decoding or comparing, then use the constant-time primitive |
| Missing or empty signing-secret configuration | An empty-key HMAC can authenticate attacker-created requests | Reject verification when protected configuration is missing or empty |
Using the agg_ API key as the secret |
Verification always fails | The callback_secret is a separate value you set under API -> Callbacks |
Replay protection
Section titled “Replay protection”Aggregator.gg does not emit a timestamp header on outbound callbacks. The replay defence is idempotency at the transaction layer, not signature-level timestamps:
- Persist results by the trusted integration/organization context,
provider_codeandtransaction_id. The ID is provider-unique, not globally unique. For a duplicate of that full identity with the sametransaction_type,amount,currencyandplayer_id, replay the stored original status and body without another wallet movement; a repeat where one of those differs gets a4xxand moves no money. Matching ID text from another provider or integration must remain a distinct transaction. - A replayed callback therefore cannot double-charge the player even if an attacker captures and re-sends it: your deduplication catches it before any wallet movement.
This is the current callback contract. A valid signature alone does not establish freshness; persistent scoped idempotency is still required. The full retry and deduplication semantics live in Callbacks and Idempotency.
Step check: repeat a legitimate callback within the same scoped identity and confirm the original status and body are replayed with no second wallet movement. Confirm that identical ID text from another provider or integration does not reuse that result.