Skip to content

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.

POST /your/callback-url HTTP/1.1
Host: your-server.example.com
Content-Type: application/json
X-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.

  1. Take the raw request body bytes (do not re-serialise the JSON — re-serialisation changes whitespace and breaks the signature)
  2. HMAC-SHA256 with your operator’s callback_secret (set in the cabinet under Game Providers -> Settings)
  3. Hex-encode the digest (lowercase)
  4. Constant-time compare with the value in the X-SIGNATURE header
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"),
);
}
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

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 every transaction_id you have processed. If a callback arrives with a transaction_id you 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.

Use welcome credits in production to verify your signature check rejects tampered payloads before going live. See Testing your integration.