Skip to content

Wallet Callback Integration Guide

  1. A player opens a game through your platform via The Aggregator.
  2. The game provider sends a callback to The Aggregator at POST /callbacks/{provider_code}/{action}.
  3. The Aggregator verifies the provider’s signature, records the transaction, and forwards a normalized payload to your configured callback_url.
  4. Your server processes the wallet event (debit or credit the player’s balance) and returns a JSON response.
Player -> Game Provider -> The Aggregator -> Your Server
|
<- response <--------+

The Aggregator acts as a normalizer: regardless of which game provider originates the event, you always receive the same payload shape and signing scheme.


The Aggregator sends a POST request to your callback_url with the following format.

Header Value
Content-Type application/json
X-SIGNATURE HMAC-SHA256 hex digest of the raw JSON body, keyed with your callback_secret
{
"action": "BET-WIN",
"transaction_type": "bet",
"is_free": false,
"free_round_grant_id": "",
"transaction_id": "provider-unique-tx-id",
"amount": 150,
"currency": "EUR",
"player_id": "player-123",
"provider_code": "truelabs",
"game": "gates-of-olympus",
"game_id": "550e8400-e29b-41d4-a716-446655440000",
"round_id": "round-123",
"finished": false,
"session_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
Field Type Description
action string Provider-specific action label (e.g. "BET-WIN", "BET", "WIN"). Use transaction_type for branching logic.
transaction_type string One of "bet", "win", or "refund". This is the field you use to decide how to update the player’s balance.
is_free boolean true when this movement belongs to a platform-issued free round. A free bet is grant-funded — do not debit the player’s real-money balance for it. See the Free Rounds guide. May be absent on some provider legs — treat a missing value as false.
free_round_grant_id string The free-round grant this movement belongs to; empty string for normal real-money play. May be absent on some provider legs — treat a missing value as empty.
transaction_id string Provider-unique identifier for this transaction. Use as your idempotency key.
amount integer Transaction amount in minor units (e.g. 150 = 1.50 EUR). Always positive. Same unit as the response balance.
currency string ISO 4217 currency code (e.g. "EUR", "USD").
player_id string The player identifier you provided when launching the game session.
provider_code string Identifies the game provider (e.g. "truelabs", "bgaming").
game string Game slug or name from the provider.
game_id string UUID of the game in The Aggregator’s catalog.
round_id string Groups related transactions into a single game round.
finished boolean true on the final callback of a round.
session_id string UUID of the game session in The Aggregator.

Every forwarded callback is signed with your callback_secret using HMAC-SHA256. You must verify the signature before processing any callback.

  1. Read the raw request body as bytes. Do not parse or re-serialize it first.
  2. Compute the HMAC-SHA256 of those bytes using your callback_secret as the key.
  3. Compare the resulting hex digest with the value in the X-SIGNATURE header using a constant-time comparison function.
expected = HMAC-SHA256(raw_body_bytes, callback_secret)
valid = constant_time_compare(expected, request.headers["X-SIGNATURE"])

Important: The body is serialized with compact JSON (no spaces after separators). If you re-serialize the parsed JSON to verify the signature, the result will not match. Always use the raw bytes.


Your server must return HTTP 200 with a JSON body containing the player’s updated balance:

{
"balance": 9850,
"currency": "EUR",
"player_id": "player-123"
}
Field Type Description
balance integer Required. Player’s balance after applying the transaction, in minor units (cents) — the same unit as the request amount.
currency string Required. ISO 4217 currency code. Must match the request currency.
player_id string Optional, recommended: echo back the player_id from the request. The platform validates only balance + currency.

If your endpoint returns a non-2xx status or non-JSON body, The Aggregator treats it as a failure and triggers the retry and circuit breaker mechanisms described below.

When the player cannot afford a bet, decline it explicitly — this is a normal business outcome, not a server error:

  • HTTP 402, or
  • HTTP 200 with a body-level status marker: { "status": 402, "balance": <current>, "currency": "EUR" }.

Either form is recognized as an insufficient-funds decline: the bet is not billed, the provider rolls the round back, and nothing is retried. Never answer a business decline with a 5xx — a 5xx means “transient platform failure” and triggers the retry/backpressure machinery below.


The player places a bet. Debit the player’s balance by amount. amount is already in minor units, so subtract it directly — no conversion.

player.balance -= callback.amount // integer minor units

The player wins. Credit the player’s balance by amount. amount is already in minor units, so add it directly — no conversion.

player.balance += callback.amount // integer minor units

The round was cancelled by the provider. Reverse the original bet – credit the player’s balance by amount. amount is already in minor units, so add it directly — no conversion.

player.balance += callback.amount // integer minor units

A refund always refers to a previously processed bet with the same round_id. If you have not processed the original bet, you can safely ignore the refund.


A game round follows a predictable sequence:

bet (finished=false)
-> win (finished=false) # optional, may repeat
-> win (finished=true) # final callback closes the round
  • A round starts with a bet callback where finished=false.
  • Zero or more win callbacks follow within the same round_id.
  • The final callback in a round has finished=true.
  • If a round is cancelled, a refund callback reverses the original bet.

You can use round_id + finished to track round state on your side, but it is not required. Processing each callback individually by transaction_id is sufficient.


The Aggregator enforces idempotency on its side using a body hash and signature. If a duplicate callback arrives from the provider, The Aggregator replays the cached response from your server instead of forwarding the request again.

You must also implement idempotency on your side. Use transaction_id as the idempotency key:

  1. Before processing a callback, check whether you have already processed a transaction with this transaction_id.
  2. If yes, return the same response you returned the first time. Do not debit or credit the player again.
  3. If no, process the transaction and store the result keyed by transaction_id.

This protects against network-level retries and edge cases where The Aggregator’s deduplication does not cover (e.g., retry queue deliveries).


If your endpoint returns an HTTP 5xx status or is unreachable (connection error, timeout), The Aggregator enqueues the callback for retry with exponential backoff.

  • Multiple retry attempts are made over roughly an hour total.
  • A 4xx response is treated as a permanent failure and is not retried — fix the cause and have your support contact replay the callback if needed.
  • Callbacks that exhaust the retry budget are parked and can be replayed manually by your support contact.

Implication for your handler: make it idempotent. The same transaction_id may arrive more than once and you must return the same result on every invocation without double-debiting or double-crediting. See Idempotency.

Aim for under 2 seconds at p95 and well under 5 seconds at the worst case. If your processing genuinely takes longer, return 200 immediately with the current balance and complete the work asynchronously.


The Aggregator applies per-operator backpressure when your endpoint is repeatedly failing — sustained 5xx or timeouts cause The Aggregator to pause forward attempts for a short cool-off, then resume with a probe. No transactions are lost; they are queued and delivered once your endpoint recovers.

The practical implication is: a degraded operator endpoint does not back up the platform’s queue for other operators, and you do not need to “drain” anything manually after a recovery — delivery resumes automatically.


You configure your callback_url and callback_secret through the operator cabinet setup wizard or by contacting support.

  • callback_url: The HTTPS endpoint on your server that receives wallet callbacks. Must be publicly reachable. Internal/private IP addresses are blocked (SSRF protection).
  • callback_secret: A shared secret used to sign forwarded payloads. Encrypted at rest with AES-256-GCM. Treat it like a password – never log it, never commit it to version control.

const express = require("express");
const crypto = require("crypto");
const app = express();
// IMPORTANT: Use raw body for signature verification
app.use("/callbacks", express.raw({ type: "application/json" }));
const CALLBACK_SECRET = process.env.CALLBACK_SECRET;
app.post("/callbacks", (req, res) => {
// 1. Verify signature
const signature = req.headers["x-signature"];
if (!signature) {
return res.status(401).json({ error: "Missing signature" });
}
const expected = crypto
.createHmac("sha256", CALLBACK_SECRET)
.update(req.body) // req.body is a Buffer when using express.raw()
.digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
return res.status(401).json({ error: "Invalid signature" });
}
// 2. Parse the callback
const callback = JSON.parse(req.body.toString());
// 3. Idempotency check
const existing = getProcessedTransaction(callback.transaction_id);
if (existing) {
return res.json(existing.response);
}
// 4. Process by transaction type
let balance;
switch (callback.transaction_type) {
case "bet":
balance = debitPlayer(callback.player_id, callback.amount, callback.currency);
break;
case "win":
case "refund":
balance = creditPlayer(callback.player_id, callback.amount, callback.currency);
break;
default:
return res.status(400).json({ error: `Unknown type: ${callback.transaction_type}` });
}
// 5. Build response
const response = {
balance: balance, // integer, minor units (cents)
currency: callback.currency,
player_id: callback.player_id,
};
// 6. Store for idempotency
storeProcessedTransaction(callback.transaction_id, { response });
return res.json(response);
});
app.listen(3000, () => console.log("Callback server listening on :3000"));
import hashlib
import hmac
import json
import os
from flask import Flask, request, jsonify
app = Flask(__name__)
CALLBACK_SECRET = os.environ["CALLBACK_SECRET"]
@app.post("/callbacks")
def handle_callback():
# 1. Verify signature
signature = request.headers.get("X-SIGNATURE", "")
raw_body = request.get_data() # raw bytes, not parsed
expected = hmac.new(
CALLBACK_SECRET.encode("utf-8"),
raw_body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, signature):
return jsonify({"error": "Invalid signature"}), 401
# 2. Parse the callback
callback = json.loads(raw_body)
# 3. Idempotency check
existing = get_processed_transaction(callback["transaction_id"])
if existing:
return jsonify(existing["response"])
# 4. Process by transaction type
tx_type = callback["transaction_type"]
amount = callback["amount"]
player_id = callback["player_id"]
currency = callback["currency"]
if tx_type == "bet":
balance = debit_player(player_id, amount, currency)
elif tx_type in ("win", "refund"):
balance = credit_player(player_id, amount, currency)
else:
return jsonify({"error": f"Unknown type: {tx_type}"}), 400
# 5. Build response
response = {
"balance": balance, # integer, minor units (cents)
"currency": currency,
"player_id": player_id,
}
# 6. Store for idempotency
store_processed_transaction(callback["transaction_id"], {"response": response})
return jsonify(response)

