Wallet Callback Integration Guide
How Callbacks Work
Section titled “How Callbacks Work”- A player opens a game through your platform via The Aggregator.
- The game provider sends a callback to The Aggregator at
POST /callbacks/{provider_code}/{action}. - The Aggregator verifies the provider’s signature, records the transaction, and forwards a normalized payload to your configured
callback_url. - 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.
Forward Payload
Section titled “Forward Payload”The Aggregator sends a POST request to your callback_url with the following format.
Headers
Section titled “Headers”| 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 Reference
Section titled “Field Reference”| 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. |
Signature Verification
Section titled “Signature Verification”Every forwarded callback is signed with your callback_secret using HMAC-SHA256. You must verify the signature before processing any callback.
Algorithm
Section titled “Algorithm”- Read the raw request body as bytes. Do not parse or re-serialize it first.
- Compute the HMAC-SHA256 of those bytes using your
callback_secretas the key. - Compare the resulting hex digest with the value in the
X-SIGNATUREheader 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.
Expected Response
Section titled “Expected Response”Your server must return HTTP 200 with a JSON body containing the player’s updated balance:
{ "balance": 9850, "currency": "EUR", "player_id": "player-123"}Response Fields
Section titled “Response Fields”| 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.
Declining a bet (insufficient funds)
Section titled “Declining a bet (insufficient funds)”When the player cannot afford a bet, decline it explicitly — this is a normal
business outcome, not a server error:
- HTTP
402, or - HTTP
200with 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.
Transaction Types
Section titled “Transaction Types”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 unitsThe 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 unitsrefund
Section titled “refund”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 unitsA 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.
Round Lifecycle
Section titled “Round Lifecycle”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
betcallback wherefinished=false. - Zero or more
wincallbacks follow within the sameround_id. - The final callback in a round has
finished=true. - If a round is cancelled, a
refundcallback 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.
Idempotency
Section titled “Idempotency”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:
- Before processing a callback, check whether you have already processed a transaction with this
transaction_id. - If yes, return the same response you returned the first time. Do not debit or credit the player again.
- 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).
Retry behaviour
Section titled “Retry behaviour”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.
Response time
Section titled “Response time”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.
Backpressure
Section titled “Backpressure”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.
Configuration
Section titled “Configuration”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.
Code Examples
Section titled “Code Examples”Node.js (Express)
Section titled “Node.js (Express)”const express = require("express");const crypto = require("crypto");
const app = express();
// IMPORTANT: Use raw body for signature verificationapp.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"));Python (Flask)
Section titled “Python (Flask)”import hashlibimport hmacimport jsonimport 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)Troubleshooting
Section titled “Troubleshooting”Signature Mismatch (401)
Section titled “Signature Mismatch (401)”Symptom: Your signature verification fails on every callback.
Common causes:
- 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. - Wrong secret. You are using the API key instead of the
callback_secret. These are different values. - Encoding mismatch. Your HMAC key must be the
callback_secretstring encoded as UTF-8 bytes. The body is raw bytes as received over the wire. - 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:
# Manually verify a captured callbackecho -n '{"action":"BET","transaction_type":"bet",...}' | \ openssl dgst -sha256 -hmac "your_callback_secret"Compare the output with the X-SIGNATURE header value.
Timeout (5xx or no response)
Section titled “Timeout (5xx or no response)”Symptom: The Aggregator logs show Failed to reach operator or your callbacks are being retried.
Common causes:
- Slow database queries. Your handler takes longer than 5 seconds to respond. Optimize your queries or return early and process asynchronously.
- Firewall blocking inbound requests. The Aggregator’s IP addresses must be allowed through your firewall. Contact support for the current IP list.
- DNS resolution failure. Your
callback_urlhostname is not resolvable from The Aggregator’s network.
Wrong Response Format
Section titled “Wrong Response Format”Symptom: The Aggregator treats your response as a failure even though you return HTTP 200.
Common causes:
- 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. - Missing
balancefield. The response must includebalanceas an integer in minor units (cents). If you return the balance as a decimal string or omit it, the round may not complete correctly.
Duplicate Callbacks
Section titled “Duplicate Callbacks”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.
Delivery paused after sustained failures
Section titled “Delivery paused after sustained failures”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.
Go-Live Checklist
Section titled “Go-Live Checklist”Complete every item before switching from test mode to live traffic.
Endpoint
Section titled “Endpoint”- Your
callback_urlis 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).
Security
Section titled “Security”- You verify the
X-SIGNATUREheader on every request using HMAC-SHA256 with constant-time comparison. - You reject requests with a missing or invalid signature.
- Your
callback_secretis stored securely and not logged or exposed in error messages.
Idempotency
Section titled “Idempotency”- You store processed
transaction_idvalues and return cached responses on duplicates. - You do not debit or credit the player balance more than once for the same
transaction_id.
Balance Handling
Section titled “Balance Handling”-
betcallbacks debit the player balance. -
wincallbacks credit the player balance. -
refundcallbacks credit the player balance (reversing a previous bet). - You read the request
amountas an integer in minor units (cents) — the same unit asbalance— and apply it without any major/minor conversion. - The
balancein your response is an integer in minor units (cents). - You decline unaffordable
betcallbacks with HTTP402(or200+ body"status": 402) — never a 5xx. See Declining a bet.
Error Handling
Section titled “Error Handling”- 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.
Testing
Section titled “Testing”- You have tested the full round lifecycle:
bet->win(withfinished=true). - You have tested
refundhandling. - You have tested duplicate
transaction_idhandling (idempotency). - You have tested signature verification with a known body + secret pair.
- You have tested your endpoint under the 5-second timeout constraint.