Signature verification
Every wallet callback delivered by The Aggregator includes an HMAC-SHA256 signature in the X-SIGNATURE header. You must verify the signature before debiting or crediting any player wallet. Skipping verification is the #1 fraud vector in B2B gaming.
What you receive
Section titled “What you receive”POST /your/callback-url HTTP/1.1Host: your-server.example.comContent-Type: application/jsonX-SIGNATURE: 8b1a9953c4611296a827abf8c47804d7e8c8e8c8e8c8e8c8e8c8e8c8e8c8e8c8
{"action":"BET","transaction_type":"bet","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":"..."}The value of X-SIGNATURE is a lowercase hex-encoded HMAC-SHA256 digest. No sha256= prefix, no separator — just the raw 64-character hex string.
What you compute
Section titled “What you compute”- Take the raw request body bytes (do not re-serialise the JSON — re-serialisation changes whitespace and breaks the signature)
- HMAC-SHA256 with your operator’s
callback_secret(set in the cabinet under Game Providers -> Settings) - Hex-encode the digest (lowercase)
- Constant-time compare with the value in the
X-SIGNATUREheader
Snippets
Section titled “Snippets”import crypto from "node:crypto";
const SECRET = process.env.AGGREGATOR_CALLBACK_SECRET!;
export function verifyAggregatorSignature( rawBody: Buffer, signatureHeader: string | undefined,): boolean { if (!signatureHeader) return false;
const expected = crypto .createHmac("sha256", SECRET) .update(rawBody) .digest("hex");
// Node's crypto.timingSafeEqual throws when buffer lengths differ. // Guard with a length check so a missing/truncated header returns false // instead of crashing the request handler. if (expected.length !== signatureHeader.length) return false;
return crypto.timingSafeEqual( Buffer.from(expected, "hex"), Buffer.from(signatureHeader, "hex"), );}<?php
function verifyAggregatorSignature(string $rawBody, ?string $signatureHeader): bool { if ($signatureHeader === null || $signatureHeader === '') { return false; }
$secret = getenv('AGGREGATOR_CALLBACK_SECRET'); if ($secret === false) { throw new RuntimeException('AGGREGATOR_CALLBACK_SECRET is not set'); }
$expected = hash_hmac('sha256', $rawBody, $secret); // hash_equals is length-safe and constant-time. return hash_equals($expected, $signatureHeader);}import hmacimport hashlibimport os
def verify_aggregator_signature(raw_body: bytes, signature_header: str | None) -> bool: if not signature_header: return False
secret = os.environ["AGGREGATOR_CALLBACK_SECRET"].encode() expected = hmac.new(secret, raw_body, hashlib.sha256).hexdigest() # hmac.compare_digest is length-safe and constant-time. return hmac.compare_digest(expected, signature_header)Common mistakes
Section titled “Common mistakes”| Mistake | Symptom | Fix |
|---|---|---|
| Re-serialising the JSON before HMAC | Signature always invalid even when secret is correct | Use the raw bytes of the request body; do not json.loads() then json.dumps() |
| String comparison instead of constant-time | Timing-attack vulnerability | Use crypto.timingSafeEqual (with length check) / hash_equals / hmac.compare_digest |
| Skipping the length check in Node | Uncaught exception → 500 → retry storm when an unsigned request hits the endpoint | Always compare lengths before calling timingSafeEqual (PHP and Python primitives handle this automatically) |
Using sk_live_* API key as the secret |
Always fails — wrong secret | The callback_secret is separate; find it in cabinet -> Game Providers -> Settings |
Replay protection
Section titled “Replay protection”The Aggregator does not emit a timestamp header on outbound callbacks. The platform’s replay defence is idempotency at the transaction layer, not signature-level timestamps:
- Every callback body includes a unique
transaction_id. Persist everytransaction_idyou have processed. If a callback arrives with atransaction_idyou have already credited/debited, return the original response without modifying the player balance. - A replayed callback therefore cannot double-charge the player even if an attacker captures and re-sends it — your dedup logic catches it before any wallet movement.
- The full retry-and-idempotency semantics are documented in Wallet callbacks and Idempotency.
This is the industry-standard pattern for iGaming aggregators (where the canonical anti-replay token is the transaction ID, signed inside the body, not a separate timestamp header). Stripe-style timestamp+signature schemes are for one-shot webhooks (subscription created, payment captured), not for high-frequency money-path callbacks where idempotency is the natural defence.
Test your implementation
Section titled “Test your implementation”Use welcome credits in production to verify your signature check rejects tampered payloads before going live. See Testing your integration.