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(configured separately from your API key; confirm the callback settings available to your deployment) - 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 { // The wire format is exactly 64 lowercase hexadecimal characters. if (typeof signatureHeader !== "string" || signatureHeader.length !== 64 || !/^[0-9a-f]+$/.test(signatureHeader)) return false;
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); // hash_equals is length-safe and constant-time. 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() # 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 | Validate the 64-character lowercase hex format, then use crypto.timingSafeEqual / hash_equals / hmac.compare_digest |
| Accepting malformed signature text | Invalid hex or non-ASCII input can throw during decoding or comparison | Reject anything except exactly 64 lowercase hex characters before decoding or comparing |
| Using the API key as the signing secret | Always fails — wrong secret | Use the separate callback_secret configured for your integration; confirm the callback settings for your deployment |
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:
- A callback’s
transaction_idis provider-unique, not globally unique. Persist results by integration/organization,provider_codeandtransaction_id. For a duplicate of that full identity, replay the stored original status and body without another wallet movement. The same opaque ID from a different provider or integration must not share that result. - 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”With authorized test traffic and agreed funding, verify your signature check rejects tampered payloads before going live. See Testing your integration.