Callbacks
Wallet callbacks are how money moves. Whenever a player interacts with a game, Aggregator.gg delivers a signed POST to your configured callback_url; you debit or credit the player’s wallet and answer with the new balance. Use this contract together with the provider-specific transaction and recovery evidence agreed for your integration.
How callbacks reach you
Section titled “How callbacks reach you”- A player opens a game through your platform via Aggregator.gg.
- The game provider reports the wallet event to Aggregator.gg.
- Aggregator.gg verifies the provider’s signature, records the transaction, and forwards a normalized payload to your
callback_url. - Your server applies the wallet movement and returns a JSON response. The provider waits on that response before showing the result to the player.
Player -> Game Provider -> Aggregator.gg -> Your server | <- response <--------+Aggregator.gg acts as a normalizer: whichever provider originates the event, you always receive the same payload shape and the same signing scheme.
Use a scoped transaction identity throughout this guide: the trusted integration/organization context configured for your endpoint together with provider_code and transaction_id. The same transaction ID from another provider or organization is a different movement; the ID text alone is not globally unique. For a shared platform wallet, derive the operator/brand context from your trusted session mapping and include it in the namespace; the parent organization alone does not distinguish its brands.
Callback delivery is synchronous. Verify the signature and validate the request, then commit the wallet change and its idempotent result before returning a successful response with the resulting balance. Complete this path within 5 seconds and aim for under 2 seconds at p95. Do not acknowledge success while settlement is still pending. If processing cannot complete, return the documented failure response; a repeated transaction must replay its stored result. Nonessential work such as analytics may run after settlement.
Step check: for one session and spin, observe the expected signed callback(s), which can include separate bet and win transactions or retries. Verify each signature and confirm that every reply is contract-valid and leaves within 5 seconds. For a repeated scoped transaction identity, verify that the stored original status and body are replayed with no additional wallet movement. Identical ID text in another provider or organization must remain a separate transaction.
Callback contract
Section titled “Callback contract”Every forwarded callback is a POST with two headers that matter:
| Header | Value |
|---|---|
Content-Type |
application/json |
X-SIGNATURE |
HMAC-SHA256 hex digest of the raw JSON body, keyed with your callback_secret |
Verify the signature against the raw body bytes before touching any wallet; the full algorithm and constant-time comparison snippets live in Request signatures. The signature authenticates the exact transmitted bytes; do not depend on JSON whitespace, property order or re-serialization.
A bet body looks like this:
{ "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 ("BET-WIN", "BET", "WIN"). Never branch on it; branch on transaction_type. |
transaction_type |
string |
One of "bet", "win", "refund". This field decides how you update the balance. |
is_free |
boolean |
true when the 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. 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 cash play. May be absent; treat a missing value as empty. |
transaction_id |
string |
Provider-unique identifier for this transaction. Treat it as opaque and combine it with provider_code and the trusted integration/organization context for idempotency. |
amount |
integer |
Transaction amount in the currency’s minor units (150 = 1.50 EUR). Same unit as your response balance. |
currency |
string |
ISO 4217 code ("EUR", "USD"). |
player_id |
string |
The player identifier you supplied at session launch. |
provider_code |
string |
The game provider ("truelabs", "bgaming"). |
game |
string |
Game slug or name from the provider. |
game_id |
string |
UUID of the game in Aggregator.gg catalog. |
round_id |
string |
Groups related transactions into one game round. |
finished |
boolean |
true on the final callback of a round. |
session_id |
string |
UUID of the game session in Aggregator.gg. |
The request amount and your response balance are integers in the same minor units, never decimal strings, so you apply amount to your stored balance with no conversion at all. The minor unit is fixed by the ISO-4217 currency, not by you:
| Currency type | Examples | Conversion | amount for 1.50 of currency |
|---|---|---|---|
| 2-decimal | EUR, USD, GBP | major x100 | 150 (= 1.50 EUR) |
| 0-decimal | JPY, KRW | 1:1 | 278 (= 278 JPY) |
| 3-decimal | KWD, BHD, OMR | major x1000 | 1500 (= 1.500 KWD) |
Use integer arithmetic or a decimal type throughout. Floating point on money is a go-live blocker.
Step check: inspect a callback locally and confirm your parser reads amount as an integer and your wallet applies it without any x100 or /100 anywhere. Record the check result, not the callback body or signature.
Response contract
Section titled “Response contract”Return HTTP 200 with a JSON body carrying the player’s balance after applying the transaction:
{ "balance": 9850, "currency": "EUR", "player_id": "player-123"}| Field | Type | Description |
|---|---|---|
balance |
integer |
Required. Post-transaction balance in minor units, the same unit as the request amount. |
currency |
string |
Required. ISO 4217 code; must match the request currency. |
player_id |
string |
Optional but recommended: echo the request’s player_id. |
Return HTTP 200 with a contract-valid body to confirm acceptance. An unreadable or contract-invalid 2xx leaves delivery unknown and stops automatic redelivery; reconcile the transaction before considering recovery. Framework HTML error pages are a common cause, so make every path of your handler, including errors, return JSON. Other failures follow the retry rules below.
Step check: a paid bet of 500 against a stored balance of 10000 produces the response {"balance": 9500, "currency": "..."} and nothing else. A grant-funded bet preserves the real-money balance.
Declining a bet
Section titled “Declining a bet”When the player cannot afford a bet, decline it explicitly. A decline is a normal business outcome. The generic classifier accepts these exact forms:
- HTTP
402, or - HTTP
200with a body-level marker:{"status": 402, "balance": <current>, "currency": "EUR"}.
HTTP 200 with the string marker "status": "402" is also accepted. Other text, a float marker such as 402.0, or a truthy error field is not a generic decline. A recognized decline is terminal, so no debit should be committed and automatic redelivery stops. Provider-facing error mapping varies; E4001 is the platform’s insufficient-funds code.
Never answer a business decline with a 5xx. Reserve 5xx for server failures; retry eligibility and backpressure follow the delivery path and deployed policy. For a duplicate within the same scoped transaction identity, replay your original response instead of returning 409, as specified under Retry behaviour.
Step check: set a test player’s balance below the bet amount, spin, and confirm your endpoint answers 402 (or 200 plus "status": 402), the player sees an insufficient-funds message, and no debit lands in your ledger.
Transaction types
Section titled “Transaction types”transaction_type |
Wallet movement | Notes |
|---|---|---|
bet |
Debit amount for a paid bet |
For is_free: true, preserve the real-money balance. A paid debit must not go negative: decline instead. |
win |
Credit amount |
player.balance += amount. |
refund |
Reverse a recorded debit once | Resolve the original bet and its recorded wallet movement; a grant-funded bet with no real-money debit must not create a new real-money credit. |
For a refund, reverse only the recorded wallet debit of the original bet, once. Identify that original through the provider’s supported original-transaction identity within the trusted integration/organization context, and reconcile it with your ledger; round_id alone does not establish which debit to reverse. If the original is missing or its settlement is ambiguous, reconcile before choosing a response or recovery action. Do not invent a credit or silently ignore an unresolved refund. Replay a duplicate refund’s stored original status and body under its own scoped transaction identity without another movement.
A round can contain a bet, zero or more win callbacks and a supported refund. Free-round delivery can differ as described below. Treat round_id and finished as round context, not as substitutes for the scoped transaction identity or the original-debit record needed for reversal. The wider lifecycle, including how losing rounds settle, is on How a round works.
Retry behaviour
Section titled “Retry behaviour”What Aggregator.gg does with your response:
- HTTP 200 with a contract-valid acceptance body: delivery is confirmed. The response needs an integer
balance(not a boolean, float or string) and a matchingcurrency. Omitstatus, or use integer200/ string"200";error,errorType,codeandmessagemust be absent or null. Business declines follow the separate contract above. - Any 2xx other than 200, or unreadable / contract-invalid 200: delivery is unknown. Automatic redelivery stops; reconcile the transaction before choosing a recovery action.
- HTTP 5xx, connection error, or timeout: retry eligibility and budget depend on the deployed policy. Where the documented legacy queue policy is deployed, eligible deliveries default to six queued retries after the original delivery, with scheduled delays of 5s, 5s, 30s, 2min, 10min and 1h. Those delays total 1 hour 12 minutes 40 seconds; request duration, polling and backlog add to the elapsed time. Confirm the active policy for your integration. Existing queue entries retain their stored attempt budget, and other recovery paths can follow different policies.
- HTTP 3xx or 4xx: a terminal response (402 is the explicit decline). It is not retried; fix the cause and contact support to determine whether recovery is safe.
- Retry budget exhausted or safe redelivery cannot be established: the item remains for investigation. Contact support to determine a safe recovery path; manual replay is not available for every callback.
Two implications for your handler:
- Idempotency is mandatory. Retries and replays can repeat a scoped transaction identity. Look up the trusted integration/organization context plus
provider_codeandtransaction_id; never look up the ID text in a global store. If settled, return the stored original status and body without another wallet movement. Otherwise, atomically commit the wallet change and that response under the same scoped identity before success. Aggregator.gg also deduplicates on its side by body hash and signature, replaying your cached response to the provider, but its deduplication does not cover every path (retry-queue deliveries in particular), so yours must exist too. The outboundIdempotency-Keyheader is a separate API-request mechanism described in Idempotency. - Sustained failure triggers backpressure. Repeated 5xx or timeouts can pause initial forwarding to your endpoint for a short cool-off. Eligible queued retries continue within their attempt budget. Exhausted or unsafe deliveries require investigation; endpoint recovery alone does not guarantee that every outstanding callback will be delivered.
Step check: for a callback eligible for the legacy retry queue, return 500 before moving money, then recover. When the same scoped transaction identity arrives on retry, process it once and store the valid 200 status and body atomically with the wallet change. For a later duplicate in that namespace, replay the stored response without another wallet movement.
Free-round legs
Section titled “Free-round legs”Free rounds arrive at this same endpoint as ordinary bet and win callbacks carrying is_free: true, a free_round_grant_id, and a free_round_kind. There is no separate endpoint to build. The one rule that matters most: a bet with is_free: true is grant-funded, so do not debit the player’s real-money balance for it; its amount is the nominal stake for your reporting, not a charge. A free win is credited exactly like a cash win.
Some free rounds send both legs, others settle as a single free win with no preceding free bet, so never require a matching bet before accepting a free win. Issuing grants, stake models, and the full balance-handling table live on Free rounds.
Configuration
Section titled “Configuration”For operator accounts, open API in the cabinet at app.aggregator.gg/api-keys and use the Callbacks settings, or the supported integration setup. Platform accounts currently use a shared platform wallet; see Platform accounts, billing and callbacks before configuring traffic. If the editor is unavailable for your account, contact support rather than changing a brand field that delivery does not use:
callback_url: the HTTPS endpoint on your server that receives wallet callbacks. It must be publicly reachable; private and internal IP addresses are rejected by SSRF validation (E6002).callback_secret: the shared secret that signs every forwarded payload. You choose it yourself (minimum 16 characters; the wizard can generate one), and it is stored encrypted at rest with AES-256-GCM and never displayed again, so keep it in your secret manager from the moment you set it. Treat it like a password: never log it, never commit it. It is a separate credential from youragg_API key.
Before changing a wallet URL or signing secret, coordinate pending sessions and deliveries with support. Existing queued deliveries can retain their old URL while retry signing uses current configuration; this release does not guarantee a pinned destination and signer for every session. Do not delete an old endpoint or assume an immediate safe cutover.
Callbacks originate from the platform egress IPs described in Environments. If you firewall the endpoint, allowlist those addresses, and keep verifying the signature regardless: the allowlist is a network control, not a replacement for HMAC.
Step check: from a machine outside your network, curl -X POST https://your-server.example.com/aggregator/callback -H 'Content-Type: application/json' -d '{}' reaches your handler and is rejected with a 4xx for its missing signature, which proves both reachability and that unsigned traffic cannot move money.
Troubleshooting
Section titled “Troubleshooting”Signature mismatch on every callback. You re-serialised the JSON before computing the HMAC (verify the raw bytes instead), you are using the API key instead of the callback_secret, your HMAC key is not the UTF-8 bytes of the secret, or a proxy or middleware altered the body before your handler read it. Use the in-process verification examples against the untouched raw request bytes. Load the callback secret from a protected environment variable or secret manager and reject missing or empty configuration before verification. Record only whether verification passed; never place the secret in command arguments or log the secret, body, or signature.
A 200 response was not accepted. A non-JSON body, missing balance, decimal-string balance, or mismatched currency can leave delivery unknown. Return Content-Type: application/json with the required integer balance and matching currency. Correcting future responses does not settle the ambiguous transaction; reconcile it before recovery.
Timeouts. Slow database queries in the hot path, a firewall dropping the platform’s egress IPs, or DNS trouble on your callback_url hostname. Optimise, allowlist, or fix resolution; the 5-second budget is not negotiable.
Duplicate transaction_id received. First compare the full scoped identity: trusted integration/organization context, provider_code and transaction_id. If that identity was settled, return its stored original status and body without another wallet movement. Matching ID text from another provider or organization is not a duplicate.
Delivery paused, then a burst of retries. Sustained 5xx can trip per-operator backpressure. Fix the endpoint and check outstanding transactions: eligible retries can resume, while exhausted or unsafe deliveries need investigation with support.