Symptom: Your signature verification fails on every callback.

Common causes:

  1. Re-serialized body. You parsed the JSON and then re-serialized it before computing the HMAC. The Aggregator uses compact JSON (json.dumps(data, separators=(",", ":"))) with no whitespace. Always verify against the raw request bytes.
  2. Wrong secret. You are using the API key instead of the callback_secret. These are different values.
  3. Encoding mismatch. Your HMAC key must be the callback_secret string encoded as UTF-8 bytes. The body is raw bytes as received over the wire.
  4. Middleware altering the body. A proxy, WAF, or framework middleware modified the request body before your handler received it. Ensure you read the raw, unmodified body.

Debug steps:

Terminal window
# Manually verify a captured callback
echo -n '{"action":"BET","transaction_type":"bet",...}' | \
openssl dgst -sha256 -hmac "your_callback_secret"

Compare the output with the X-SIGNATURE header value.

Symptom: The Aggregator logs show Failed to reach operator or your callbacks are being retried.

Common causes:

  1. Slow database queries. Your handler takes longer than 5 seconds to respond. Optimize your queries or return early and process asynchronously.
  2. Firewall blocking inbound requests. The Aggregator’s IP addresses must be allowed through your firewall. Contact support for the current IP list.
  3. DNS resolution failure. Your callback_url hostname is not resolvable from The Aggregator’s network.

