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 opaque identifier. Combine it with provider_code, scoped to your integration/organization, for wallet idempotency. |
amount |
integer |
Transaction amount in minor units (e.g. 150 = 1.50 EUR). A documented zero-win settlement may have amount: 0; validate according to the transaction and provider contract. 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”When your server accepts a transaction, 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. |
A non-2xx status or non-JSON body does not by itself establish whether a delivery will be retried. Business declines follow the contract below; other failures and ambiguous outcomes follow the delivery path and deployed policy described under Retry behaviour.
Use the response status to distinguish an accepted transaction, a business refusal, and a technical failure. Always return JSON.
| Outcome | HTTP status | JSON response |
|---|---|---|
| Transaction accepted | 200 |
Updated balance and currency |
Player cannot afford a bet |
402, or 200 with body status: 402 |
Current balance and currency; status: 402 is required for the HTTP 200 form |
| Definite transaction-state refusal | 400 |
status: 400, machine-readable error, current balance and currency |
| Transient server failure | 5xx |
Error details without secrets; see Retry behaviour |
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" }.
Reserve this insufficient-funds response for an unaffordable bet. Do not use
402 to label a refused win or refund, or a round-state problem. Never
answer a business refusal with a 5xx — use 5xx for transient server failures.
Refusing a transaction because of its state
Section titled “Refusing a transaction because of its state”If your wallet definitively refuses a transaction under its round rules, return
HTTP 400 with a matching body-level status: 400. For example:
{ "status": 400, "error": "ROUND_CLOSED", "balance": 9850, "currency": "EUR"}Include the machine-readable reason, such as ROUND_CLOSED or ROUND_NOT_FOUND,
and the player’s current balance and currency. This classification does not
change which refunds your wallet must accept under its agreed round rules.
Do not return HTTP 200 with status: 400: the HTTP success status can be
treated as an accepted transaction. HTTP 409 is also not a substitute for
this definite business refusal.
The wallet response describes your wallet’s decision. Provider-facing error mapping, reconciliation and retry behavior depend on the provider integration; returning a refusal does not by itself select a new automatic retry policy.
Transaction Types
Section titled “Transaction Types”For a paid bet (is_free absent or false), debit the player’s balance by amount after validation. amount is already in minor units; no conversion is needed. For a grant-funded bet with is_free: true, do not debit the player’s real-money balance; follow the free-round rules. Commit the result atomically with the idempotency record as described below.
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 unitsrefund
Section titled “refund”On a supported refund path, reconcile the refund with the original bet and reverse only its recorded wallet debit, once. Use the provider’s documented original-transaction identity and your ledger; round_id alone does not prove which movement should be reversed. A grant-funded free bet that did not debit the player’s wallet must not create a new real-money credit when reversed.
If the original outcome is missing or ambiguous, use the documented reconciliation procedure before changing the wallet. Do not infer a debit from the refund’s arrival or silently discard an unresolved financial event. Return the documented response for the resolved outcome and retain it for duplicate replay.
Round Lifecycle
Section titled “Round Lifecycle”A paid round can follow this representative sequence; the provider contract determines the actual legs:
bet (finished=false) -> win (finished=false) # optional, may repeat -> win (finished=true) # final callback closes the round- In this representative paid-round flow, a
betcallback withfinished=falseopens the round. Supported free-round paths may settle with a win alone, includingamount: 0; see Provider differences. - 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. Process callbacks using the scoped transaction identity described under Idempotency; a bare provider transaction ID is not globally unique.
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 the combination of integration/organization, provider_code and transaction_id as the wallet idempotency identity. Two providers can send the same opaque transaction ID; their events must not share a stored result:
- Before processing a callback, look up the full scoped identity and check whether that transaction is already processed.
- If yes, return the same response you returned the first time. Do not debit or credit the player again.
- If no, durably commit the wallet change and its result keyed by that full scoped identity before returning success. Store them together so a retry cannot apply the change twice.
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”Retry eligibility, delays and attempt budgets depend on the delivery path and deployed policy. Where queued retries are enabled, eligible connection errors, timeouts or server failures may be retried. Do not assume every failed callback is queued or every path shares the same retry budget.
- Definite business refusals (
400for transaction state,402for an unaffordable bet) are distinct from transient failures. Follow the provider integration’s reconciliation policy before replaying a refused transaction; do not assume every non-2xx response triggers a retry. - Confirm which failures are retryable for your integration; a client or business error may require correcting the request instead.
- Confirm how exhausted or permanently failed deliveries are recorded and whether a controlled replay is available. Do not assume manual replay exists for every path.
- After a timeout or recovery, reconcile transaction outcomes before requesting a replay: the wallet change may already have committed even when its response was not received.
Implication for your handler: make it idempotent. The same scoped transaction may arrive more than once and you must return its stored result on every duplicate 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. Verify the signature and validate the request, then durably commit the wallet change and its idempotent result before returning a successful response with the resulting balance. Do not acknowledge success while settlement is pending. A duplicate transaction must replay its stored result. If processing cannot complete, return the documented failure response. Nonessential work such as analytics may run asynchronously after settlement.
Backpressure
Section titled “Backpressure”Backpressure, pause/probe behavior and recovery controls depend on the eligible delivery path and deployed policy. Confirm the behavior configured for your integration; a pause does not establish that every pending transaction has been queued or will be delivered automatically.
After recovery, verify delivery status and reconcile your wallet ledger against the recorded transactions. Investigate missing or unresolved outcomes with your support contact and use only the replay procedure available for that path. Do not assume lossless delivery or that recovery needs no intervention.
Configuration
Section titled “Configuration”Configure your callback_url and separate callback_secret through the operator cabinet setup wizard where your deployment exposes it, or confirm the available configuration procedure with your support contact.
- 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”Use the signature verification snippets for TypeScript, PHP and Python, and the callback integration requirements for the framework boundary. A signature verifier is one part of the endpoint; it does not implement wallet settlement.
On a free bet with is_free: true, do not debit the player’s real-money balance. Follow the free-round rules and the request and response contracts on this page.
Atomically commit the wallet change and the stored original status and body under the integration/organization, provider_code and transaction_id identity before returning success. A duplicate replays the stored original status and body without another wallet movement. Validate these guarantees in your own ledger implementation before handling customer traffic.
For diagnostic handling, use the fixed-code error guidance. Keep raw callback bodies, signature headers and secrets out of errors and logs.
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:
Verify the captured raw body bytes and X-SIGNATURE with the in-process signature verifier, loading AGGREGATOR_CALLBACK_SECRET from protected environment configuration. Do not place the secret in command arguments, shell history or diagnostic logs. Record only whether verification passed; keep the captured body and header out of shared diagnostics.
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 the durable settlement path and move only nonessential work after it. Never return success before the wallet change and idempotent result are committed.
- 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 the full integration/organization, provider and transaction identity 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.
Sustained failures may trigger backpressure on an eligible delivery path. Confirm the actual cause and deployed recovery policy — see Backpressure. Do not infer automatic redelivery from the endpoint becoming healthy.
Action: Check safe server diagnostics for the root cause, fix the underlying issue, then verify delivery status and reconcile transaction outcomes. Use the supported recovery or replay procedure for unresolved deliveries.
Go-Live Checklist
Section titled “Go-Live Checklist”Complete every item with authorized validation traffic and the applicable launch requirements before opening customer 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.
- Successful
bet,winandrefundcallbacks return HTTP 200 with a valid JSON body only after the wallet change and idempotent result are durably committed.
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 results by integration/organization,
provider_codeandtransaction_id, and replay them only for duplicates of that full identity. - You do not debit or credit the player balance more than once for the same scoped transaction identity.
Balance Handling
Section titled “Balance Handling”- Paid
betcallbacks debit the player balance; grant-funded bets withis_free: truedo not debit real money. -
wincallbacks credit the player balance. - Supported
refundcallbacks reverse only an original recorded wallet debit, once; free bets without a debit do not create a real-money credit. - 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).
- Definite transaction-state refusals use HTTP
400and bodystatus: 400, with the machine-readable error, current balance and currency. Only an unaffordablebetuses the insufficient-funds402response. - You use the documented refusal forms for business outcomes and reserve 5xx for genuine server failures.
- You have alerting on server failures and understand the recovery controls enabled for your delivery path.
Testing
Section titled “Testing”- You have tested the full round lifecycle:
bet->win(withfinished=true). - You have tested
refundhandling. - You have tested duplicate handling within the same provider/integration and distinct handling when another provider uses the same
transaction_id. - You have tested signature verification with a known body + secret pair.
- You have tested your endpoint under the 5-second timeout constraint.