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 (configured separately from your API key; confirm the callback settings available to your deployment)
  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 {
// 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"),
);
}
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

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_id is provider-unique, not globally unique. Persist results by integration/organization, provider_code and transaction_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.

With authorized test traffic and agreed funding, verify your signature check rejects tampered payloads before going live. See Testing your integration.