Symptom: The Aggregator treats your response as a failure even though you return HTTP 200.

Common causes:

  1. Non-JSON response body. Your server returns HTML (e.g., a framework error page) instead of JSON. Ensure your endpoint always returns Content-Type: application/json.
  2. Missing balance field. The response must include balance as an integer in minor units (cents). If you return the balance as a decimal string or omit it, the round may not complete correctly.

Symptom: You receive the same transaction_id more than once.

This is expected behavior during retries. Implement idempotency using transaction_id as described in the Idempotency section. Return the same response you returned the first time – do not process the transaction again.

Symptom: Callbacks stop arriving for a period, then resume with a burst of retries.

Your endpoint returned sustained 5xx errors, which triggered per-operator backpressure. Delivery resumes automatically once your endpoint recovers — see the Backpressure section above.

Action: Check your server logs for the root cause of the 5xx errors. Fix the underlying issue; delivery recovers without manual intervention.


Complete every item before switching from test mode to live traffic.

  • Your callback_url is a publicly reachable HTTPS endpoint (not HTTP, not a private IP).
  • Your endpoint responds within 5 seconds under normal load.
  • Your endpoint returns HTTP 200 with a valid JSON body for all transaction types (bet, win, refund).
  • You verify the X-SIGNATURE header on every request using HMAC-SHA256 with constant-time comparison.
  • You reject requests with a missing or invalid signature.
  • Your callback_secret is stored securely and not logged or exposed in error messages.
  • You store processed transaction_id values and return cached responses on duplicates.
  • You do not debit or credit the player balance more than once for the same transaction_id.
  • bet callbacks debit the player balance.
  • win callbacks credit the player balance.
  • refund callbacks credit the player balance (reversing a previous bet).
  • You read the request amount as an integer in minor units (cents) — the same unit as balance — and apply it without any major/minor conversion.
  • The balance in your response is an integer in minor units (cents).
  • You decline unaffordable bet callbacks with HTTP 402 (or 200 + body "status": 402) — never a 5xx. See Declining a bet.
  • Your endpoint always returns JSON, even on errors (no HTML error pages).
  • You return 4xx for client errors (bad request, insufficient funds) and 5xx only for genuine server failures.
  • You have alerting on 5xx responses to avoid triggering the circuit breaker in production.
  • You have tested the full round lifecycle: bet -> win (with finished=true).
  • You have tested refund handling.
  • You have tested duplicate transaction_id handling (idempotency).
  • You have tested signature verification with a known body + secret pair.
  • You have tested your endpoint under the 5-second timeout constraint.