# Game Alligators — Operator Integration v2 (llms-full.txt) Contract 2.0.0, additive-only. # Document Control | Item | Value | |---|---| | Contract version | 2.0.0 (frozen, additive-only) | | Document revision | 2026-09-25 | | Exact field types | `proto/*.proto` and `openapi/*.yaml` in this package. Where this guide and those files differ, the files govern; tell us at [integration@gamealligator.com](mailto:integration@gamealligator.com) | | Money rules in full | `money-path/OPERATOR-API-MONEY-PATH-RULES.md` (the **Money Path Rules** page on the docs site) | | Postman | `postman/GA_Operator_API_v2` (your wallet), `postman/GA_Aggregator_API_v2` and `postman/GA_Features_API_v2` (calls to GA), environment `postman/GA_Sandbox_v2` | | Certification | Operator Portal → Integration → Callback test, or the `ga-operator-certify` tool for CI; sign-off form `certification/GA_Operator_Certification_Checklist_v2.md` (chapter 9) | | Support | [integration@gamealligator.com](mailto:integration@gamealligator.com) | --- # 1. Quick start ### 1.1 What you are building Game Alligators (GA) puts game studios' games into your casino. Traffic runs in two directions, each with its own key pair. Don't mix them. | Direction | Who calls whom | What it's for | Key you use | Chapter | |---|---|---|---|---| | **You → GA** | You call `https://api.rexplay.site/v2/…` | Launch a game, read the catalog, issue free rounds, read reports | **Operator API key** + hash-based message authentication code (HMAC) secret, from your onboarding profile | 4 | | **GA → you** | GA calls your wallet at `https:///v2/wallet/*` | Balance, bets, wins, rollbacks | **Wallet signing key**, from the Operator Portal. GA signs, you verify | 5 | Transports: HTTP/JSON, which this guide covers, or native gRPC with the same messages. On gRPC there is no HMAC signature in either direction: the credential travels as metadata (`x-api-key` plus `ga-brand-id` on calls to your wallet, `x-api-key` plus `x-brand-id` on your calls to GA). If you already run a Softgaming-compatible wallet API, ask GA about the compatibility adapter. A separate document covers it. ### 1.2 One session, end to end ``` Player Your site GA Your wallet | click game | | | |------------------>| launch_game | | | |---------------->| (token, player, currency) | | |<----------------| launch_url, session 24h | |<------------------| open launch_url | | | | | get_balance / authenticate | | | |--------------------------->| | | |<---------------------------| balance | spin | | debit (op_id A) | | | |--------------------------->| | | |<---------------------------| ok, balance_after | | | credit (op_id B, win) | | | |--------------------------->| | | |<---------------------------| ok | spin, no answer | | debit (op_id C) ... 5 s per attempt, 8 s per bet | | |--------------------------->| (timeout) | | | rollback (original C) | | | |--------------------------->| | | |<---------------------------| ok, original_found: false | | | (late debit C arrives -> you refuse ALREADY_ROLLED_BACK) ``` ### 1.3 Six steps to go-live 1. **Get sandbox credentials.** Email [integration@gamealligator.com](mailto:integration@gamealligator.com). You receive an operator profile with `operator_id`, the Operator API key pair, and a login to `https://operator.rexplay.site`. There you rotate the wallet signing key once and copy the secret. The portal shows it only at that moment. 2. **Give GA your sandbox wallet URL** and the optional wallet actions you support. The URL uses HTTPS and is reachable from the internet. Also give your currencies with their exponent, a 24/7 technical contact, and a finance contact. 3. **Build the five mandatory wallet actions** (chapter 5) with the four money rules (chapter 3). Test your signature check with the worked example in §2.2 first. 4. **Run the Postman collection `GA_Operator_API_v2` against your wallet.** Set `wallet_base_url`, `ga_wallet_key_id`, `ga_wallet_hmac_secret`. Done when all ten requests answer a valid envelope. 5. **Run the certification checks from the Operator Portal** (chapter 9) until every check passes, then sign the checklist with your GA integration manager. 6. **Repeat steps 2, 4, and 5 with production credentials** and your production wallet URL. Production credentials are separate. GA never derives them from sandbox ones. Addresses: | Surface | Sandbox | Production | |---|---|---| | GA API (chapter 4) | `https://api.rexplay.site` | `https://api.game-alligator.com` | | Operator Portal (wallet key, reports) | `https://operator.rexplay.site` | `https://operator.game-alligator.com` | | Your wallet | you host it, you give GA the URL | separate URL and credentials | | GA calls your wallet from | `49.13.169.177`, `46.224.156.1` | `49.13.169.177` | --- # 2. Common to every call ### 2.1 Headers and signature: GA → your wallet Every call GA sends to your wallet looks like this: ```http POST /v2/wallet/debit HTTP/1.1 Host: wallet.operator.example Content-Type: application/json X-API-Key-Id: key-live-01 X-Request-Id: 0198a1d0-9a30-7f08-a7dd-713e4fd33db0 Idempotency-Key: op-bet-48912 X-GA-Signature: t=1783944000,v1=8a75f608c2a8e178d89a055198050e9a2d5341c2f390ac1d2c2c68e433eb5f2d ``` - `X-API-Key-Id` names the wallet signing key. It's the key id you see in the Operator Portal. - `X-Request-Id` is this one network attempt. It equals `request_id` in the body and `payload.meta.request_id`, and changes on every retry. - `Idempotency-Key` equals `payload.meta.op_id`, the business operation. It stays the same across retries. Use it, or the body field, as your idempotency key. - `X-GA-Signature` carries the timestamp `t` (unix seconds, the same instant as the body's `ts`) and the signature `v1`. Verify in this order, **before** you parse the body: 1. Find your secret by `X-API-Key-Id`. 2. Split `X-GA-Signature` into `t` and `v1`. 3. Reject if `t` is more than 300 seconds away from your clock. 4. Compute `SHA256_HEX` of the **raw body bytes** exactly as received. Don't re-serialize. 5. Compute `HMAC-SHA256(secret, t + "." + request_id + "." + body_sha256)` where `request_id` is the top-level `request_id` field of the body (identical to the `X-Request-Id` header). Compare with `v1` using a constant-time compare. 6. Check that `operator_id` in the body is your operator id. If any step fails, answer HTTP 401 or 403 with no body. GA treats that as "no answer" and retries. A bet that never verifies ends in a rollback. ### 2.2 Worked example Use these values to test your verification code before GA sends anything. Secret, body, and timestamp are fixed, so your output must match to the byte. | Input | Value | |---|---| | Wallet HMAC secret | `sec-cert-01` | | `t` | `1783944000` (the body's `ts` `2026-07-13T12:00:00Z`) | | `request_id` | `0198a1d0-9a30-7f08-a7dd-713e4fd33db0` | | Raw body (one line, no trailing newline) | `{"request_id":"0198a1d0-9a30-7f08-a7dd-713e4fd33db0","ts":"2026-07-13T12:00:00Z","operator_id":"0197aaaa-0000-7000-a000-000000000002","action":"get_balance","payload":{"meta":{"request_id":"0198a1d0-9a30-7f08-a7dd-713e4fd33db0","op_id":"op-bal-1001","operator_id":"0197aaaa-0000-7000-a000-000000000002","player_ref":"player-10428"},"currency":"EUR"}}` | | `SHA256_HEX(body)` | `f6ebf9ad22209d716b34fff30cb66d7232e96a2d403e86ed0dc23e5da9f8ad6b` | | Signing input | `1783944000.0198a1d0-9a30-7f08-a7dd-713e4fd33db0.f6ebf9ad22209d716b34fff30cb66d7232e96a2d403e86ed0dc23e5da9f8ad6b` | | **Expected `v1`** | `8a75f608c2a8e178d89a055198050e9a2d5341c2f390ac1d2c2c68e433eb5f2d` | Node.js: ```js const crypto = require("crypto"); function verify(rawBody, headers, secret) { const m = /t=(\d+),v1=([0-9a-f]{64})/.exec(headers["x-ga-signature"] || ""); if (!m) return false; const [, t, v1] = m; if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; const requestId = JSON.parse(rawBody).request_id; const bodySha = crypto.createHash("sha256").update(rawBody).digest("hex"); const expected = crypto.createHmac("sha256", secret).update(`${t}.${requestId}.${bodySha}`).digest(); return crypto.timingSafeEqual(expected, Buffer.from(v1, "hex")); } ``` PHP: ```php function verify(string $rawBody, array $headers, string $secret): bool { if (!preg_match('/t=(\d+),v1=([0-9a-f]{64})/', $headers['X-GA-Signature'] ?? '', $m)) return false; [, $t, $v1] = $m; if (abs(time() - (int)$t) > 300) return false; $requestId = json_decode($rawBody, true)['request_id'] ?? ''; $input = $t . '.' . $requestId . '.' . hash('sha256', $rawBody); return hash_equals(hash_hmac('sha256', $input, $secret), $v1); } ``` Go: ```go func verify(rawBody []byte, sigHeader, secret string) bool { var t int64; var v1 string if _, err := fmt.Sscanf(sigHeader, "t=%d,v1=%s", &t, &v1); err != nil { return false } if d := time.Now().Unix() - t; d > 300 || d < -300 { return false } var env struct{ RequestID string `json:"request_id"` } _ = json.Unmarshal(rawBody, &env) sum := sha256.Sum256(rawBody) mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(fmt.Sprintf("%d.%s.%s", t, env.RequestID, hex.EncodeToString(sum[:])))) want, _ := hex.DecodeString(v1) return hmac.Equal(mac.Sum(nil), want) } ``` Python: ```python import hashlib, hmac, json, re, time def verify(raw_body: bytes, sig_header: str, secret: str) -> bool: m = re.fullmatch(r"t=(\d+),v1=([0-9a-f]{64})", sig_header or "") if not m: return False t, v1 = m.groups() if abs(time.time() - int(t)) > 300: return False request_id = json.loads(raw_body)["request_id"] signing_input = f"{t}.{request_id}.{hashlib.sha256(raw_body).hexdigest()}" expected = hmac.new(secret.encode(), signing_input.encode(), hashlib.sha256).hexdigest() return hmac.compare_digest(expected, v1) ``` Java: ```java static boolean verify(byte[] rawBody, String sigHeader, String secret) throws Exception { var m = java.util.regex.Pattern.compile("t=(\\d+),v1=([0-9a-f]{64})").matcher(java.util.Objects.requireNonNullElse(sigHeader, "")); if (!m.matches()) return false; long t = Long.parseLong(m.group(1)); String v1 = m.group(2); if (Math.abs(System.currentTimeMillis() / 1000 - t) > 300) return false; String requestId = new com.fasterxml.jackson.databind.ObjectMapper().readTree(rawBody).get("request_id").asText(); String bodySha = java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(rawBody)); var mac = javax.crypto.Mac.getInstance("HmacSHA256"); mac.init(new javax.crypto.spec.SecretKeySpec(secret.getBytes(java.nio.charset.StandardCharsets.UTF_8), "HmacSHA256")); byte[] expected = mac.doFinal((t + "." + requestId + "." + bodySha).getBytes(java.nio.charset.StandardCharsets.UTF_8)); return java.security.MessageDigest.isEqual(expected, java.util.HexFormat.of().parseHex(v1)); } ``` ### 2.3 Signature: your calls to GA Same header names (`X-API-Key-Id`, `X-GA-Signature: t=…,v1=…`), your **Operator API** key pair, the same 300-second window, but a different signing input: ```text SIGNING_INPUT = METHOD + "\n" + PATH + "\n" + t + "\n" + SHA256_HEX(raw_body) v1 = HMAC-SHA256(operator_api_secret, SIGNING_INPUT) ``` `METHOD` is `POST`, `PATH` is the URL path only (`/v2/aggregator/launch_game`), `t` is unix seconds. The Postman collections `GA_Aggregator_API_v2` and `GA_Features_API_v2` compute this for you. Set `ga_api_key_id` and `ga_hmac_secret`. Use these values to test your signing code before you call GA. Secret, body, and timestamp are fixed, so your output must match to the byte. | Input | Value | |---|---| | Operator API HMAC secret | `sec-op-cert-01` | | `METHOD` | `POST` | | `PATH` | `/v2/aggregator/launch_game` | | `t` | `1783944000` | | Raw body (one line, no trailing newline) | `{"player_ref":"player-10428","game_id":"pragmatic-vs20olympgate","currency":"EUR","token":"lt-cert-556677","return_url":"https://operator.example/lobby"}` | | `SHA256_HEX(body)` | `fd1f9b4dff076382033df7e7f9e575f62cebefb6cdc28276fc6e95ec4f411eb0` | | Signing input | `POST\n/v2/aggregator/launch_game\n1783944000\nfd1f9b4dff076382033df7e7f9e575f62cebefb6cdc28276fc6e95ec4f411eb0` | | **Expected `v1`** | `d1de5557c4a004e53eec6e34930e331638497c15884d17e2a649a75ce82d5283` | The header you send is: ```http X-API-Key-Id: key-cert-op-01 X-GA-Signature: t=1783944000,v1=d1de5557c4a004e53eec6e34930e331638497c15884d17e2a649a75ce82d5283 ``` Note the separator. Unlike §2.1's GA→wallet signature (which joins `t`, `request_id`, and the body hash with `.`), the signing input here joins `METHOD`, `PATH`, `t`, and the body hash with newlines (`\n`). There is no trailing newline after the hash. ### 2.4 The wallet envelope Every wallet request has the same outer shape. `payload` is the action-specific part and the per-call ids live in `payload.meta`. ```json { "request_id": "0198a1d0-9a30-7f08-a7dd-713e4fd33db0", "ts": "2026-09-13T12:00:00Z", "operator_id": "0197aaaa-0000-7000-a000-000000000002", "action": "debit", "correlation": { "provider_slug": "pragmatic", "provider_round": "round-82721", "provider_ref": "spin-99182" }, "payload": { "meta": { "request_id": "0198a1d0-9a30-7f08-a7dd-713e4fd33db0", "op_id": "op-bet-48912", "operator_id": "0197aaaa-0000-7000-a000-000000000002", "session_id": "0198a1cb-4f37-7ee8-8e7d-08d39925aec0", "player_ref": "player-10428", "launch_token": "lt-abcdef123456", "provider_slug": "pragmatic", "game_code": "vs20olympgate", "round_id": "round-82721", "provider_ref": "spin-99182", "ts": "2026-09-13T12:00:00Z" }, "money": { "currency": "EUR", "amount": 100, "exponent": 2 } } } ``` | Field | Meaning | |---|---| | `op_id` | The business operation. The same bet has the same `op_id` on every retry. **Store your answer under it.** | | `request_id` | This one network attempt. Changes on every retry. Logs only. | | `round_id` | Groups the operations of one game round. Never an idempotency key. | | `session_id`, `launch_token` | The player session and the token you gave at launch. Absent on session-less credits, such as promo payouts. | | `player_ref` | Your player id, passed through unchanged. | | `correlation` | The game provider's own ids, for support tickets. | Ignore fields you don't know. New optional fields may appear. GA never removes or renames anything published. ### 2.5 Two answers Success: ```json { "status": "ok", "data": { "operator_tx_id": "tx-op-982103", "balance_after": { "currency": "EUR", "amount": 14900, "exponent": 2 }, "debited": { "currency": "EUR", "amount": 100, "exponent": 2 } } } ``` Refusal, **always HTTP 200**, the reason in the body, no balance: ```json { "status": "error", "error": { "code": "ERROR_CODE_INSUFFICIENT_FUNDS", "message": "Player available balance is below requested debit amount", "retryable": false } } ``` You may add `retry_after` (a duration such as `"5s"`) to a retryable code. `attributes` is a free-form string map for your own diagnostics. GA doesn't read it. ### 2.6 Money ```json { "currency": "EUR", "amount": 100, "exponent": 2 } ``` - `amount` is an integer in minor units. `100` with `exponent: 2` is 1.00 EUR. `exponent` is 0 to 18. GA never sends less than 2. - Take the exponent from the message, never from your own currency table. If it differs from what you hold the player's currency in, refuse with `ERROR_CODE_CURRENCY_MISMATCH`. - On **your wallet** `amount` is a JSON number: `100`. On **GA's own APIs** (chapter 4) it's a JSON string: `"100"`, because proto3 JSON encodes every 64-bit integer as a string. Same value, same minor units. A parser that accepts both is the safe choice. ### 2.7 Timeouts and retries The most important number: **GA waits 5 seconds for each wallet call.** Answer faster than that, or GA treats the call as lost. GA sees only two kinds of outcome: - **Refused.** A valid envelope with a non-retryable code, such as `INSUFFICIENT_FUNDS`. Final: GA doesn't resend that `op_id`. - **No answer.** A timeout, a dropped connection, a bare HTTP status with no envelope, or a valid envelope with a retryable code (`RATE_LIMITED`, `MAINTENANCE`, `INTERNAL`). GA doesn't know whether you applied the operation and retries with the **same `op_id`** and a new `request_id`. | Operation | On "no answer" GA does | |---|---| | `debit`, `debit_credit` | Up to 4 attempts, 100 ms, 500 ms, and 2 s apart, all inside one **8-second budget** for the bet. Each attempt waits at most 5 s or what's left of the budget, whichever is shorter. GA starts a retry only if the budget still holds the pause before it. So a wallet that doesn't answer at all gets **2 attempts** (5 s, then the rest of the 8 s). All 4 happen only when your wallet answers fast with a retryable code. Still nothing: the bet fails towards the game and GA sends **one rollback** for that `op_id`. | | `credit`, `settle_grant` | The same quick attempts, then a background resend with the same `op_id` for up to **72 hours**. Backoff starts at 1 second and doubles up to a cap of 10 minutes. The limit is the 72 hours, not a number of attempts. GA never rolls back a win. | | `rollback` | The same quick attempts, then the same 72-hour resend. Stops at the first valid envelope: success or refusal. | | `notify` | One attempt, no retry on HTTP/JSON. On gRPC it gets the same quick attempts as every other call. | GA honours a `Retry-After` header or `retry_after` field, capped at 10 seconds inside the quick attempts. When the 72 hours are over, GA flags the operation for manual review and alerts its team. GA never drops anything silently. What this means for your wallet: 1. A bet can reach you **after** GA already rolled it back. Rule 4 (chapter 3) handles it. 2. A win can reach you days later, after the session ended. Rule 3 handles it. 3. Every retry carries the same `op_id`, so Rule 1 makes retries harmless. ### 2.8 Errors on GA's side, in one place Your wallet's codes are Appendix A.1. What GA returns to you is Appendix A.2. Every error from `/v2/aggregator/*` and `/v2/features/*` has a non-200 HTTP status and one JSON body, `Content-Type: application/json`: ```json { "code": "ERROR_CODE_MAINTENANCE", "message": "provider_unavailable", "retryable": true } ``` `code` is one of the 15 `ERROR_CODE_*` values of Appendix A.1. `message` is for your logs and GA support. It starts with a stable token (`signature_invalid`, `game_unavailable`, `provider_timeout`, …). GA omits `retryable` when false. This holds for signature and key problems (401, 403) and for a read-only key on a write call (403) too. Retry only `429`, `503`, and `504` (`retryable: true` in the body), with backoff. Every other status, `502` included, is final for that request. --- # 3. Idempotency: the four rules that protect money Certification tests these hardest. Build them in from day one. The full normative text is in Money Path Rules. ### Rule 1. A repeated `op_id` gets the same answer Keep every `op_id` with its stored answer for at least **4 months**. GA resends for 72 hours, and the margin covers reconciliation disputes. - Same `op_id`, same content: return the stored answer, move no money. Even if the balance is now too low or the session expired. - Same `op_id`, **different** content (player, operation type, currency, amount with exponent, round, original, grant): refuse with `ERROR_CODE_IDEMPOTENCY_CONFLICT`, move no money. - Two identical requests at the same moment: the second waits for the first and gets the same answer. Never apply twice. ### Rule 2. Check things in this order ``` signature -> schema -> op_id lookup -> player + currency/exponent -> [bets only: session, player status, limits, balance] -> round state -> apply and store the answer in one atomic step ``` The `op_id` lookup comes **before** session, player, and balance checks. Otherwise a repeated bet answers "insufficient funds" or "session expired" where it must answer the stored success, and you and GA disagree about whether the bet exists. ### Rule 3. Never refuse a win, a rollback, or a grant settlement for session, block, or limits A win can arrive hours after the session ended, for a blocked player, or past a deposit limit. **Accept it.** The only final refusals for a credit are: player not found, currency mismatch, invalid request, idempotency conflict, unknown original, already rolled back. After any other refusal, GA resends the credit for 72 hours, and then it lands in manual review. A promo, bonus, jackpot, or tournament payout may arrive with no bet in the round and with no session. Don't require either. ### Rule 4. A rollback of something you never saw is a success, and you remember it If a rollback names an `original_op_id` you don't have: 1. Return `status: ok` with `"original_found": false`. Move no money. 2. Store that `original_op_id` as "rolled back". 3. If the original debit or credit arrives later, refuse it with `ERROR_CODE_ALREADY_ROLLED_BACK`. Never return `ERROR_CODE_UNKNOWN_ORIGINAL` to a rollback. Step 2 is what stops you from charging a player for a bet GA already cancelled. --- # 4. You call GA: launch, catalog, free rounds, reports Base URL `https://api.rexplay.site` (sandbox) or `https://api.game-alligator.com` (production). Every call is `POST` with a JSON body and the signature of §2.3. Paths are `/v2/aggregator/` in snake_case. The sandbox also accepts the proto method name (`/v2/aggregator/LaunchGame`, case-sensitive). Sign exactly the path you call. Success is `200` with the call's own response. Amounts are strings in minor units (§2.6). Timestamps are Request for Comments (RFC) 3339. Paginated calls take `page: {cursor, limit}` and return `page: {next_cursor, has_more, total_count}`. `limit` defaults to 50, with a maximum of 500. Start from Postman: import `postman/GA_Aggregator_API_v2.postman_collection.json` with `postman/GA_Sandbox_v2.postman_environment.json`, set `ga_api_key_id` and `ga_hmac_secret`, and send `launch_game` first. The pack with both files is `/downloads/GA_Operator_Integration_Pack_v2.0.0.zip` on the docs site. ### 4.1 Launch a game `POST /v2/aggregator/launch_game` | Field | Required | Meaning | |---|---|---| | `player_ref` | yes | Your player id. Opaque to GA, returned unchanged in every wallet call. | | `game_id` | yes | From the catalog (§4.3). | | `currency` | yes | The currency of this session. Uppercase code. GA takes the exponent from your onboarding profile. | | `token` | yes | **Your launch token.** GA stores it and sends it back as `launch_token` in every wallet call of this session. It's how you tie a wallet call to a player session. | | `return_url` | no | Where the game's "back to lobby" button leads. | | `attributes` | no | String map. `attributes["language"]` = two-letter lowercase code (`"de"`). Anything else falls back to `"en"`. | Response: `session_id`, `launch_url`, `expires_at` (the session lives **24 hours**), plus running `total_bet`, `total_win`, `rounds`. `launch_game` is idempotent by `token` while the session is open. The same token with the same player, game, and currency returns the first response again: the same `session_id`, `launch_url`, and `expires_at`. So a page reload doesn't open a second session, and GA doesn't call the game provider again. GA refuses the same token with `409` `ERROR_CODE_IDEMPOTENCY_CONFLICT` (`session_token_conflict`) in these cases: - the token comes with another player, game, or currency; - the session of that token is closed: by `close_session`, or by a newer launch of the same game for the same player, because a launch closes that player's earlier sessions of the game; - the session of that token is past its `expires_at`. GA never reopens a closed session. Don't retry the `409`: launch again with a fresh token. If the first launch with the token is still running, GA answers `503` `ERROR_CODE_INTERNAL` with `retryable: true` (`launch_in_progress`). Retry with the same token to get the first response. Use a fresh token for every new session. Open `launch_url` in an iframe or by redirect. Both work. The URL is single-session: launch again for a new session. Device, IP, and country aren't fields on this call. If you embed `launch_url` in an `iframe`, set a `sandbox` attribute. Don't omit it, and don't use an unrestricted frame. `launch_url` always points to a real address that you load via `src`, never as inline HTML, so the minimal working set is: ```html ``` - `allow-scripts`—the game is a script-driven client. Without it, nothing runs. - `allow-same-origin`—the frame loads from `src`, so this keeps it same-origin with *GA's* domain, not yours, which the game's own websocket connection and storage need. It doesn't weaken your page's origin boundary. - `allow-forms`—some game and payment flows submit forms inside the frame. - `allow-popups`—rule and payment/cashier windows some games open are new browser contexts, not just navigation inside the frame. - `allow="autoplay; fullscreen"` and `allowfullscreen`—background/video-driven games and fullscreen mode need these. Playback or fullscreen silently fails without them. ```json { "player_ref": "player-10428", "game_id": "0198a2aa-1111-7000-a000-000000000077", "currency": "EUR", "token": "lt-abcdef123456", "return_url": "https://casino.example/lobby", "attributes": { "language": "de" } } ``` ```json { "session_id": "0198a1cb-4f37-7ee8-8e7d-08d39925aec0", "player_ref": "player-10428", "game_id": "0198a2aa-1111-7000-a000-000000000077", "currency": "EUR", "launch_url": "https://play.rexplay.site/s/0198a1cb-4f37-7ee8-8e7d-08d39925aec0", "expires_at": "2026-09-14T12:00:00Z", "total_bet": { "currency": "EUR", "amount": "0", "exponent": 2 }, "total_win": { "currency": "EUR", "amount": "0", "exponent": 2 }, "rounds": 0 } ``` ### 4.2 Launch a demo `POST /v2/aggregator/launch_demo` with `game_id` and optional `return_url`. No player, no token, no currency, no session: a demo never reaches your wallet. Response: `launch_url`, `game_id`. Games with `demo_supported: false` refuse with `412` (`DEMO_NOT_SUPPORTED`, Appendix A.2). ### 4.3 Catalog `POST /v2/aggregator/games` with optional filters `query`, `vertical`, `category`, `provider`, `tags[]`, `updated_since`, `page`. Each `Game` carries: `game_id`, `title`, `vertical`, `category`, `provider`, `enabled`, `demo_supported`, `wager_weight_bp`, `tags[]`, `assets[]`, `limits`, `fee_bp`, `fee_source`, `rtp`, `features[]`, `updated_at`. `wager_weight_bp` is how much a bet on this game contributes to bonus wagering and loyalty accrual, in basis points, 0 to 10000. 10000 bp = 100 %, 1000 bp = 10 %, 0 bp = 0 %. `assets[]` is `{kind, url}` with kinds `banner`, `thumbnail`, `background`, `icon`. `limits` is `min_bet`, `max_bet`, `max_win`. `fee_bp` is GA's fee for this game on your brand, in basis points, 0 to 10000. It's an `optional` field—present only once GA has resolved a fee for this game/brand pair, absent otherwise, so check presence rather than treating "absent" as 0. `fee_source` is which fee-ladder tier produced `fee_bp`: `FEE_SOURCE_UNSPECIFIED`, `FEE_SOURCE_PROVIDER`, `FEE_SOURCE_GAME`. `rtp` is the return to player (RTP) in percent, for example `96.5`. The wire value is also `0` when GA has recorded no RTP for this game, so a `0` doesn't mean "0 % RTP". `features[]` is always empty in this call. Call `get_game_features` (§6.1) to get a game's actual `features[]`. Practical rules: - Poll with `updated_since` (the last `updated_at` you stored) rather than reloading the whole catalog. A daily full reload is enough. - Download `assets[].url` images to your own content delivery network (CDN) and serve them from there. Don't hotlink them from your lobby. - Show only `enabled: true` games. A disabled game refuses to launch. Other catalog calls are `capabilities`, `operator_capabilities`, and `update_game`. `capabilities` is what GA supports for you: currencies, settlement modes, streaming. `operator_capabilities` is what GA has on record for your wallet: `wallet_capabilities` with `rpcs`, `credit_kinds`, `debit_modes`, `notify_kinds`, `authenticate`, and `supported_transports`. `update_game` changes your own settings on a game, partial update with `update_mask` and a `reason` for audit. ### 4.4 Free rounds you issue `POST /v2/aggregator/issue_grant` | Field | Required | Meaning | |---|---|---| | `player_ref`, `game_ids[]` | yes | Who and on which games. | | `rounds_total` | yes | Number of free rounds. | | `round_value` | yes | Stake value of one round (Money). | | `bet_level`, `lines` | no | Provider bet level and lines, from `get_bet_ranges` (§6.1) where the game has levels. | | `settlement_mode` | no | GA supports only `SETTLEMENT_MODE_ON_COMPLETION`. Leave it out. | | `idempotency_key` | recommended | The same key returns the same grant. GA refuses a different body under the same key with `IDEMPOTENCY_CONFLICT`. Without a key every call creates a new grant. | | `valid_until` | yes | Hard end date. GA refuses rounds after it. | | `external_ref` | no | Your campaign or bonus id. | Response: `Grant` with `grant_id`, `status` (`active`, `exhausted`, `expired`, `cancelled`, `settled`), `rounds_remaining`, `rounds_played`, `accumulated_win`. How the money flows for a grant you issued: **no bet and no win reaches your wallet while the player plays the rounds.** The win accumulates on the grant. When the grant is exhausted, expired, or cancelled with a win, GA sends **one `settle_grant`** to your wallet with `total_win`, and that's when you credit the player (§5.9). GA also settles a zero-win grant, with `total_win: 0`. `list_grants` (by `player_ref` and `status`) and `cancel_grant` (`grant_id`, `reason`) complete the set. You can cancel only an active grant, and GA still settles the win already earned. ### 4.5 Sessions, rounds, and reports | Call | Send | Get back | |---|---|---| | `close_session` | `session_id`, `reason` (player request, self-exclusion, limit, operator request, suspicion, timeout) | the closed session | | `sessions` | `player_ref`, `page` | sessions | | `rounds` | `session_id` or `player_ref`, `start_time`, `end_time`, `page` | rounds with `total_bet`, `total_win`, `status` (`SETTLED`, `REFUNDED`, `VOIDED`, `FAILED`, `REFUND_FAILED`, see §4.6), `placed_at`, `settled_at` | | `export_rounds` | same filters plus `currency` | the same rounds streamed as NDJSON lines `{"result": …}`, for bulk reconciliation | | `aggregates` | `date` (`YYYY-MM-DD`), `currency` | `total_rounds`, `total_bet_amount`, `total_win_amount`, `total_refund_amount`, `net_ggr`, `total_fee` | Use `aggregates` for the daily settlement check and `export_rounds` when a number doesn't match. ### 4.6 Event stream (optional) `subscribe` (gRPC server stream) delivers round, grant, session, and notification events with a monotonic `sequence` per brand. `ack` confirms the cursor. It's an alternative to polling `rounds`, and a first integration doesn't need it. Round events carry a `Round`. Keep the latest one by `sequence` per `(player_ref, round_id)`: | `event_type` | `Round.status` | Meaning | |---|---|---| | `round.settled.raw` | `SETTLED` | The round closed without a rollback. | | `round.refunded` | `REFUNDED` / `VOIDED` | Part or all of the round's money was rolled back. | | `round.failed` | `FAILED` | Your wallet declined an operation of the round. That operation moved no money. `total_bet` / `total_win` hold the declined amount. | | `round.failed` | `REFUND_FAILED` | GA owed a rollback and couldn't complete it: retries ran out, or your wallet refused the rollback. **The stake is still debited.** Reconcile the round manually. `total_bet` (or `total_win` for an unpaid re-issued credit) holds the stuck amount. | --- # 5. GA calls your wallet: the actions GA sends `POST` to `https:///v2/wallet/`. Ten actions exist. Five are mandatory. | Action | Mandatory | What GA asks | |---|---|---| | `get_balance` | **yes** | "How much can this player play with?" | | `debit` | **yes** | "Take the stake for this bet." | | `credit` | **yes** | "Pay this win." | | `rollback` | **yes** | "Undo this earlier operation." | | `reconcile` | **yes** | "Did this operation go through?" | | `authenticate` | no | "This player is opening a game. Who are they and what's their balance?" | | `debit_credit` | no | "Take the stake and pay the win in one step." | | `close_round` | no | "This round is over. Here are the totals." | | `settle_grant` | no (mandatory if you issue free rounds) | "This free-round package is finished. Here is the total win." | | `notify` | no | "Something non-monetary happened." | For an optional action you don't support, return the error envelope with `ERROR_CODE_UNSUPPORTED_ACTION` on HTTP 200 or 404. GA reads the envelope and stops calling that action. A bare 404 without the envelope is "no answer", and GA retries it. The examples below show the envelope reduced to the fields that matter. `ts`, `operator_id`, `correlation`, and the headers are as in chapter 2. All values are illustrative. ### 5.1 `get_balance` **Return** the playable balance in the requested currency. Change nothing. ```json { "request_id": "req-0101", "action": "get_balance", "payload": { "meta": { "request_id": "req-0101", "op_id": "op-bal-1001", "player_ref": "player-10428", "session_id": "0198a1cb-4f37-7ee8-8e7d-08d39925aec0" }, "currency": "EUR" } } ``` ```json { "status": "ok", "data": { "balance": { "currency": "EUR", "amount": 15000, "exponent": 2 }, "bonus_balance": { "currency": "EUR", "amount": 500, "exponent": 2 } } } ``` Refuse only for: player not found, currency mismatch, invalid request. ### 5.2 `authenticate` (optional) **When:** the player opens a game. GA sends the `launch_token` you passed at launch. **Return** who the player is, the currency, and the balance. Keep the token valid: it comes back in every wallet call of the session. ```json { "request_id": "req-0102", "action": "authenticate", "payload": { "meta": { "request_id": "req-0102", "op_id": "op-auth-1002", "player_ref": "player-10428", "session_id": "0198a1cb-4f37-7ee8-8e7d-08d39925aec0", "launch_token": "lt-abcdef123456" } } } ``` ```json { "status": "ok", "data": { "player_ref": "player-10428", "currency": "EUR", "balance": { "currency": "EUR", "amount": 15000, "exponent": 2 }, "bonus_balance": { "currency": "EUR", "amount": 500, "exponent": 2 }, "attributes": { "country": "DE", "nickname": "lucky_ann" } } } ``` Refuse for: unknown or expired token (`SESSION_EXPIRED`), blocked player (`PLAYER_BLOCKED`), player not found. ### 5.3 `debit` **Do:** take the stake. **Return** your transaction id, the balance after, the amount debited. Refuse when: - `amount` is zero or negative: `INVALID_REQUEST`. - the balance can't cover it: `INSUFFICIENT_FUNDS`. Final: GA doesn't resend that bet. - the session or token is expired or missing on a call that names a session: `SESSION_EXPIRED` / `INVALID_REQUEST`. - the player is blocked or over a limit: `PLAYER_BLOCKED` / `LIMIT_EXCEEDED`. - the round is already closed or voided: `BUSINESS_REJECTED`. Never refuse a **repeated** debit (Rule 1). A debit is also how a balance correction from the game provider reaches you, sometimes outside a live session. Apply it the same way. ```json { "request_id": "req-0103", "action": "debit", "payload": { "meta": { "request_id": "req-0103", "op_id": "op-bet-48912", "player_ref": "player-10428", "session_id": "0198a1cb-4f37-7ee8-8e7d-08d39925aec0", "launch_token": "lt-abcdef123456", "round_id": "round-82721", "provider_ref": "spin-99182" }, "money": { "currency": "EUR", "amount": 100, "exponent": 2 } } } ``` ```json { "status": "ok", "data": { "operator_tx_id": "tx-op-982103", "balance_after": { "currency": "EUR", "amount": 14900, "exponent": 2 }, "debited": { "currency": "EUR", "amount": 100, "exponent": 2 } } } ``` ### 5.4 `credit` **Do:** add the win. Rule 3 applies: never refuse for session, block, or limits. **Return** your transaction id and the balance after. - `amount` may be `0` for a losing spin that closes the round. Answer `ok` with the unchanged balance. - `kind` says what the payout is: `CREDIT_KIND_WIN`, `_BONUS`, `_JACKPOT`, `_PROMO`, `_FREE_ROUND`, `_TOURNAMENT`, `_CASHBACK`, `_ADJUSTMENT`. - `components[]` is an optional breakdown (`KIND_BASE`, `KIND_JACKPOT`, `KIND_PROMO`, `KIND_BONUS`, `KIND_CONTRIBUTION`). The parts sum to `money`. - `round_finish: true` means this credit ends the round. Still accept a credit into a round you already closed. - `campaign_ref` names the provider promo on a `FREE_ROUND` credit. Refuse only for: player not found, currency mismatch, invalid request, idempotency conflict, unknown original, already rolled back. ```json { "request_id": "req-0104", "action": "credit", "payload": { "meta": { "request_id": "req-0104", "op_id": "op-win-48913", "player_ref": "player-10428", "session_id": "0198a1cb-4f37-7ee8-8e7d-08d39925aec0", "launch_token": "lt-abcdef123456", "round_id": "round-82721", "provider_ref": "spin-99182" }, "money": { "currency": "EUR", "amount": 2500, "exponent": 2 }, "kind": "CREDIT_KIND_WIN", "round_finish": true } } ``` ```json { "status": "ok", "data": { "operator_tx_id": "tx-op-982104", "balance_after": { "currency": "EUR", "amount": 17400, "exponent": 2 } } } ``` ### 5.5 `debit_credit` (optional) **Do:** take the stake and pay the win in one atomic step. Check the stake against the **current** balance first, exactly like a debit. If it doesn't cover `debit_money`, refuse the whole call with `INSUFFICIENT_FUNDS` and change nothing. Never apply only one half. ```json { "request_id": "req-0105", "action": "debit_credit", "payload": { "meta": { "request_id": "req-0105", "op_id": "op-step-48914", "player_ref": "player-10428", "session_id": "0198a1cb-4f37-7ee8-8e7d-08d39925aec0", "launch_token": "lt-abcdef123456", "round_id": "round-82721", "provider_ref": "spin-99183" }, "debit_money": { "currency": "EUR", "amount": 100, "exponent": 2 }, "credit_money": { "currency": "EUR", "amount": 300, "exponent": 2 }, "credit_kind": "CREDIT_KIND_WIN", "round_finish": true } } ``` ```json { "status": "ok", "data": { "operator_tx_id": "tx-op-982105", "balance_after": { "currency": "EUR", "amount": 17600, "exponent": 2 }, "debited": { "currency": "EUR", "amount": 100, "exponent": 2 } } } ``` ### 5.6 `rollback` **Do:** undo the operation named by `original_op_id`. Rule 4 applies when you don't know it. **Return** your transaction id, the balance after, and `original_found`. - Rolling back a **debit** is always for the full stored amount: return the stake to the player. - Rolling back a **credit** takes money back. `debit_mode` says what to do when the balance is too low. `DEBIT_MODE_REJECT` means refuse with `INSUFFICIENT_FUNDS`. GA's own rollbacks use this mode. `DEBIT_MODE_ALLOW_NEGATIVE` means go negative. `DEBIT_MODE_PARTIAL` means take what's there, down to zero, and report the taken amount in `debited`. `partial: true` marks a partial rollback of a credit. - A second rollback of the same original returns the first answer and moves nothing. - If you're still committing the original, answer an error envelope with `retryable: true`. GA comes back. - If the player or currency doesn't match the original, refuse with `INVALID_REQUEST` or `CURRENCY_MISMATCH` and move nothing. Never refuse a rollback for session, block, or limits. Any final refusal you do send (for example `INSUFFICIENT_FUNDS` under `DEBIT_MODE_REJECT`) stops GA's retries at once and puts the operation into manual review on GA's side. ```json { "request_id": "req-0106", "action": "rollback", "payload": { "meta": { "request_id": "req-0106", "op_id": "op-rb-48915", "player_ref": "player-10428", "session_id": "0198a1cb-4f37-7ee8-8e7d-08d39925aec0", "launch_token": "lt-abcdef123456", "round_id": "round-82721", "provider_ref": "spin-99182" }, "original_op_id": "op-bet-48912", "original_kind": "ORIGINAL_KIND_DEBIT", "money": { "currency": "EUR", "amount": 100, "exponent": 2 } } } ``` ```json { "status": "ok", "data": { "operator_tx_id": "tx-op-982106", "balance_after": { "currency": "EUR", "amount": 17700, "exponent": 2 }, "original_found": true } } ``` Unknown original: same shape with `"original_found": false`, no money moved, and the id stored as rolled back. ### 5.7 `close_round` (optional) **Do:** record that the round is over. `net_win`, `bet_total`, `win_total` are for your reports. No money moves on this call. ```json { "request_id": "req-0107", "action": "close_round", "payload": { "meta": { "request_id": "req-0107", "op_id": "op-close-48916", "player_ref": "player-10428", "round_id": "round-82721" }, "net_win": { "currency": "EUR", "amount": 200, "exponent": 2 }, "bet_total": { "currency": "EUR", "amount": 100, "exponent": 2 }, "win_total": { "currency": "EUR", "amount": 300, "exponent": 2 } } } ``` ```json { "status": "ok", "data": { "operator_tx_id": "tx-op-982107", "balance_after": { "currency": "EUR", "amount": 17700, "exponent": 2 } } } ``` ### 5.8 `reconcile` **When:** GA got no clear answer to an operation and asks what happened. **Do:** look up `original_op_id` and report. **Change nothing and remember nothing.** This is a read. Never return `UNKNOWN_ORIGINAL` here. | Your record of `original_op_id` | Return `state` | |---|---| | not there | `RECONCILE_STATE_NOT_APPLIED` | | still being committed | `RECONCILE_STATE_UNKNOWN` | | applied | `RECONCILE_STATE_APPLIED` with the original `operator_tx_id` and `balance_after` | | rolled back, or remembered as rolled back (Rule 4) | `RECONCILE_STATE_NOT_APPLIED` | ```json { "request_id": "req-0110", "action": "reconcile", "payload": { "meta": { "request_id": "req-0110", "op_id": "op-rec-48918", "player_ref": "player-10428" }, "original_op_id": "op-bet-48912" } } ``` ```json { "status": "ok", "data": { "state": "RECONCILE_STATE_APPLIED", "operator_tx_id": "tx-op-982103", "balance_after": { "currency": "EUR", "amount": 14900, "exponent": 2 } } } ``` ### 5.9 `settle_grant` (mandatory if you issue free rounds) **When:** a grant you issued with `issue_grant` (§4.4) is finished: exhausted, expired, or cancelled with a win. **Do:** credit `total_win` to the player, once per `grant_id`. The call is idempotent by `op_id`. GA sent no debits and no credits for the rounds, so this call is the only place the win reaches the player. `total_win: 0` needs only an `ok`. Exception (Money Path Rules §2.10 item 7): a win of a round played before the grant ended can reach GA after the grant ended (settled via `settle_grant`, or cancelled or expired before payout). Such a win arrives as a separate `credit` with `kind = CREDIT_KIND_FREE_ROUND` and `campaign_ref` set to the `grant_id`, idempotent by `op_id` like any other credit. A new round reported on a grant that has already ended is still refused to GA and never reaches you. Two free-round mechanisms exist and they never overlap for the same win: | Mechanism | Who starts it | How the win reaches your wallet | |---|---|---| | Grant you issue (`issue_grant`) | you | one `settle_grant` with `total_win` at the end | | Promo free spins from the game provider | the provider or a GA campaign | an ordinary `credit` with `CREDIT_KIND_FREE_ROUND` and `campaign_ref` per winning spin | Free-round wins may arrive with no `session_id` and no `launch_token`. Accept them (Rule 3). ```json { "request_id": "req-0108", "action": "settle_grant", "payload": { "meta": { "request_id": "req-0108", "op_id": "op-grant-7781", "player_ref": "player-10428" }, "grant_id": "grant-7781", "total_win": { "currency": "EUR", "amount": 1250, "exponent": 2 }, "rounds_played": 20 } } ``` ```json { "status": "ok", "data": { "operator_tx_id": "tx-op-982108", "balance_after": { "currency": "EUR", "amount": 18950, "exponent": 2 } } } ``` ### 5.10 `notify` (optional) **Do:** record the event and answer `ok`. Nothing to move. GA sends it once and doesn't retry. Kinds: `NOTIFY_KIND_FREE_ROUNDS_STARTED`, `_TOKEN_RESET`, `_SESSION_EXPIRED`, `_REALITY_CHECK`, `_PRIZE_AWARDED`, `_FREESPIN_ISSUED`, `_FREESPIN_FINISHED`, `_BONUS_BUY_FINISHED`. ```json { "request_id": "req-0109", "action": "notify", "payload": { "meta": { "request_id": "req-0109", "op_id": "op-ntf-48917", "player_ref": "player-10428", "session_id": "0198a1cb-4f37-7ee8-8e7d-08d39925aec0" }, "kind": "NOTIFY_KIND_SESSION_EXPIRED", "data": { "attributes": { "reason": "idle_timeout" } } } } ``` ```json { "status": "ok", "data": { "operator_tx_id": "ntf-op-1" } } ``` --- # 6. Optional: provider features `POST https://api.rexplay.site/v2/features/`, signed as in §2.3. Each game supports only some features. Call `get_game_features` first and look at `features[]`. Postman: `postman/GA_Features_API_v2.postman_collection.json`. ### 6.1 The calls | What you want | Call | Send | Get back | |---|---|---|---| | Which features does this game support? | `get_game_features` | `game_code`, `provider_slug` | `features[]` (`FEATURE_FREE_ROUNDS_GRANT`, `FEATURE_BONUS_BUY`, `FEATURE_JACKPOT_FEED`, `FEATURE_BET_RANGES`, `FEATURE_ROUND_REPLAY`, `FEATURE_TOURNAMENT`, `FEATURE_DEMO`, …), `limits` | | Allowed stake levels for a game | `get_bet_ranges` | `provider_slug`, `game_code`, `currency` | `bet_ranges[]` with `bet_level`, `bet_value`, `min_bet`, `max_bet`, `step`. Use `bet_level` in `issue_grant`. | | Bonus buy | `list_bonus_buy_types`, `issue_bonus_buy`, `cancel_bonus_buy` | `provider_slug`, `game_code`, `player_ref`, `bonus_type`, `amount`, `currency`, `attributes["idempotency_key"]` | `provider_ref` | | Current jackpot values | `get_jackpots` | `provider_slug`, `currency` | `jackpots[]` | | Replay or details of a disputed round | `get_round_replay`, `get_round_details` | `provider_slug`, `round_id`, `player_ref` | `replay_url` / `details` | | Provider campaigns | `list_campaigns`, `cancel_campaign` | `provider_slug`, `campaign_id` | campaigns | | Tournaments | `list_tournaments`, `get_tournament`, `get_leaderboard` | `provider_slug`, `tournament_id` | tournaments, `entries[]` | | Provider-side statement for reconciliation | `query_provider_transactions` | `provider_slug`, `player_ref`, `date_from`, `date_to` | `transactions[]`, totals | Errors: the `{"code", "message", "retryable"}` body of §2.8 with a non-200 status, Appendix A.2. --- # 7. Data formats | Value | Format | |---|---| | Currency | Uppercase letters and digits, 3 to 10 characters. International Organization for Standardization (ISO) 4217 for fiat (`EUR`), exchange ticker for crypto (`USDT`). You and GA agree the exponent per currency at onboarding. | | Money | `{currency, amount, exponent}`. See §2.6. Number on your wallet, string on GA's APIs. | | Timestamps | RFC 3339 in Coordinated Universal Time (UTC): `2026-09-13T12:00:00Z`. `aggregates.date` is `YYYY-MM-DD`. | | Language | Two lowercase letters: `en`, `de`. Regional forms (`en-GB`) fall back to `en`. | | Ids (`player_ref`, `op_id`, `round_id`, `game_id`, `session_id`) | Opaque strings. GA sets no length limit. Keep yours under 128 characters. GA's session ids are universally unique identifiers (UUIDs). | | `wager_weight_bp`, `fee_bp` | Basis points, 0 to 10000. | Unknown fields in any message must be ignored. Unknown enum values in a known field are refused with `INVALID_REQUEST`. --- # 8. Network and limits - **Your wallet must answer within 5 seconds** per call (§2.7). Aim for well under 1 second. - GA doesn't filter your source IP on the Operator API. If you restrict inbound traffic to your wallet, allow GA's outbound addresses: `49.13.169.177` and `46.224.156.1` for the sandbox, `49.13.169.177` for production. - GA enforces no request rate limit on the Operator API today. Keep catalog polling to once a minute or less and use `updated_since`. GA reserves a `429` with `ERROR_CODE_RATE_LIMITED` for abuse. - Secrets never go into source control, browser code, tickets, or logs. --- # 9. Certification and go-live ### 9.1 Run the checks from the Operator Portal Log in to the Operator Portal with an `operator_admin` account and open **Integration**. The **Callback test** section runs the certification checks against your sandbox wallet: 1. Save your wallet endpoint URL. For the v2 contract the URL must contain `/v2` (for example `https://wallet.operator.example/v2/wallet`). That's how the portal picks the v2 suite. 2. Create a test player with the **person_add** button. The portal creates and funds it. The player reference doubles as the launch token in the run. 3. Choose **Full suite**, set the currency and bet amount, and press **Run suite**. The run shows every check with its pass/fail state, balance deltas, and a hint for each failure. You can replay bet and refund steps one by one. The checks are the same 55 `W2-*` checks the command-line tool runs. Six of them (`W2-D1` to `W2-D6`) test the four rules of chapter 3. You're done when every check passes. ### 9.1a The command-line tool (for your CI) The same suite exists as `ga-operator-certify` for automated runs in your pipeline. Ask your GA integration manager for the binary for your platform. ```bash ./ga-operator-certify \ --binding http \ --target https://wallet.operator.example \ --api-key key-cert-01 \ --secret sec-cert-01 \ --strict ``` Before running, create in your wallet a test player `certify-player-001` (or pass `--player-ref`) with a funded EUR balance (or `--currency`), and a valid launch token for that player passed with `--launch-token` (default `token-cert-001`). The tool creates neither. `--json` writes a report with the request and response of every failed check. ### 9.2 The checklist Go through `certification/GA_Operator_Certification_Checklist_v2.md` with your GA integration manager and sign it on both sides. Then repeat the Postman run, the tool, and the checklist against your production wallet with production credentials. ### 9.3 Changelog Contract changes are additive only, and GA lists them in `CHANGELOG.md` in this package. GA never removes or renames anything published. New optional fields may appear at any time, which is why your parser must ignore unknown fields. --- # Appendix A. Every error code ### A.1 Codes your wallet returns to GA Always HTTP 200 with `{"status":"error","error":{"code":…}}` (§2.5). 15 codes. The list is closed. | Code | GA retries? | When to return it | |---|---|---| | `ERROR_CODE_INSUFFICIENT_FUNDS` | no | The balance can't cover the stake, or a credit rollback under `DEBIT_MODE_REJECT` | | `ERROR_CODE_PLAYER_NOT_FOUND` | no | No such player | | `ERROR_CODE_PLAYER_BLOCKED` | no | Player blocked, self-excluded, or disabled. **New bets only.** Still accept wins and rollbacks | | `ERROR_CODE_CURRENCY_MISMATCH` | no | Currency or exponent doesn't match the player's account | | `ERROR_CODE_BUSINESS_REJECTED` | no | Your own business rule refused it. Also a new bet into a closed or voided round | | `ERROR_CODE_UNKNOWN_ORIGINAL` | no | A credit names an original you don't have. **Never for a rollback or a reconcile** | | `ERROR_CODE_ALREADY_ROLLED_BACK` | no | You already rolled back the `op_id` (Rule 4), or the round is voided | | `ERROR_CODE_UNSUPPORTED_ACTION` | no | You don't implement this optional action (HTTP 200 or 404, with the envelope) | | `ERROR_CODE_RATE_LIMITED` | yes | You are rate-limiting GA (HTTP 200 or 429, with the envelope) | | `ERROR_CODE_MAINTENANCE` | yes | Your wallet is down for maintenance | | `ERROR_CODE_INVALID_REQUEST` | no | Malformed request: missing field, zero or negative amount, overflow, unknown enum value, missing token on a session call | | `ERROR_CODE_SESSION_EXPIRED` | no | Launch token or session expired. **New bets only** | | `ERROR_CODE_LIMIT_EXCEEDED` | no | Deposit or loss limit reached. **New bets only** | | `ERROR_CODE_INTERNAL` | yes | Unexpected error on your side (HTTP 200 or 500, with the envelope) | | `ERROR_CODE_IDEMPOTENCY_CONFLICT` | no | Same `op_id`, different content (Rule 1) | Bare statuses without the envelope (`401`, `403`, `404`, `429`, `5xx`) are all "no answer": GA retries, and a bet ends in a rollback. ### A.2 What GA returns to you Every `/v2/aggregator/*` and `/v2/features/*` error is the body of §2.8. `message` starts with the token in the table. | HTTP | `code` | Retry? | `message` starts with | Meaning | |---|---|---|---|---| | `400` | `ERROR_CODE_INVALID_REQUEST` | no | `invalid json body`, `invalid request`, `invalid_request`, `invalid_provider_ref`, `capability_not_supported`, `bonus_buy_not_cancellable` | Malformed JSON, missing or invalid field, GA can't read the body, or the game provider doesn't offer the capability | | `401` | `ERROR_CODE_INVALID_REQUEST` | no | `unauthorized`, `signature_missing`, `signature_invalid`, `signature_stale` | Wrong or missing key id, signature, or a timestamp outside 300 s | | `403` | `ERROR_CODE_BUSINESS_REJECTED` | no | `forbidden` | The operator is suspended, or the key isn't allowed to do this (for example a read-only key on `launch_game`) | | `403` | `ERROR_CODE_BUSINESS_REJECTED` or `ERROR_CODE_INVALID_REQUEST` | no | `operator_suspended`, `operator forbidden`, `restricted`, `game_unavailable`, `PLAYER_SELF_EXCLUDED`, `PLAYER_COOL_OFF` | Not allowed for this operator, game, or player | | `404` | `ERROR_CODE_INVALID_REQUEST` | no | `provider not found`, `provider_not_found`, `game not found`, `player_not_found`, `bonus_buy_not_found` | Provider, game, player, grant, or bonus not found | | `409` | `ERROR_CODE_IDEMPOTENCY_CONFLICT` | no | `session_token_conflict` | You reused the `token` of `launch_game` with another player, game, or currency, or after its session closed or expired, or the `idempotency_key` of `issue_grant` with a different body (§4.1, §4.4) | | `412` | `ERROR_CODE_INVALID_REQUEST` | no | `DEMO_NOT_SUPPORTED` | `launch_demo` on a game with `demo_supported: false` | | `422` | `ERROR_CODE_INVALID_REQUEST` | no | `unsupported action` | Action not supported for this game or provider | | `429` | `ERROR_CODE_RATE_LIMITED` | yes | | Rate limit | | `500` | `ERROR_CODE_INTERNAL` | no | `internal error` | GA-side failure. GA gets an alert. Contact support if it repeats | | `501` | `ERROR_CODE_UNSUPPORTED_ACTION` | no | | Call not implemented on this binding (`settlement_mode PER_ROUND`) | | `502` | `ERROR_CODE_INTERNAL` | no (`retryable` not set) | `provider_error` | The game provider returned an error. Repeating the same call returns the same error | | `503` | `ERROR_CODE_MAINTENANCE` | yes | `provider_unavailable` | The game provider is unavailable | | `503` | `ERROR_CODE_INTERNAL` | yes | `launch_in_progress` | The first `launch_game` with this `token` is still running. Retry with the same `token` (§4.1) | | `504` | `ERROR_CODE_INTERNAL` | yes | `provider_timeout` | The game provider timed out | Retry only `429`, `503`, and `504`, with backoff. --- # Appendix B. Numbers to remember | What | Value | |---|---| | GA waits for one wallet call | 5 seconds | | Quick attempts on "no answer" | up to 4, spaced 100 ms, 500 ms, 2 s, inside an 8 s budget per bet (a silent wallet gets 2) | | Background resend of a credit, rollback, or grant settlement | up to 72 hours, backoff 1 s doubling to a 10-minute cap | | `Retry-After` honoured up to | 10 seconds inside the quick attempts | | Signature clock window, both directions | ±300 seconds | | GA outbound IPs | `49.13.169.177`, `46.224.156.1` (sandbox). `49.13.169.177` (production) | | Keep `op_id` records for | 4 months | | Game session lifetime | 24 hours | | Money `exponent` | 0 to 18, from the message. GA sends 2 or more | | Catalog page size | 50 by default, 500 maximum | | Wallet HMAC secret shown | once, at rotation in the Operator Portal | | Certification checks | 55, of which 6 are money-path checks | # Money Path Rules The rules below govern the v2 wallet contract when a call goes wrong. The cases are a timeout, a duplicate, a rollback of an operation you never saw, and a win that arrives after the session closed. They're additive: GA removes or renames nothing published in `proto/` or `openapi/`. **Conventions.** - Normative keywords (MUST, MUST NOT, SHOULD, MAY) are used as in Request for Comments (RFC) 2119. - Error codes are written in prose without the `ERROR_CODE_` prefix. On the wire they use the published enum form (for example `ERROR_CODE_ALREADY_ROLLED_BACK`). The same applies to `RECONCILE_STATE_*`, `CREDIT_KIND_*`, `DEBIT_MODE_*` and `ORIGINAL_KIND_*` values. - A business refusal travels on HTTP 200 with `{"status":"error","error":{code,message,retryable,retry_after,attributes}}`. A success travels as `{"status":"ok","data":{…}}`. - Request examples are reduced to the relevant envelope fields (`ts`, `operator_id`, `correlation` and signature headers are omitted unless relevant). All values are illustrative. --- ## 1. Summary | # | Rule | |---|---| | 1 | Rollback of an unknown original is a success with `original_found=false`. The operator remembers the original's id. A late Debit/Credit with that id gets `ALREADY_ROLLED_BACK`. The operator never returns `UNKNOWN_ORIGINAL` to a Rollback | | 2 | Mandatory order of checks at the operator | | 3 | New enum value `IDEMPOTENCY_CONFLICT` and the fingerprint field list | | 4 | The operator never refuses Credit, SettleGrant, and Rollback for session, player block, or limits. Closed list of terminal Credit refusals | | 5 | Reconcile for an unknown operation returns an ok envelope + `NOT_APPLIED`. It's a read, never a barrier | | 6 | Concurrent duplicates: the second request waits for the commit and receives the first response | | 7 | Numbers: `op_id` retention, GA attempts, resend schedule and horizon, signature window | | 8 | Zero amount, exponent mismatch, int64 overflow, unknown fields, and unknown enum values | | 9 | Rollback must match the original's player and currency. DebitCredit checks the stake first and is all-or-nothing. Balance-reducing corrections arrive as an ordinary Debit | | 10 | Free rounds and bonus-type credits: what the operator receives, the token rule, no bet required for promo payouts, accrued wins are always settled | | 11 | What GA does on an unknown outcome, per operation type | | 12 | Deliberate differences from other aggregators | --- ## 2. Items ### 2.1 Rollback of an unknown original **Normative text** 1. If the operator holds no record of `original_op_id`, a Rollback MUST be answered with a success envelope, MUST move no money, and MUST carry `original_found=false` and the player's current balance. 2. The operator MUST remember the ORIGINAL's id (`original_op_id`) as rolled back. This is the barrier. Its key is the original's id, not the Rollback's `op_id`. 3. A Debit MUST check the barrier in the same atomic step that records the debit, before any money moves. The operator serialises a Debit and the Rollback of that Debit on the ORIGINAL's id. 4. The operator MUST refuse a later Debit or Credit that carries that `op_id` with `ALREADY_ROLLED_BACK`, regardless of its fingerprint. 5. A Rollback naming an original that the operator is still processing MUST get an error envelope with `retryable=true`. GA re-drives it until the original resolves. 6. The operator MUST NOT return `UNKNOWN_ORIGINAL` to a Rollback. The value stays in the enum. 7. A repeated Rollback of the same original MUST return the original response and MUST NOT reverse money again. **Example**—the operator has never seen `op-bet-90`: ```json { "request_id": "req-0002", "action": "rollback", "payload": { "meta": { "request_id": "req-0002", "op_id": "op-rb-91", "round_id": "rnd-55" }, "original_op_id": "op-bet-90", "original_kind": "ORIGINAL_KIND_DEBIT", "money": { "currency": "EUR", "amount": 1000, "exponent": 2 } } } ``` ```json { "status": "ok", "data": { "operator_tx_id": "tx-8841", "balance_after": { "currency": "EUR", "amount": 25000, "exponent": 2 }, "original_found": false } } ``` The Debit with `op_id` `op-bet-90` then arrives late: ```json { "status": "error", "error": { "code": "ERROR_CODE_ALREADY_ROLLED_BACK", "message": "operation was rolled back", "retryable": false } } ``` --- ### 2.2 Mandatory order of checks **Normative text** 1. The operator MUST evaluate checks in this order: signature → schema → `op_id` lookup (replay / conflict / already rolled back) → player + currency/exponent → [Debit/DebitCredit only: session, player status, limits, balance] → round state → apply and store the result atomically. 2. The `op_id` lookup MUST precede the session, player-status, limit, and balance checks. 3. A duplicate of an applied Debit MUST return the stored success, even when the balance is now insufficient or the session has expired. 4. A signature or time failure is a transport-level 401/403 without a code. 5. For a new Debit, an expired session is `SESSION_EXPIRED`. **Example**—a stake of 10.00 took the balance to 0.00. GA resends the same Debit: ```json { "request_id": "req-0011", "action": "debit", "payload": { "meta": { "request_id": "req-0011", "op_id": "op-bet-77", "round_id": "rnd-40", "launch_token": "lt-abc" }, "money": { "currency": "EUR", "amount": 1000, "exponent": 2 } } } ``` ```json { "status": "ok", "data": { "operator_tx_id": "tx-9001", "balance_after": { "currency": "EUR", "amount": 0, "exponent": 2 } } } ``` This response is identical to the response to the first request. --- ### 2.3 `IDEMPOTENCY_CONFLICT` **Normative text** 1. The closed error enum has 15 codes plus the `ERROR_CODE_UNSPECIFIED` default. `IDEMPOTENCY_CONFLICT` is the only code added after the initial publication. 2. For the same `op_id` with a different fingerprint, the operator MUST refuse with `IDEMPOTENCY_CONFLICT`, and no money moves. 3. The fingerprint is: player, operation type, currency, amount together with exponent, round, original, grant reference. 4. This applies while the operation is APPLIED. An `op_id` that the operator has rolled back, or remembers as rolled back, answers per item 2.1, whatever the fingerprint. 5. The same `op_id` with the same fingerprint returns the stored success. **Example**—the operator applied `op-bet-77` for 10.00. GA now sends the same `op_id` with 20.00: ```json { "request_id": "req-0031", "action": "debit", "payload": { "meta": { "request_id": "req-0031", "op_id": "op-bet-77", "round_id": "rnd-40", "launch_token": "lt-abc" }, "money": { "currency": "EUR", "amount": 2000, "exponent": 2 } } } ``` ```json { "status": "error", "error": { "code": "ERROR_CODE_IDEMPOTENCY_CONFLICT", "message": "op_id reused with a different fingerprint", "retryable": false } } ``` --- ### 2.4 What's never refused **Normative text** 1. The operator MUST NOT refuse Credit and Rollback because the session or token has expired. GA still sends the launch token of that session. Only its expiry isn't a reason to refuse. 2. A blocked or disabled player can't place NEW bets. The operator MUST still accept Credit and Rollback for such a player. 3. The operator MUST NOT refuse Credit and Rollback for limits. 4. SettleGrant is a money operation like a Credit and follows the same rule: the operator MUST NOT refuse it for session, player block, or limits. 5. The terminal refusals of a Credit are a closed list: - player not found (`PLAYER_NOT_FOUND`) - currency mismatch (`CURRENCY_MISMATCH`) - invalid request (`INVALID_REQUEST`) - `IDEMPOTENCY_CONFLICT` - "the bet this win refers to is unknown to the operator or was rolled back" (`UNKNOWN_ORIGINAL` / `ALREADY_ROLLED_BACK`) 6. After any other refusal of a Credit, GA resends it. 7. Round state: the operator refuses a new Debit in a CLOSED or VOIDED round with `BUSINESS_REJECTED`. It accepts a Credit in OPEN rounds and, as a late win, in CLOSED rounds. It refuses a Credit in a VOIDED round with `ALREADY_ROLLED_BACK`. It accepts Rollback and Reconcile in any round state. **Example**—the player's session ended hours ago, but the win still arrives: ```json { "request_id": "req-0042", "action": "credit", "payload": { "meta": { "request_id": "req-0042", "op_id": "op-win-77", "round_id": "rnd-40", "launch_token": "lt-abc" }, "money": { "currency": "EUR", "amount": 2500, "exponent": 2 }, "kind": "CREDIT_KIND_WIN", "round_finish": true } } ``` ```json { "status": "ok", "data": { "operator_tx_id": "tx-9002", "balance_after": { "currency": "EUR", "amount": 2500, "exponent": 2 } } } ``` --- ### 2.5 Reconcile **Normative text** 1. Reconcile is a read. It MUST always return an ok envelope. 2. The operator derives the state from its record of `original_op_id`: | Operator's record | Reconcile state | |---|---| | absent | `NOT_APPLIED` | | still being committed | `UNKNOWN` | | applied | `APPLIED` | | rolled back / remembered as rolled back | `NOT_APPLIED` | 3. Reconcile MUST NOT record anything. It's never a barrier. 4. The operator MUST NOT return `UNKNOWN_ORIGINAL` to a Reconcile. 5. For `NOT_APPLIED` of a Credit, GA's reading is "resend it". **Example**—the operator never saw `op-bet-90`: ```json { "request_id": "req-0050", "action": "reconcile", "payload": { "meta": { "request_id": "req-0050", "op_id": "op-rc-12" }, "original_op_id": "op-bet-90" } } ``` ```json { "status": "ok", "data": { "state": "RECONCILE_STATE_NOT_APPLIED" } } ``` The operator processes a later Debit with `op_id` `op-bet-90` normally, because the Reconcile recorded nothing. --- ### 2.6 Concurrent duplicates **Normative text** 1. The operator MUST serialise per `op_id`: it inserts a unique key before business logic runs. 2. The second request MUST wait for the commit of the first. 3. If its fingerprint matches, the second request MUST receive the same response as the first. Otherwise it receives `IDEMPOTENCY_CONFLICT`. 4. The operator never applies the operation a second time. 5. The "same response" rule concerns operations that WERE processed. A refused request leaves the `op_id` unused, and the operator MAY evaluate it again when resent. GA never resends a Debit after your business refusal. GA resends a refused Credit until you accept it. If an earlier attempt of the same Debit ended without an answer, GA treats the Debit as unknown even after a later refusal and sends a Rollback. Keep the barrier (2.4): the earlier request may still reach you. **Example**—two requests arrive at once. Both carry `op_id` `op-bet-88`, with `request_id` `req-0061` and `req-0062`. Both receive: ```json { "status": "ok", "data": { "operator_tx_id": "tx-9100", "balance_after": { "currency": "EUR", "amount": 4000, "exponent": 2 } } } ``` The balance changed once. --- ### 2.7 Numbers **Normative text** 1. Both sides MUST keep `op_id` records for at least 4 months. The 7-day window of the published v1 wallet spec remains the hard minimum for v1 bridges. 2. For a Debit/DebitCredit in unknown state, GA makes up to 4 quick attempts with the same `op_id`, 100 ms, 500 ms, and 2 s apart. A `Retry-After` may stretch a pause up to 10 s. Each attempt waits at most 5 s. All attempts share the operation budget of item 2.11.7 (8 s by default), so a silent operator gets 2 attempts: 5 s, then the rest of the budget. 3. For a Rollback, and on the same schedule for a Credit/SettleGrant in unknown state, GA resends for up to 72 hours. The backoff is exponential (1 s, doubling, capped at 10 minutes). GA stops at the first logical, valid-envelope response. The limit is the 72-hour horizon, not a number of attempts. This is inside the 7-day v1 window and inside the 4-month retention. 4. When the 72 hours are over, GA sets `manual_review` and raises an alert. The state stays unknown, and GA never marks it "failed". 5. Every attempt carries a new `request_id` and the same `op_id`. 6. The signature window is 300 s: `X-GA-Signature: t=,v1=`, rejected when `|now − t| > 300`. The failure is a transport-level 401/403 without a code. **Example**—attempt 2 of a Credit resend (new `request_id`, same `op_id`): ``` X-GA-Signature: t=1790000000,v1= ``` ```json { "request_id": "req-0072", "action": "credit", "payload": { "meta": { "request_id": "req-0072", "op_id": "op-win-91", "round_id": "rnd-60", "launch_token": "lt-abc" }, "money": { "currency": "EUR", "amount": 500, "exponent": 2 }, "kind": "CREDIT_KIND_WIN" } } ``` ```json { "status": "ok", "data": { "operator_tx_id": "tx-9200", "balance_after": { "currency": "EUR", "amount": 1500, "exponent": 2 } } } ``` This is the stored success of attempt 1, which the operator had committed but GA never received. --- ### 2.8 Amount and schema validation **Normative text** 1. Money is `{currency, amount:int64, exponent:0..18}`. 2. A Debit amount MUST be > 0. A Credit amount MAY be 0 and is then valid. A zero Credit closes a round. A negative amount is `INVALID_REQUEST`. 3. The operator MUST refuse an exponent mismatch or an exponent outside 0..18 with `CURRENCY_MISMATCH`. 4. The operator MUST refuse an int64 overflow with `INVALID_REQUEST`. 5. Both refusals happen before money moves, and the `op_id` stays unused. 6. The operator MUST ignore unknown fields. 7. The operator MUST refuse an unknown enum value in a known field with `INVALID_REQUEST`. **Example**—the operator holds EUR with exponent 2: ```json { "request_id": "req-0080", "action": "debit", "payload": { "meta": { "request_id": "req-0080", "op_id": "op-bet-95", "round_id": "rnd-70", "launch_token": "lt-abc" }, "money": { "currency": "EUR", "amount": 1000, "exponent": 3 } } } ``` ```json { "status": "error", "error": { "code": "ERROR_CODE_CURRENCY_MISMATCH", "message": "exponent mismatch", "retryable": false } } ``` --- ### 2.9 Rollback matching, DebitCredit, balance-reducing corrections **Normative text** 1. The operator MUST execute a Rollback only if its player and currency match the original's. Otherwise it refuses the Rollback terminally with `INVALID_REQUEST` or `CURRENCY_MISMATCH`, and no money moves. 2. A Rollback of a Debit is always full: `money` equals the stored amount of the original. A partial rollback exists only for a Credit, through `partial` / `debit_mode` / `debited`. 3. `DebitCredit` MUST check the stake against the current balance FIRST, as for a separate Debit, and only then pay the win of the same call. The operator applies or refuses stake and win together. It's all-or-nothing. 4. A provider correction (adjustment) that REDUCES the player's balance arrives as an ordinary `Debit`, idempotent by an `op_id` that GA derives from the provider's own correction id. One that INCREASES it arrives as a `Credit` of kind `ADJUSTMENT`. The correction names no earlier operation and rolls nothing back. 5. Apply your usual Debit checks. The only refusal the providers define for a correction is insufficient funds (`INSUFFICIENT_FUNDS`). GA relays your refusal to the provider as a refusal and never turns a refused correction into another operation. 6. The Debit carries no correction marker in v1.0. A correction can arrive outside an active game session, for example in live-casino settlements and tournaments. If you reject Debits on an expired session, you refuse such a correction, and the provider resolves it on its side. The same applies to any other charge a provider sends outside a session: a participation fee, a contribution to a promo pool, a retried bet that the provider flags as offline. **Example**—a 30.00 balance-reducing correction: ```json { "request_id": "req-0090", "action": "debit", "payload": { "meta": { "request_id": "req-0090", "op_id": "op-corr-5", "round_id": "adj-5531" }, "money": { "currency": "EUR", "amount": 3000, "exponent": 2 } } } ``` ```json { "status": "ok", "data": { "operator_tx_id": "tx-9300", "balance_after": { "currency": "EUR", "amount": 10000, "exponent": 2 } } } ``` --- ### 2.10 Free rounds and bonus-type credits **Normative text** 1. GA has two free-round mechanisms. They never share a callback. 2. **Operator-issued grant.** The operator calls `IssueGrant` / `ListGrants` / `CancelGrant`. A round played on a grant sends NO Debit and NO Credit to the operator. The win accumulates on the grant, and `SettleGrant` pays it ONCE (carrying `grant_id`, `total_win`, `rounds_played`) when the grant is exhausted. GA settles a zero-win grant too. Grant states are ACTIVE / EXHAUSTED / CANCELLED / EXPIRED / SETTLED. For an operator-issued grant, `SettleGrant` is the only money call the operator receives. 3. **Provider-issued promo.** The operator receives, per winning spin, an ordinary `Credit` of kind `FREE_ROUND` carrying the campaign reference (`campaign_ref`), or one `SettleGrant` at the end. 4. `IssueGrant` is idempotent by the operator's `idempotency_key`. The same key returns the same grant. GA refuses the same key with different parameters. Without a key every call creates a new grant, so the operator SHOULD always send one. 5. You can cancel only an ACTIVE grant. A repeated cancel returns the same result. GA allows cancelling a partly played ACTIVE grant. 6. A grant has a hard end date (`valid_until`). GA refuses a free round consumed after that date. 7. GA never drops money already won on a grant. GA pays a win of a round played before the grant ended (cancelled, expired, or settled). `SettleGrant` pays it for the rounds actually played. When the win arrives after settlement, a separate `Credit` of kind `FREE_ROUND` carrying the grant reference pays it. GA refuses to the provider a NEW round reported on a grant that has already ended, and that round never reaches you. 8. **Token rule.** For free-round, promo, and bonus credits, the operator MUST NOT validate the EXPIRY of the session/token. They may arrive when the player is offline. The token, when sent, must still be one the operator knows. 9. Credits of kind `BONUS`, `PROMO`, `JACKPOT`, `TOURNAMENT`, `CASHBACK`, `ADJUSTMENT`, `FREE_ROUND` are ordinary Credits: items 2.2–2.7 apply in full. They're idempotent by `op_id`. The operator never refuses them for session, block, or limits. GA resends them until delivered and never rolls them back. 10. **No bet required.** A promo, bonus, jackpot, or tournament payout may come without any bet or debit in the round. The operator MUST NOT require one. An ordinary win belongs to its round as usual. 11. The operator answers success to a provider's rollback of a free-round bet. The rollback moves no money, because the bet took none. `SettleGrant` is never a Rollback target. 12. `SettleGrant` is one per grant and idempotent by `op_id`. On an unknown outcome, GA resends it per item 2.7 until acknowledged. **Example**—a grant is exhausted, and GA settles it once: ```json { "request_id": "req-0100", "action": "settle_grant", "payload": { "meta": { "request_id": "req-0100", "op_id": "op-sg-3" }, "grant_id": "grant-2041", "total_win": { "currency": "EUR", "amount": 1800, "exponent": 2 }, "rounds_played": 20 } } ``` ```json { "status": "ok", "data": { "operator_tx_id": "tx-9400", "balance_after": { "currency": "EUR", "amount": 11800, "exponent": 2 } } } ``` --- ### 2.11 What GA does on an unknown outcome **Normative text** 1. An unknown outcome is any of: a timeout, a disconnect, any response that isn't a valid envelope with a known code (including 401/403/404/5xx from a proxy). A valid envelope with a retryable code (`RATE_LIMITED`, `MAINTENANCE`, `INTERNAL`) on a money operation is also an unknown outcome. 2. **Debit / DebitCredit.** - GA makes up to 4 quick attempts with the same `op_id`, 100 ms, 500 ms, and 2 s apart, inside the budget of item 2.11.7 (numbers in item 2.7). - If the outcome is still unknown, GA fails the bet towards the provider with a retryable error, so the round doesn't start. GA also generates ONE linked Rollback. 3. **Credit / SettleGrant.** GA resends with the same `op_id` until success or until the resend limit of item 2.7 runs out. GA never rolls back a bet because it couldn't deliver a win. Only the provider may send that rollback. 4. **Rollback.** - GA resends with the same `op_id` per item 2.7 (up to 72 hours, exponential backoff, stopping at the first logical response). - A non-retryable refusal, meaning a valid envelope with a non-retryable code, stops resending at once: GA sets `manual_review` and raises an alert. 5. A Rollback is never in flight concurrently with its original. GA records a provider's cancel that arrives while the Debit is in flight and answers it retryable. GA executes it after the Debit resolves. 6. There is one Rollback per original, and GA reads its amount from the stored original. 7. An overall operation budget bounds the retry sequence (GA's own sender setting, 8s by default): the first attempt always goes out. GA starts no retry and waits no backoff the remaining budget can't hold. It answers the provider retryable with an unknown outcome before the provider's own timeout expires. **Example**—a Debit `op-bet-90` gets no valid response within the budget (two attempts on a silent wallet: two `request_id` values, same `op_id`). GA fails the bet towards the provider and sends the linked Rollback: ```json { "request_id": "req-0205", "action": "rollback", "payload": { "meta": { "request_id": "req-0205", "op_id": "op-rb-91", "round_id": "rnd-55" }, "original_op_id": "op-bet-90", "original_kind": "ORIGINAL_KIND_DEBIT", "money": { "currency": "EUR", "amount": 1000, "exponent": 2 } } } ``` ```json { "status": "ok", "data": { "operator_tx_id": "tx-8841", "balance_after": { "currency": "EUR", "amount": 25000, "exponent": 2 }, "original_found": false } } ``` If the Debit reaches the operator later, the operator refuses it with `ALREADY_ROLLED_BACK` (item 2.1). --- ### 2.12 Deliberate differences from other aggregators The published GA contract governs. Some of its choices differ from what other aggregators do. Don't "fix" them when porting an existing wallet integration: - A business refusal travels on HTTP 200 with the status in the body. Non-200 statuses are transport-level only. - An error response carries no balance. - Money is `int64` in minor units plus an explicit `exponent`, never a fixed scale, and never a decimal string. - A signature failure is a transport-level 401/403 without a body status. - A partial rollback exists, but only for a Credit (`partial`, `debit_mode`, `debited`). A rollback of a Debit is always full. - A Credit is never refused for limits or session (item 2.4). - A round may stay OPEN forever. GA doesn't require every round to be closed. - A duplicate returns the stored success (item 2.6), never a conflict status. - GA doesn't send free-round bets to the operator (item 2.10). The operator learns the rounds from `SettleGrant`. --- ## 3. Conformance additions | Check id | Setup | Action | Expected | |---|---|---|---| | W2-DELTA-01 debit after rollback-of-unknown | The operator has no record of `op_id` D. The player has a known balance B | 1. Rollback with `original_op_id` = D. 2. Debit with `op_id` = D: once with the original amount, once with a different amount | 1. ok envelope, `original_found=false`, balance B, no money moved. 2. Both Debits refused with `ALREADY_ROLLED_BACK`. Balance still B | | W2-DELTA-02 duplicate debit when balance is now insufficient | Debit D (stake S) applied. The balance is now below S. Also run with the session expired | The same Debit resent: same `op_id`, same fingerprint, new `request_id` | ok envelope identical to the first response (same `operator_tx_id` and `balance_after`). Balance unchanged | | W2-DELTA-03 duplicate with a different amount | Debit D (amount A) applied | Debit with the same `op_id` and amount A′ ≠ A | `IDEMPOTENCY_CONFLICT`. Balance unchanged. The stored result of D unchanged | | W2-DELTA-04 credit after session expiry | Debit applied in a session. The session/token has since expired | Credit for the same round, carrying the launch token of that session | ok envelope. The operator credits the balance exactly once | | W2-DELTA-05 concurrent duplicates | A fresh `op_id`, a known balance | Two or more simultaneous identical Debits (different `request_id`, same `op_id`) | Every request gets the same ok response. The operator applies exactly one debit | | W2-DELTA-06 exponent mismatch | The player's currency has exponent e at the operator | Debit with the same currency and an exponent ≠ e | `CURRENCY_MISMATCH`. No money moved. The `op_id` stays unused | --- # Certification checklist **Operator:** ____________________ **Environment:** ____________________ **Transport Binding:** [ ] HTTP/JSON v2     [ ] Native gRPC v2 **Execution Date:** ____________________ **GA Certification Engineer:** ____________________ **Operator Lead Engineer:** ____________________ --- ## 0. Scope of certification A **PASS** here means one thing: your wallet endpoint follows the v2 contract on a test player. This includes the money rules that certification can exercise (idempotency, order of checks, rollback barrier, never-refused credits). It's a necessary condition for go-live, not the whole of it. It does **not** prove: - that your production wallet, with its own URL, credentials, and infrastructure, behaves the same. Run the tool against production before go-live. - that your calls **to** GA work: game launch, catalog, free rounds, reports. Cover them with the `GA_Aggregator_API_v2` Postman collection and one real end-to-end session in the sandbox. Launch a game, place a bet, and see it in `rounds` and in the Operator Portal. - that the numbers reconcile: compare one day of `aggregates` with your own ledger before the first settlement. - performance under load: the tool sends one call at a time. Your wallet must answer every call within 5 seconds at your peak traffic. Go-live needs all four plus this signed checklist. ### Files you need | What | Where | |---|---| | Certification checks | Operator Portal → **Integration** → **Callback test** (`operator_admin` role, wallet URL with `/v2`). The same suite as the command-line tool `ga-operator-certify`, which your GA integration manager provides for CI runs | | Postman: the calls GA sends to your wallet | `../postman/GA_Operator_API_v2.postman_collection.json` + `../postman/GA_Sandbox_v2.postman_environment.json` | | Postman: your calls to GA | `../postman/GA_Aggregator_API_v2.postman_collection.json`, `../postman/GA_Features_API_v2.postman_collection.json` | | All the above in one zip | `/downloads/GA_Operator_Integration_Pack_v2.0.0.zip` on the docs site | | The guide | `/docs` on the docs site, or `GA_Operator_Integration_Guide_v2.pdf` in the zip | --- ## 1. Conformance tooling verification ### Prerequisites to confirm before running - A dedicated test player exists in the operator wallet under the `--player-ref` passed to the tool (default `certify-player-001`) with a funded balance in the `--currency` (default `EUR`). - You issue a valid launch token for that player and pass it via `--launch-token` (default `token-cert-001`). The tool doesn't create players or tokens. - The API key/secret pair passed via `--api-key`/`--secret` is the one the wallet validates signatures with. - The target is a sandbox/staging wallet, never production. Certification requires one run in which every check passes: either from the Operator Portal (Integration → Callback test) or with `ga-operator-certify`, a binary your GA integration manager provides: ```bash # HTTP Binding: ./ga-operator-certify --binding http --target https://wallet.operator.example --api-key --secret --strict # gRPC Binding: ./ga-operator-certify --binding grpc --target wallet.operator.example:9090 --api-key --secret --strict ``` --- ## 2. Test cases & obligations (all 55 `W2-*` checks) ### 2.1 Money primitives & idempotency - [ ] `W2-G1-OPID-REQUIRED`: Your wallet refuses an empty `op_id` with `INVALID_REQUEST`. - [ ] `W2-G1-DEBIT-ANSWER-SHAPE`: A debit answer carries `operator_tx_id` and `balance_after`. - [ ] `W2-G1-OPID-REPLAY-MOVES-MONEY-ONCE`: Replaying an `op_id` returns stored result and does NOT move money twice. - [ ] `W2-G1-TWO-WINS-ONE-ROUND-BOTH-SETTLE`: Two Credits in one `round_id` with distinct `op_ids` both settle. - [ ] `W2-DEBIT-INSUFFICIENT-FUNDS`: A debit exceeding balance answers business refusal `INSUFFICIENT_FUNDS`. - [ ] `W2-DEBIT-ZERO-AMOUNT`: Your wallet refuses a debit with zero amount as `INVALID_REQUEST`. - [ ] `W2-CREDIT-ZERO-AMOUNT`: A credit with zero amount succeeds cleanly without altering balance. - [ ] `W2-KIND-EACH-CREDIT-KIND-ACCEPTED`: Credit accepts all defined `CreditKind` variants (WIN, BONUS, JACKPOT, PROMO, FREE_ROUND, TOURNAMENT, CASHBACK, ADJUSTMENT). - [ ] `W2-COMPONENTS-SUM-EQUALS-AMOUNT`: A credit with `MoneyComponent` breakdown matches total amount. ### 2.2 Atomic debit & credit - [ ] `W2-DEBITCREDIT-HAPPY`: Atomic `DebitCredit` applies net difference to player balance. - [ ] `W2-DEBITCREDIT-INSUFFICIENT`: Atomic `DebitCredit` with bet > balance refuses with `INSUFFICIENT_FUNDS`. - [ ] `W2-DEBITCREDIT-ATOMIC-ON-CREDIT-FAILURE`: Atomic `DebitCredit` leaves balance intact if operation can't complete. - [ ] `W2-DEBITCREDIT-REPLAY`: Atomic `DebitCredit` replayed returns identical stored response. ### 2.3 Reversals & rollbacks - [ ] `W2-G4-ROLLBACK-IDEMPOTENT-ON-ITS-OWN-OPID`: The same Rollback delivered twice reverses exactly once. - [ ] `W2-G4-ROLLBACK-OF-UNKNOWN-ORIGINAL-IS-OK-NOOP`: A Rollback naming an unknown original answers OK and moves nothing. - [ ] `W2-G4-DEBIT-AFTER-ROLLBACK-OF-UNKNOWN-REJECTED`: Your wallet rejects a Debit whose `op_id` it already rolled back as an unknown original. The barrier holds. - [ ] `W2-ROLLBACK-PARTIAL`: A Rollback with partial amount reverses only specified amount. - [ ] `W2-ROLLBACK-DEBIT-MODE-REJECT`: Rollback with `debit_mode=REJECT` refuses if balance insufficient. - [ ] `W2-ROLLBACK-DEBIT-MODE-ALLOW_NEGATIVE`: Rollback with `debit_mode=ALLOW_NEGATIVE` permits negative balance. - [ ] `W2-ROLLBACK-DEBIT-MODE-PARTIAL`: Rollback with `debit_mode=PARTIAL` drains balance up to available funds. ### 2.4 Rounds & grants - [ ] `W2-CLOSEROUND-IDEMPOTENT`: Your wallet acknowledges `CloseRound`, and the call is idempotent on `op_id`. - [ ] `W2-CLOSEROUND-WITH-NETTO`: `CloseRound` carries the round summary `net_win`, `bet_total`, `win_total`. - [ ] `W2-G6-SETTLEGRANT-IS-ACKNOWLEDGED`: Your wallet acknowledges `SettleGrant` with OK, including zero total. - [ ] `W2-G6-SETTLEGRANT-IDEMPOTENT`: `SettleGrant` replayed for one grant pays once. - [ ] `W2-G6-SETTLEGRANT-CREDITS-TOTAL-WIN`: `SettleGrant` credits `total_win` (§5.9 of the Integration Guide): right after the `ok` reply the balance is up by exactly `total_win`. An unchanged balance fails: this call is the only place the free-round win reaches the player. ### 2.5 Transaction reconciliation - [ ] `W2-RECONCILE-APPLIED`: `Reconcile` reports `APPLIED` for a completed `op_id`. - [ ] `W2-RECONCILE-NOT_APPLIED`: `Reconcile` reports `NOT_APPLIED` for an unknown `op_id`. - [ ] `W2-RECONCILE-UNKNOWN`: `Reconcile` reports valid `ReconcileState` enum. - [ ] `W2-RECONCILE-CARRIES-OPERATOR-TX`: `Reconcile` for applied operation returns original `operator_tx_id`. ### 2.6 Non-financial lifecycle & notifications - [ ] `W2-NOTIFY-EACH-KIND-ACK`: `Notify` acknowledges non-monetary lifecycle events. - [ ] `W2-NOTIFY-FREEROUNDS-STARTED`: `Notify` `NOTIFY_KIND_FREE_ROUNDS_STARTED` event acknowledged. - [ ] `W2-NOTIFY-FREESPIN-FINISHED`: `Notify` `NOTIFY_KIND_FREESPIN_FINISHED` event acknowledged. - [ ] `W2-NOTIFY-SESSION-EXPIRED`: `Notify` `session_expired` event acknowledged. ### 2.7 Metadata, authentication & scope isolation - [ ] `W2-PROVIDER-DATA-ECHOED`: `ProviderData` with raw payload in `CallMeta` accepted. - [ ] `W2-AUTH-TOKEN`: `Authenticate` validates launch token and answers session profile. - [ ] `W2-AUTH-INVALID-TOKEN`: Your wallet cleanly rejects `Authenticate` with an invalid launch token. - [ ] `W2-TOKEN-EMPTY-IS-REJECTED`: Your wallet refuses a money call arriving with empty `launch_token`. - [ ] `W2-TOKEN-BALANCE-WITHOUT-TOKEN`: `GetBalance` without `launch_token` answers from `player_ref` or refuses cleanly. - [ ] `W2-G7-PLAYER-REF-IS-OPAQUE`: The player reference is carried through unparsed and unaltered. - [ ] `W2-G8-BRAND-ISOLATION`: A session's money can't be moved from another brand. - [ ] `W2-G5-GRANT-ISSUANCE-IDEMPOTENCY`: The aggregator, not the wallet, handles grant issuance idempotency. - [ ] `W2-G10-DEMO-CANNOT-REACH-THE-WALLET`: A demo launch has no field that could ever address a wallet. ### 2.8 Error handling, retries & capabilities - [ ] `W2-ERR-REFUSAL-IS-FAILED-PRECONDITION`: a business refusal answers with a typed error code on HTTP 200 (on gRPC: a status carrying `ErrorDetail`), never a bare `FAILED_PRECONDITION` and never an internal error. - [ ] `W2-ERR-UNDECIDED-RETRY-SAME-OPID`: An undecided call retried with the SAME `op_id` moves money once. - [ ] `W2-ERR-MALFORMED-IS-INVALID-REQUEST`: Your wallet refuses a malformed money request as `INVALID_REQUEST`, not 500. - [ ] `W2-ERR-EACH-ERRORCODE-SHAPE`: Structured error responses conform to `ErrorCode` contract. - [ ] `W2-RETRY-SAME-OPID-NEW-REQUESTID`: Your wallet recognizes a retry with SAME `op_id` and NEW `request_id` as an idempotent retry. - [ ] `W2-CURRENCY-MISMATCH-REFUSED`: Your wallet cleanly rejects a request with currency mismatch against the player account. - [ ] `W2-CAPS-DECLARED-EQUALS-BEHAVIOUR`: Declared capabilities in `wallet_capabilities` match runtime behavior. ### 2.9 Money-path delta (`W2-D1` … `W2-D6`) The six checks of `../money-path/OPERATOR-API-MONEY-PATH-RULES.md` §3, required of every operator certifying on the v2 contract. The document numbers them `W2-DELTA-01` … `W2-DELTA-06`. `ga-operator-certify` prints them under the ids below. - [ ] `W2-D1-DEBIT-AFTER-ROLLBACK-OF-UNKNOWN`: After a Rollback of an unknown original, your wallet refuses a Debit with that `op_id` with `ALREADY_ROLLED_BACK`. This holds at the original amount and at a different one. The balance never moves. - [ ] `W2-D2-DUPLICATE-DEBIT-BALANCE-NOW-INSUFFICIENT`: A resent Debit whose stake no longer fits the balance returns the identical stored success. The check also runs with the session expired. - [ ] `W2-D3-DUPLICATE-DEBIT-DIFFERENT-AMOUNT`: For the same `op_id` with a different amount, your wallet refuses with `IDEMPOTENCY_CONFLICT`. The stored result stays unchanged. - [ ] `W2-D4-CREDIT-AFTER-SESSION-EXPIRY`: Your wallet accepts a Credit carrying the launch token of an expired session and credits it exactly once. - [ ] `W2-D5-CONCURRENT-DUPLICATES`: Simultaneous identical Debits all get the same response. Your wallet applies exactly one debit. - [ ] `W2-D6-EXPONENT-MISMATCH`: For a Debit with an exponent the player's currency doesn't use, your wallet refuses with `CURRENCY_MISMATCH`. No money moves, and the `op_id` stays unused. --- ## 3. Decision & sign-off | Result | Status | Notes | |---|---|---| | **PASS** | [ ] | All 55 checks passed, allowing the structurally PARTIAL results the tool reports (`W2-G8-BRAND-ISOLATION` on the HTTP binding)—0 errors and 0 failures. | | **FAIL** | [ ] | One or more tests failed with unexpected state or money loss. | | **BLOCKED** | [ ] | Network connectivity or authentication issue prevented suite completion. | **Certification Signatures:** GA Integration Engineer: _________________________     Date: ______________ Operator Tech Lead: _____________________________     Date: ______________ # Document Control | | | | :--- | :--- | | Document | GA Promo — Integration Guide | | Version | 1.6.2 | | Date | 2026-10-08 | | Audience | engineers of a client who put GA Promo widgets on their site and connect their players, games and gameplay to GA Promo | | Pack | [`README.md`](README.md) lists every file | **What this pack covers.** You run a casino site for one or more brands registered in GA. GA Promo runs the promotions of those brands: collection events, quests, prize drops and the rewards they pay. Your site connects to it in six places: | What | Direction | Chapter | | :--- | :--- | :--- | | widgets on your pages | GA Promo → your page | §2 | | who the player is | your backend → GA Promo, once per page | §3 | | game launch from a widget | SDK, or widget → your page | §4 | | gameplay and account events | your systems → GA Promo | §5–§7 | | game catalogues | both ways | §8 | | rewards to credit | GA Promo → your backend | §9 | **Games that run through GA's aggregation** (launched through GA, §4.3): GA itself delivers every real-money `bet`, `win`, `refund` and `settled` of their rounds to GA Promo, under the player of your token (§3.2), and these rounds are not checked against any catalogue. For these games you send **no round events and no catalogue** (§8.1). Do not send them anyway: your connection is checked against the catalogue you sent, so a GA game there is set aside as `skipped/game_unknown`, and on a connection without a catalogue it is counted a second time. You still send, over §5–§7: - account events (`deposit`, `withdrawal`, `login`, §5.4) — GA does not see them — when your promotions use them; - the rounds of games that do not run through GA, and their catalogue (§8.1). A client whose games all run through GA and whose promotions use no account events sends no events at all. Everything else applies to every client. # 1. Quick Start 1. Fill in [`GA_Promo_Onboarding_Form_v1.yaml`](GA_Promo_Onboarding_Form_v1.yaml) once and send it to your GA integration manager. The form holds no credentials. 2. We send you, once and over the channel your manager names (§10.2): your `client_id` for the widgets, your `source_id` and signing secret per event transport, and the secret we sign reward notifications with. Player tokens (§3.2) are signed with the operator signing key you already hold for Operator API v2 — nothing new to receive for that one. 3. Put the resolver script and the widget placeholders on your pages (§2) and add the token endpoint that tells the widgets who the player is (§3). 4. Decide how a game click is launched (§4); `sdk-modal` lets the SDK launch the game. 5. Games not on GA: send their catalogue (§8.1) and their events (§5–§7). Games on GA: nothing to send; GA delivers their rounds. Account events (`deposit`, `withdrawal`, `login`): send them in either case when your promotions use them. 6. Build the endpoint that credits rewards (§9). 7. Test in the sandbox and reconcile the counts with us (§10.3); then we switch production on. | Environment | Widgets (resolver) | API, events and catalogue | | :--- | :--- | :--- | | Sandbox | `https://ludarium.rexplay.site` | `https://api.rexplay.site` | | Production | `https://ludarium.game-alligator.com` | `https://api.game-alligator.com` | # 2. Widgets on Your Site ## 2.1 Two pieces of markup **The resolver** — one script tag, anywhere on the page: ```html ``` **A placeholder per widget** — an element with `data-ig-slot`, wherever the widget should appear: ```html
``` The resolver loads the configuration of your installation (which widget goes to which slot, your brand's look, how games are launched, where your token endpoint is), finds every placeholder — including those added later — and renders the widget inside it, isolated in a Shadow DOM, so your styles and the widget's do not touch. You install nothing else: no npm package, no build step. The configuration is kept by us; changing it needs no change on your pages. Rules of the placeholder: - `data-ig-slot` names the widget (§2.2). Several elements with one slot all get the widget. - `data-ig-placement` names **where** it stands (`homepage`, `cashier`, …): one widget can be configured differently per placement. Without it the default placement applies. - Other `data-*` attributes on the placeholder override the widget's defaults for that element; values are strings. **Or no placeholder at all.** A widget of your installation can name its place on the page instead: Operator Portal → **Embed** → your installation → the widget's slot → *Place on the page without markup*, then **Publish Live**. Either a CSS selector — the resolver puts the placeholder itself at the first element the selector finds, inside it at the end (default) or at the start, before it or after it — or a floating panel pinned to a corner of the window (bottom right, bottom left, top right, top left), above your page, with a close button; a closed panel stays closed until the page is loaded again. Your page carries only the resolver script. - Name an element that keeps its place across your releases — an `id` or a `data-` attribute of your own (`#promo-area`, `[data-ga-promo]`), not a layout class: when nothing matches, the widget is not shown. - On a single-page site the widget follows the element: it appears when the element does and is removed when the element leaves the page. - A placeholder you put on the page yourself for the same slot and placement wins over the selector or the floating panel: the widget is never shown twice. - A floating panel covers a corner of your page: keep it clear of your own chat or cookie banner. ## 2.2 Widgets | Slot | Shows | | :--- | :--- | | `game-sections` | rows of game categories from GA's catalogue for your operator (`GET /public/v1/catalog/lobby?operator_id=…`) | | `game-section` | one category of GA's catalogue | | `promo-l`, `promo-m`, `promo-s` | a collection promotion banner — large (desktop), medium, small (mobile) | | `collection-event` | the page of a collection event: the album, its items and the player's progress | | `event-page` | a full event page: the album, the player's collected items, the next item to collect, the countdown | | `in-game` | a compact card over a game: the next item to collect, progress, the countdown | Which widgets and placements you use is agreed at onboarding (form, `widgets`). `collection-event` shows the campaign set in your configuration. To show another campaign on one page, add `data-campaign-id` with the id we give you. Put one `collection-event` on a page: ```html
``` `promo-l`, `promo-m`, `promo-s`, `event-page` and `in-game` advertise the same campaign as `collection-event` and take `data-campaign-id` the same way: ```html
``` A signed-in player sees their own progress — items collected out of the album, the next item to collect, and the countdown to the campaign's end. A guest sees the campaign and how many items it has, with a sign-in button (§3.4), no personal numbers. Without a running campaign the widgets show the states of §2.5, never sample data. The countdown on `event-page`, `in-game` and the promo banners reads `hh:mm:ss` under a day left; from 24 hours it puts the whole days in front, e.g. `104d 05:08:50` (`104д …` for a `ru` locale). ## 2.3 Events and game launch Widgets tell your page what happened with ordinary DOM events raised on the placeholder element. They bubble, so one listener on `document` catches all of them. Listening is optional. | Event | `event.detail` | When | | :--- | :--- | :--- | | `ig:game-launch` | §4.2 | a player chose a game; with `sdk-modal`, after the game's modal has opened | | `ig:login-required` | `{reason}` | an action needs a signed-in player; show your sign-in. A game launch gives `{reason: 'launch'}` | | `ig:game-launch-error` | the game's fields of §4.2, `status`, `reason` | `sdk-modal` could not launch the game (§4.3) | ```js document.addEventListener('ig:login-required', () => showLogin()); document.addEventListener('ig:game-launch', (e) => track(e.detail.gameId)); document.addEventListener('ig:game-launch-error', (e) => showError(e.detail.reason)); ``` The events reach your page only when the manifest declares the capability `host-events`. An event is a notification: the state stays with GA Promo, your page reacts to it. ### Launching a game from a widget click With the launch mode `sdk-modal` (§4.1) the SDK launches the game itself: your page writes no launch code. You set up three things: ```html ``` 1. **The script.** `resolver.js` with your `data-client-id`, as in §2.1. 2. **The token.** `tokenProvider` returns the operator JWT (§3.2.1). The token is the only proof of who the player is: the widget never gives your page a user id. 3. **The place.** The manifest's `mount.selector` puts the widget where you need it (§2.1), with no placeholder on your page. On a click on a game, the SDK: 1. takes a fresh token from `tokenProvider` (for a `real` launch; a `demo` launch needs none); 2. calls `POST https:///public/v1/embed/launch` (§4.3): `real` with `operatorToken`, `demo` without a token; 3. shows the game in its own full-screen modal over your page; the player closes it with the cross or `Esc`; 4. raises `ig:game-launch` (§4.2). If there is no token, or GA answers `401`, the SDK raises `ig:login-required` with `{reason: 'launch'}` on your page: show your sign-in. A `real` launch is never downgraded to `demo`. Any other failure raises `ig:game-launch-error` (§4.3). Limits: - the events reach your page only when the manifest declares `host-events`; - with the manifest setting `isolation: 'iframe'` a `real` launch for now gives `ig:login-required`; `demo` works. What your side does: - Issue the operator token (§3.2). Optional `ext_param` in it carries your context (SoftGaming); it reaches your wallet calls as `i_extparam` and is read only from the signed token. - Accept the wallet callbacks `bet` and `win` for this player (Operator API v1 or v2). Nothing else: no `fetch` to `embed/launch`, no iframe, no popup. The modes `redirect`, `popup` and `host-handled` stay (§4.1). With `host-handled` your page launches the game itself, as in §4.3. What happens at GA and your wallet on a `real` launch: 1. GA checks the signature of `operatorToken` and takes the player from it (`external_player_id`). 2. GA calls your wallet once, with that exact token string as `launch_token` (`Authenticate`, or `GetBalance` when your wallet has no `Authenticate`). Your wallet validates the token. If it refuses, the launch fails with `401` `login_required` and no session opens. 3. Every bet, win and refund of the session reaches your wallet as a wallet call for this player, with the same `launch_token`. This needs your wallet connected (Operator API v1 or v2) and the `embed_launch_operator_token` setting turned on for your operator; both are in §4.3. A game with `has_demo: true` also launches with `mode: "demo"` and no token. All fields, statuses and the demo rules are in §4.3. ## 2.4 Single-page sites Remove the placeholder from the page as you normally do: the widget stops its timers and connections. Put it back and the widget is rendered again, as many times as it happens. Moving the element within the page does not remove the widget. ## 2.5 When something fails A failure on our side never breaks your page: | What happened | What the player sees | | :--- | :--- | | the configuration could not be loaded | the placeholders stay empty | | one widget failed to load or to start | that placeholder stays empty; the others work | | the placeholder names a slot or placement we have not configured | that placeholder stays empty | | the CSS selector of a widget (§2.1) finds no element, or is not valid CSS | the widget is not shown; the page is untouched | | your token endpoint failed or took longer than 1.5 s | the widgets show what a guest sees (§3.4) | | `collection-event`, `event-page`, `in-game` or a promo banner has no campaign set | the widget says the campaign is not set up | | the campaign is unknown or not running | the widget says the event is unavailable | | `game-sections`/`game-section` has no category to show | the widget says no games are available | | our API answered an error | a short message inside the widget; `collection-event` adds a retry button | `game-sections`/`game-section` show your lobby categories; `collection-event`/`event-page`/`in-game`/ `promo-l`/`promo-m`/`promo-s` show your promo campaigns. Both are set up by your team in the **Operator Portal** (`https://operator.game-alligator.com`, DEV sandbox `https://operator.rexplay.site`) — Catalog Engine for categories (create, add games, publish) and Promo Studio for campaigns (create, set dates, activate). Nothing to configure on GA's side beyond onboarding; an empty widget almost always means a category or campaign is still in `draft` there. A campaign's collection has exactly four rarity tiers, from most to least common (e.g. common, rare, epic, legendary) — the widgets draw four. Promo Studio starts a new collection with four and refuses to publish one with any other number, naming the collection's buckets. ## 2.6 Your site's settings - **Allowed origins.** The widgets call our API from your pages; list every origin of your site in the form (`site.origins`) — scheme and host exactly, e.g. `https://www.example-casino.com`. - **Language.** The widgets take their language from the locale of your installation, published in your manifest as `display.locale` (e.g. `en-US`). We set it at onboarding; you change it yourself in the Operator Portal: **Embed** → your installation → **Default Locale** → save, then **Publish** — pages opened after the publish use the new locale. One placeholder can use another locale than the rest of the page with `data-locale`, e.g. `
`. The player token's `locale` (§3.2) does not change the widgets' language. `collection-event` shows its texts in Russian for a `ru` locale and in English for any other; the other widgets' texts are in English. Item names follow the locale when the collection has them in it. - **Content-Security-Policy**, if your site sends one: | Directive | Add | | :--- | :--- | | `script-src` | the resolver host | | `connect-src` | the resolver host and the API host | | `img-src` | the resolver host, the API host, `data:`, and the image hosts of game providers we send you at onboarding | | `style-src` | `'unsafe-inline'`, `https://fonts.googleapis.com` | | `font-src` | the resolver host, `https://fonts.gstatic.com` | Game images come from the API host; some of them redirect to the image host of their provider, and that list follows your catalogue. We collect only whether each widget started (widget, placement, error code, versions) — never the player, the page address or anything the player typed. # 3. Signed-in Players The widgets show a signed-in player their own progress, items and rewards. They learn who the player is from your site, once per page, without GA ever seeing your passwords or your session cookies. ## 3.1 How it works 1. The resolver calls **your token endpoint** on your own origin with a plain `GET`. The browser sends your session cookies with it, because it is your origin. 2. Your endpoint answers with a short-lived **operator token** for the signed-in player (§3.2), or with an error when nobody is signed in. 3. The resolver exchanges the operator token for a GA player token and hands it to every widget of the page. It lives in the page's memory only — never in storage, cookies or the address bar — and is renewed in the background before it expires. You tell us the path of the endpoint in the form (`players.token_endpoint`), e.g. `/api/ga-promo/token`. It must be on the same origin as the page that shows the widgets. ## 3.2 Your token endpoint ``` GET /api/ga-promo/token Accept: application/json Cookie: ``` | Your answer | When | | :--- | :--- | | `200` `{"operatorToken":""}` | a player is signed in | | `401` (any body) | nobody is signed in; the widgets show what a guest sees | The JWT is signed **on your backend**, never in the browser: - algorithm `HS256`, key: the same operator signing key you already hold for Operator API v2 (the wallet signing key; its secret is shown once when you rotate it in the Operator Portal) — not a separate embed-only secret (§10.2); `none` and other algorithms are refused; - the HMAC key is the secret string **exactly as issued, as its UTF-8 bytes** — do not base64-decode or hex-decode it, do not trim or add anything; - no `kid` header is needed and none is read: we check the token against each of your active signing keys, so it keeps verifying while two keys overlap during a rotation. The key id is used by the wallet requests only; - `operator_id` — your GA operator id, as registered for you; - `external_player_id` — your player's id: **the same value you send as `player_ref`** in events (§5.1). Another value makes another player; - `brand` — the code of the player's brand (§5.1). **Required** when you have several brands (a group operator): the brand ref, the same value as `x-brand-id`. **Must be absent** when you have one brand; - `exp` — required; keep it a few minutes: the widgets exchange a token once; every game launch (§4.3) asks for a fresh one. Optional: `currency`, `locale`, `ext_param` (your context for the game launch, §2.3). ```json { "operator_id": "", "external_player_id": "u-123", "brand": "partner-sandbox-eu", "exp": 1790000000 } ``` The endpoint is the only place where your site vouches for a player. Answer only for the player of the session the request carries. ## 3.2.1 A site with the session in an `Authorization` header If your players sign in with a token in the `Authorization` header rather than a cookie, the endpoint of §3.2 does not fit: the browser does not send it the session. Hand the token to the widgets from your page's JavaScript instead. `resolver.js` accepts a token provider. Set it in one of two ways: - `window.Ludarium.setTokenProvider(fn)`; - `window.LudariumConfig = { tokenProvider: fn }`, defined before `resolver.js` loads. `fn` returns a string or a `Promise`: the operator JWT, built as described in §3.2. With the launch mode `sdk-modal` (§4.1) the same provider supplies the token of every game launch. ```html ``` - The provider is used instead of `GET` to the token endpoint; its result goes to `sessionUrl` as `operatorToken`. - The provider is called again when the token expires. - If it throws or returns an empty string, the widgets show what a guest sees, the same as for a `401` from the endpoint (§3.4). - Without a provider nothing changes: the endpoint of §3.2 is used. - The token is accepted only from your page's JavaScript, never from a slot's `data-*` attributes or from the manifest. - Set the provider before the first widget mounts. ## 3.3 A player we have not seen yet A player who signs in before their first bet is known from the first signed token: the widgets show their own, empty progress. Events you send later (§5) count for the same player, because both name them by the same `external_player_id` / `player_ref` under the same brand. ## 3.4 Guests Without a token endpoint, when nobody is signed in, when the endpoint fails or takes longer than 1.5 s, or when the token is not valid, the widgets show what a guest sees: public campaigns, closed items, game rows. Promo banners show the campaign without any progress, and their button reads “Sign in to collect”. An action that needs a player — that button included — raises `ig:login-required` on your page (§2.3). Nothing breaks and nothing waits. # 4. Launching Games ## 4.1 Modes A click on a game in a widget is handled the way agreed for your installation (form, `widgets.game_launch`; manifest `launch.mode`): | Mode | What happens | | :--- | :--- | | `sdk-modal` | the SDK launches the game itself and shows it in a modal iframe over your page (§2.3, §4.3) | | `redirect` | the page goes to your URL template with the game's values in place, e.g. `/game/{gameId}`; with target `_blank` the URL opens in a new tab | | `popup` | the same URL opens in a new window | | `host-handled` | the widget does nothing itself and tells your page (§4.2); your page launches the game, on GA as in §4.3 | The URL template takes three placeholders, each filled URL-encoded, in every place it appears: | Placeholder | Value | | :--- | :--- | | `{gameId}` | the game's id in the catalogue — unique across all providers | | `{gameSlug}` | the game's code at its provider; when the game has none, `{gameId}` | | `{providerId}` | the provider's id in GA's catalogue; for the catalogue you send us (§8.1), its `provider` | A game code is unique only within its provider: two providers can offer a game under the same code. Route on `{gameId}`, or on `{providerId}` and `{gameSlug}` together — never on `{gameSlug}` alone. ## 4.2 The `ig:game-launch` event ```json { "gameId": "019edf56-a69e-75ed-bf7f-06706720a9d6", "gameSlug": "10869", "providerId": "00000000-0000-0000-0000-000000000de3", "providerName": "Yggdrasil", "gameTitle": "Hercules Fortune Quest", "mode": "redirect", "url": "/game/019edf56-a69e-75ed-bf7f-06706720a9d6" } ``` `gameSlug` is absent when the game has no code; `url` is present for `redirect` and `popup` only. With `sdk-modal` the event comes after the modal has opened, carries `"mode": "sdk-modal"` and no `url`: the SDK has launched the game itself (§2.3). With `host-handled` your page launches the game. A minimal handler: ```js document.addEventListener('ig:game-launch', (e) => { const { gameId, gameSlug, providerId, system, page } = e.detail; // launch the game on GA as in §4.3; with `system` and `page` — by that pair }); ``` `gameId` is the game's UUID in GA's catalogue; `gameSlug` is the game's code at its provider (absent when the game has none); `providerId` is the provider's id in GA's catalogue. `gameSlug` is unique only within one provider, so route on `gameId`, or on `providerId` with `gameSlug` — never on `gameSlug` alone. `system`, `page` are optional strings: the System/Page pair from Game/List for operators on the SoftGaming protocol; launch the game by it if it is present. A game without the pair has no such keys. ## 4.3 Launching on GA If your games run through GA's aggregation, the chosen game is launched through GA. With `sdk-modal` the SDK sends this request; with `host-handled` your page does. The player is proven by an operator token (§3.2): on the click, a fresh one is taken (the SDK takes it from `tokenProvider`, §3.2.1) and sent in the body. The GA player token of §3.1 stays inside the widgets; your page does not need it. A `real` launch plays with the player's money: the game's provider asks GA for the balance and every bet and win, and GA forwards each of them to **your wallet** (Operator API v1 or v2). Until your wallet endpoint is connected, GA refuses `real` launches with `403` `wallet_not_configured`; `demo` launches need no wallet. ### One token for the whole launch The operator token your page already holds is the only token of a real-money launch. Pass it as `operatorToken` when launching. GA verifies its signature, takes the player from it, and calls your wallet once before the game provider is called, with this exact string as `launch_token` (`Authenticate` if your wallet declares it, otherwise `GetBalance`). - If your wallet refuses (invalid token, blocked or unknown player), the launch is refused with `401` `login_required` (in `sdk-modal` mode: `ig:login-required`) and no session stays open. - If your wallet cannot be reached, the launch is refused with a retryable `503`; retry with a fresh token. - The same string is then the `launch_token` on every wallet call of that session. Your side: - Mint a fresh token for every launch (for example a unique `jti`): the token is also the launch's idempotency key. - Resolve the player from the token on every wallet call. - Do not apply the JWT `exp` to a session that is already running; `exp` limits the time to start the game. Only some games have a demo. A game's `has_demo` says it: `true` — `mode: "demo"` launches it; `false` — the answer is `400` `demo_not_supported`, offer the game signed-in only. `has_demo` is in every game of the catalogue the widgets read, `GET https:///public/v1/catalog/lobby?operator_id=` (no key needed; `data[].games[]`, the game's `id` is the event's `gameId`; a game may carry the optional strings `system` and `page` — the System/Page pair from Game/List for SoftGaming operators, absent when the game has none), and of GA's catalogue for your side (§8.2). ``` POST https:///public/v1/embed/launch Content-Type: application/json {"gameId": "019edf56-a69e-75ed-bf7f-06706720a9d6", "providerId": "00000000-0000-0000-0000-000000000de3", "operatorToken": "", "mode": "real", "returnUrl": "https://casino.example/lobby", "lang": "en"} ``` | Field | Rule | | :--- | :--- | | `gameId` | the `gameId` of the event (§4.2) — unique on its own; a game code works too, but it is unique only together with `providerId` | | `providerId` | optional; the `providerId` of the event. With a game code it picks that provider's game; with a `gameId` of another provider the answer is `404` | | `operatorToken` | the operator token of §3.2 for the signed-in player, checked the same way: signed with your key, `HS256`, with `exp`. Currency and locale come from its claims | | `mode` | `real` (default) needs `operatorToken` and your connected wallet; `demo` works without a token, then `operatorId` — your GA operator id — is required; only for a game with `has_demo: true` | | `returnUrl` | where the game sends the player back | | `lang` | the game's language; by default the player's, then `en` | `200` `{"launchUrl":"…","mode":"popup","expiresAt":"…"}` — with `sdk-modal` the SDK opens `launchUrl` in its modal (a `200` without `launchUrl` is `ig:game-launch-error`); with `host-handled` open it in an iframe or a new window. | Status | `error` / `message` | Meaning | | :--- | :--- | :--- | | `400` | `bad_request` / what is wrong | no `gameId`, a `providerId` or `operatorId` that is not a UUID, a demo launch without `operatorId` | | `400` | `bad_request` / `demo_not_supported` | a demo launch of a game with `has_demo: false` | | `401` | `unauthorized` / `login_required` | a `real` launch without `operatorToken`, or with one that is not signed with your key, has no `exp` or has expired; take a fresh token. With `sdk-modal` the SDK raises `ig:login-required` with `{reason: 'launch'}` (§2.3); show your sign-in | | `403` | `forbidden` / `wallet_not_configured` | a `real` launch before your wallet (Operator API v1 or v2) is connected; ask your manager to connect it | | `401` | `unauthorized` / `login_required` | your wallet refused the operator token (invalid token, blocked or unknown player); no session stays open | | `503` | retryable | your wallet could not be reached; retry with a fresh token | | `403`, `404` | `forbidden`, `not_found` / the reason, e.g. `game_not_found` | the game is unavailable to you or does not exist | | `502` | `bad_gateway` | our side failed; try again | With `sdk-modal` the SDK sends `gameId`, `providerId`, `mode`, `operatorToken` (`real`) and `operatorId` (your GA operator id, when known). Every answer except `200` and `401` raises `ig:game-launch-error` with `{status, reason}` and the game's fields of §4.2: `status` is the HTTP status, `reason` is the `message` of the answer or `launch_failed`. A network failure gives `status` `0`, `reason` `network_error`. If your games are not on GA, you launch them yourself: `gameId` is the id you sent in your catalogue (§8.1). # 5. Gameplay Events Rounds of games that run through GA's aggregation come from GA: never send them (see the note before §1). This chapter and §6–§7 cover account events and the rounds of games not on GA. ## 5.1 Identifiers | Field | What it is | Rule | | :--- | :--- | :--- | | `brand` | your brand | the code of the brand as registered for you in GA; we confirm the list at onboarding. **Required** when one connection carries events of several brands; **omitted** when it carries one brand | | `player_ref` | your player | the player's id on your side — **the same value as `external_player_id` in your player tokens** (§3.2). Up to 255 bytes of UTF-8. Another id splits one person into two players | | `event_id` | the event | yours; unique **forever** within your feed; a resend of the same event carries the same `event_id` | | `round_id` | the round | the same for every event of one round | | `game.id` | the game | the id of the game in the catalogue you send us (§8.1) | All your transports share one space of `event_id`s and `round_id`s unless you tell us otherwise in the form: an event sent through two of them is one event, and a refund sent by webhook reverses the bet of the same round sent over Kafka. ### Bonus-money sessions: `promo_player_ref` Some operators run bonus money under a separate session, for example `b7~1001~55`, while the player's promotions should count for `b7~1001`. Pass the attribute `promo_player_ref` in `LaunchGame.attributes`, set to the player's normal `player_ref` (1 to 255 characters, no whitespace). GA Promo then credits the round to that player instead of the bonus session's player. Only promo accounting changes: wallet calls, balance and the session's own `player_ref` stay as they are. The ref must belong to the same operator or brand as the session; an invalid value makes the launch fail with `InvalidArgument`. ## 5.2 The event: `promo.event.v1` JSON Schema: [`schema/promo-event-v1.schema.json`](schema/promo-event-v1.schema.json). One event is one JSON object; the webhook also takes a batch `{"events": [ … ]}` (§6.1). ```json { "spec": "promo.event.v1", "event_id": "01J8Z3K9T6-bet-778812", "brand": "partner-sandbox-eu", "player_ref": "u-123", "kind": "bet", "round_id": "r-99812", "game": { "id": "book-of-x" }, "amount": "1.50", "currency": "EUR", "occurred_at": "2026-09-24T12:00:00Z" } ``` | Field | Required | Rule | | :--- | :---: | :--- | | `spec` | yes | exactly `promo.event.v1` | | `event_id` | yes | non-empty string (§5.1) | | `brand` | see §5.1 | string, up to 255 bytes of UTF-8 | | `player_ref` | yes | non-empty string, up to 255 bytes of UTF-8 | | `kind` | yes | a round kind — `bet`, `win`, `settled`, `refund` — or an account kind — `deposit`, `withdrawal`, `login` (§5.4) | | `round_id` | round kinds | non-empty string; not read for account kinds | | `game.id` | no | string; not read for account kinds; a round event without a game counts only in promotions that list no games | | `amount` | all but `login` | **a string** with a decimal number in the **major units** of `currency` (§5.3); not read for `login` | | `currency` | all but `login` | a code from Appendix C, any letter case; not read for `login` | | `occurred_at` | yes | RFC 3339 with a zone (`Z` or `±hh:mm`); fractions of a second allowed | Fields you add beyond these are ignored, at the top level and inside `game`: the format grows by adding fields. The byte limits count UTF-8 bytes, not characters. ## 5.3 Amount A JSON **string**: digits, optionally a dot and more digits. It is converted to minor units of the currency **exactly**; more decimals than the currency has is refused, never rounded. | `amount` | `currency` | Result | | :--- | :--- | :--- | | `"1.50"`, `"1.5"` | EUR | 1.50 EUR | | `"100"` | JPY | 100 JPY | | `"0.00012345"` | BTC | 0.00012345 BTC | | `"0.00"` | EUR | a `bet` or `settled` of zero is set aside (`skipped/zero_amount`) | | `"1.505"` | EUR | refused — three decimals, EUR has two | | `"100.0"` | JPY | refused — JPY has no decimals | | `1.5`, `150` (JSON numbers) | any | refused | | `"-1.50"`, `"1e2"`, `"1,50"`, `"01.50"`, `".5"`, `"1."`, `""` | any | refused | ## 5.4 Kinds **Round kinds** — every money operation of a game round: | `kind` | Send when | `amount` | | :--- | :--- | :--- | | `bet` | a stake is placed | the stake | | `win` | a payout is made | the payout | | `refund` | money of a round is returned | the returned amount | | `settled` | the round is over — **exactly one per round** | **the sum of the round's bets** | Events of a round do not have to arrive in order. Promotions that collect items credit on `settled` only: a feed without `settled` counts nothing there. **A refund takes the whole round out of promotions**, whatever its amount: what was already counted for the round's `bet` and `settled` is reversed, and a `bet` or `settled` of that round that arrives after the refund is set aside as `skipped/round_refunded`. Send `refund` only for a round whose stake is returned. **Account kinds** — events of the player's account, not of a round: | `kind` | Send when | `amount` | | :--- | :--- | :--- | | `deposit` | the player's deposit is credited | the deposit | | `withdrawal` | the player's withdrawal is paid out | the withdrawal | | `login` | the player signs in | none | They count in promotions built on them — a login streak, a deposit or withdrawal target — and never in the promotions of rounds: they need no `round_id` or game, take no minimum bet, and a zero `amount` is decided, not set aside. One file per kind is in `examples/events/` of the pack: `bet.json`, `win.json`, `refund.json` and `settled.json` carry `brand`, for a connection of several brands; `deposit.json`, `withdrawal.json` and `login.json` are account events; `batch.json` has no `brand`, for a connection of one brand — its second event has an unknown `kind` and is refused, the other two are accepted. # 6. Transports Pick one or more. Each transport is its own connection with its own `source_id`. ## 6.1 Webhook ``` POST https:///promo/v1/ingest/ Content-Type: application/json X-Promo-Timestamp: X-Promo-Signature: ``` ``` signature = lowercase_hex( HMAC-SHA256( secret, X-Promo-Timestamp + "." + raw_body ) ) ``` - Sign **the exact bytes you send**. Serialising the body again after signing (another key order, spaces) breaks the signature. - `X-Promo-Timestamp` must be within **±300 s** of our clock. - **Rotation.** During a rotation we accept both the current and the next secret: we give you the next one, you switch to it, we retire the old one. - **Body.** One event, or `{"events": [ … ]}` with up to **500** events; at most **1 MiB**. Other limits can be agreed in the form. - **Concurrency.** The number of requests in flight is agreed in the form; a request that finds no free slot within 0.5 s is answered `503`. | Status | Body | Meaning | Resend? | | :--- | :--- | :--- | :--- | | `200` | `{"source":"","results":[{"index":0,"event_id":"…","verdict":"accepted"}, …]}` | every event is decided; `verdict` is `accepted`, `skipped`, `rejected` or `duplicate`; `skipped` and `rejected` carry a `reason` (Appendix A). A `duplicate` may carry one too — ignore it, the event was decided earlier | no | | `503` + `Retry-After: 1` | `{"source":"","error":"undecided","results":[ …decided so far… ]}` | we could not decide an event right now; events after it were not tried | **yes — the same batch, the same `event_id`s** | | `401` | `{"error":"unauthorized","results":[]}` | missing or stale timestamp, missing signature, or a signature that matches no secret | after fixing the request | | `413` | `{"error":"body_too_large","results":[]}` / `{"error":"batch_too_large","results":[]}` | over 1 MiB, or more events than the batch limit | after splitting | | `400` | `{"error":"malformed","results":[]}` | signed, but not a JSON object, or `events` is not an array | after fixing | | `404` | `{"error":"unknown_source","results":[]}` | no active connection with this `source_id` | contact us | On `503`, any other `5xx` or a network error, resend the same batch: events already decided answer `duplicate`, the rest are decided. `{"events": []}` answers `200` with empty `results`. If a connection keeps answering `503`, contact us. Examples (standard library only), each sends one file from `examples/events/`: ```bash export PROMO_INGEST_URL=https://api.rexplay.site/promo/v1/ingest/ read -rs PROMO_SOURCE_SECRET && export PROMO_SOURCE_SECRET cd examples/webhook go run . ../events/bet.json python3 send.py ../events/settled.json ``` ``` 200 {"source":"","results":[{"index":0,"event_id":"01J8Z3K9T6-bet-778812","verdict":"accepted"}]} ``` ## 6.2 Kafka (your cluster) We connect to your cluster as a consumer. - **You give us** (form, and credentials once — §10.2): the bootstrap brokers, **every broker address the cluster advertises** with its IP, the topics, TLS — the CA, and the client certificate with its key for mutual TLS, plus the TLS server name — and SASL (`PLAIN`, `SCRAM-SHA-256` or `SCRAM-SHA-512`) with a user that may read the topics and use our consumer group. We send you the group name for your ACLs. - **TLS is required** in production. - **Message.** The record value is one event (§5.2), JSON in UTF-8. The key and the headers are not read: key the topic as your own ordering needs. - **Start.** We read from the moment we connect; tell us in the form if we should also read what the topic already retains. - **Delivery.** We commit an offset only after its event is decided. An event we could not decide is read again, so delivery is at least once; `event_id` absorbs the repeats. ## 6.3 RabbitMQ (your broker) AMQP 0-9-1 over TLS (`amqps://`, required in production). You give us the host, port, vhost, user and password, and one of: - **an exchange and routing keys** — we declare our own durable queue and bind it to your exchange. You declare the exchange; our user needs the right to declare and bind that queue. The queue holds only what arrives after we create it; or - **a queue you created for us** — our user needs the right to consume it. - **Message.** The body is one event (§5.2). Headers and properties are not read. Publish persistently so an event survives a broker restart. - **Delivery.** We acknowledge a message only after its event is decided. One we could not decide stays unacknowledged and we retry it after 1 s, doubling up to 30 s; we never requeue it. If our connection drops, the broker redelivers what we had not acknowledged, and a decided event answers `duplicate`. Several of our consumers may read one queue; order is not required. # 7. Outcomes and Your Obligations ## 7.1 Verdicts Every event gets exactly one verdict. | Verdict | Meaning | | :--- | :--- | | `accepted` | stored and counted in the promotions of its brand | | `skipped` + `reason` | stored; set aside by the promotion rules of the brand | | `rejected` + `reason` | stored; not counted — the event breaks this contract | | `duplicate` | this `event_id` was already decided; nothing happens | | undecided | not stored; webhook `503`, the broker message stays unread or unacknowledged, and it is delivered again | **A decided event is final.** A resend answers `duplicate` whatever the first verdict was. To correct a rejected event, fix it and send it with a **new** `event_id`. Every reason and what to do about it is in Appendix A. The webhook returns verdicts in its answer. Kafka and RabbitMQ return none: during onboarding and on request we send you the counts and the refused events of your feed. ## 7.2 Your obligations 1. **Every money operation of a round is its own event**: `bet`, `win` and `refund` as they happen. 2. **Exactly one `settled` per settled round**, with `amount` equal to the sum of the round's bets. 3. **A resend carries the same `event_id`.** 4. **The feed carries only the games that run through you.** A game the brand also receives through GA or another provider must not be in your feed: one bet reported twice is counted twice, and there is no common id to catch it. 5. **`brand` is the code registered for you in GA.** 6. **`game.id` is an id from the catalogue you send us** (§8.1). We check them with you before production (§10.3). # 8. Game Catalogues ## 8.1 Your catalogue → GA Promo If your games are not on GA, GA Promo learns them from you: promotions are set up on your games and an event is checked against them. It holds only games that do not run through GA: GA's games reach GA Promo with GA's own rounds, and a copy of GA's catalogue (§8.2) is not sent here. ### Sending it ``` POST https:///promo/v1/catalog/ Content-Type: application/json X-Promo-Timestamp: X-Promo-Signature: ``` The URL takes the `source_id` of your **webhook** transport, and the request is signed exactly like its events (§6.1): the same secret, the same ±300 s window. A catalogue cannot be sent over Kafka or RabbitMQ. ```json { "mode": "snapshot", "games": [ { "id": "book-of-x", "name": "Book of X", "provider": "Acme Studio", "category": "Slots", "image_url": "https://cdn.acme.example/book-of-x.png" }, { "id": "crash-9", "name": "Crash 9", "category": "Crash", "is_active": false } ] } ``` | `mode` | List | Effect | | :--- | :--- | :--- | | `snapshot` | `games` | your catalogue becomes exactly `games`, at once; a game not listed is removed; `[]` clears it | | `upsert` | `games` | the listed games are added or updated; the others stay | | `delete` | `game_ids` | the listed games are removed; unknown ids are ignored | | Game field | Required | Rule | | :--- | :---: | :--- | | `id` | yes | up to 255 bytes; **the same string your events carry as `game.id`** | | `name` | yes | not blank, up to 255 bytes | | `provider` | no | up to 255 bytes | | `category` | no | up to 255 bytes; the category of the game | | `image_url` | no | an absolute `https` URL, up to 2048 bytes | | `is_active` | no | `true` by default; an inactive game is kept but not shown and not counted | One request carries up to 10 000 games or ids and 8 MiB. Fields you add beyond these are ignored; an `id` twice in one request is an error. JSON Schema: [`schema/promo-catalogue-v1.schema.json`](schema/promo-catalogue-v1.schema.json); a snapshot to try is `examples/catalogue/snapshot.json`, sent with the webhook examples of §6.1 by pointing `PROMO_INGEST_URL` at the catalogue URL. | Status | Body | Meaning | | :--- | :--- | :--- | | `200` | `{"source":"","mode":"snapshot","upserted":2,"deleted":0,"total":2}` | applied; the same request again changes nothing and answers `upserted: 0, deleted: 0` | | `400` | `{"error":"invalid","details":[{"index":0,"field":"name","reason":"required"}]}` | a game breaks a rule; nothing is written. `reason`: `required`, `too_long`, `not_a_string`, `not_a_boolean`, `not_https_url`, `duplicate`, `not_an_object` | | `400` | `{"error":"malformed"}` | not a JSON object, an unknown `mode`, or the list missing | | `401` | `{"error":"unauthorized"}` | missing or stale timestamp, missing signature, or a signature that matches no secret (§6.1) | | `404` | `{"error":"unknown_source"}` | no active webhook connection with this `source_id` | | `413` | `{"error":"body_too_large"}` / `{"source":"","error":"batch_too_large"}` | over 8 MiB, or more than 10 000 games or ids | | `503` + `Retry-After: 1` | `{"error":"unavailable"}` | not applied; send the same request again | ### What it changes - **Your events are held to it.** Once your catalogue holds a game, a `bet` or `settled` whose `game.id` is not an active game of it is set aside as `skipped/game_unknown`. `win` and `refund` are never held back, so a refund still reverses its round. Send the catalogue **before** a game appears in your feed: an event set aside stays set aside when the game arrives later. - **Promotions are set up on your games.** We pick them from your catalogue for a promotion's game lists. - **The game widgets do not show it.** `game-sections` and `game-section` show GA's catalogue for your operator, never the one you send us. The catalogue is one per client connection: every brand of a group shares it. ## 8.2 GA's catalogue → you If your games run through GA's aggregation, you can keep a copy of GA's catalogue on your side — a full snapshot once, then the changes. This is optional: the widgets read the catalogue themselves, and GA Promo needs no copy of it (§8.1). ``` GET https:///v1/catalog/snapshot GET https:///v1/catalog/diff?cursor=&limit=200 X-Public-API-Key: X-Public-Timestamp: X-Public-Signature: ``` ``` signature = lowercase_hex( HMAC-SHA256( secret, METHOD + path_with_query + body + X-Public-Timestamp ) ) ``` - `path_with_query` is the full request path with its query string, e.g. `/v1/catalog/diff?cursor=…&limit=200`; `body` is empty for these `GET`s. - The timestamp may be up to 5 minutes old and up to 30 seconds ahead of our clock. - The key needs the `read:catalog` scope; we issue it with your credentials (§10.2). **Snapshot** — one consistent read: `catalog_version` (the cursor to store once you have saved the snapshot), `generated_at`, `providers[]`, `categories[]`, `games[]` and `category_games[]` (`category_id`, `game_id`, `display_order`). A game's `image_url` is an absolute URL of its cover for your operator, `https:///public/v1/assets/games//image?operator_id=`. Every game has one: the address answers `200` with the cover, or with a placeholder marked by the response header `X-Image-Fallback: true` when the game has no cover yet. The same holds for `payload.image_url` of a game in the diff. **Diff** — `changes[]` in order, each `entity_type`, `entity_id`, `operation`, `updated_at`, `payload`; `upsert` carries the whole current object, `disable` and `delete` are tombstones. Continue with `next_cursor` while `has_more` is `true`. `limit` is 1–1000, 200 by default. A cursor too old to serve answers `410` `cursor_expired`: take a new snapshot. A cursor works only with the key that received it. # 9. Rewards When a player wins a reward that you pay out — cash, free spins, a jackpot — GA Promo tells your backend with a signed `POST`. Items, points and other in-promotion prizes stay inside GA Promo and need nothing from you. ## 9.1 The notification ``` POST Content-Type: application/json X-Promo-Timestamp: X-Promo-Signature: ``` ```json { "delivery_id": "0199a7f2-5c1e-7c3a-9d41-2b7e8f0a1c55", "player_ref": "u-123", "player_id": "0198a1cb-4f37-7ee8-8e7d-08d39925aec0", "operator_id": "", "reward_type": "freespins", "reward_value": "20", "currency": "EUR", "reference_id": "", "timestamp": "2026-09-24T12:00:00Z" } ``` | Field | Meaning | | :--- | :--- | | `delivery_id` | this one grant; **credit once per `delivery_id`** | | `player_ref` | your id of the player — the value your events carry as `player_ref` (§5.1); present for every player your events have named, absent otherwise | | `player_id` | the same player's id in GA | | `operator_id` | the GA operator id of the player's brand | | `reward_type` | `cash`, `freespins` or `jackpot` | | `reward_value` | a decimal string: the amount in `currency` for `cash` and `jackpot`, the number of spins for `freespins` | | `currency` | the currency of the amount | | `reference_id` | the campaign that granted it; the same for every grant of that campaign — not a key | | `timestamp` | when the notification was built | ## 9.2 Checking it ``` expected = lowercase_hex( HMAC-SHA256( secret, X-Promo-Timestamp + "." + raw_body ) ) ``` Compare `expected` with `X-Promo-Signature` in constant time, over the exact bytes you received. Refuse a timestamp more than 5 minutes away from your clock. The secret is the one we send you when we connect your reward endpoint (§9.3, §10.2). A request also carries `X-Promo-Signature-Raw`, an older signature over the body alone; do not rely on it. ## 9.3 Answering and retries - Answer `200`, `202` or `204` once the reward is credited or recorded to credit. Anything else, or no answer within 10 seconds, is a failure. - A failed notification is sent again with the same `delivery_id` and body: 8 attempts in all, the pause growing from 10 seconds to at most 10 minutes. After the eighth failure we stop and resolve it with you by hand. - A repeat of a `delivery_id` you already credited must answer `200` and credit nothing. You tell us the endpoint URL in the form (`rewards.endpoint_url`); each environment has its own URL and its own secret. The secret belongs to that URL: we generate it when we connect the endpoint and send it to you then, over the channel of §10.2 — so it comes only after you have given us a URL that GA can reach (a public `https://` address; plain `http://` only in the sandbox), not with your other credentials. Until the endpoint is connected, no reward notification is sent to you. ## 9.4 Delivery via Kafka Instead of a webhook we can write each reward notification to a topic on your own Kafka cluster. You give us, in place of `rewards.endpoint_url`: the broker addresses (`host:port`, TLS required), the topic, the SASL mechanism (`SCRAM-SHA-512` or `SCRAM-SHA-256`) and a user with its password, sent over the channel of §10.2. If you want to verify messages as in §9.2, say so and we send you a secret. - **Topic.** You create it. We suggest `.promo.rewards`, for example `ga-dev.promo.rewards`. We never create topics. - **Access.** Grant our user `WRITE` and `DESCRIBE` on that topic. - **Message.** The value is the JSON of §9.1, byte for byte. The key is `delivery_id`. With a secret, the headers `X-Promo-Timestamp` and `X-Promo-Signature` are the ones of §9.2, computed over the record value. - **Delivery.** At least once: we write with `acks=all` and count a notification delivered when the cluster acknowledges it. A failed write is repeated on the schedule of §9.3 (8 attempts, 10 seconds to 10 minutes). The same `delivery_id` can arrive more than once: **credit once per `delivery_id`**. - **Order.** The key is `delivery_id`, so records of one grant share a partition; order across grants is not guaranteed. # 10. Onboarding and Go-Live ## 10.1 The form — once [`GA_Promo_Onboarding_Form_v1.yaml`](GA_Promo_Onboarding_Form_v1.yaml) holds everything we need to connect you: contacts, brands, your site's origins, the widgets you place and how games launch, your token endpoint, your reward endpoint, what `player_ref` is, and — if your games are not on GA — the volumes and per transport its addresses and options. Fill it in once; we connect you from it. Changes later — a new broker, a new brand — are a new version of the same form. ## 10.2 Credentials and allow-lists — once One exchange, over the channel your manager names; never in the form, e-mail or tickets. | From you | From us | | :--- | :--- | | Kafka: SASL user and password; the CA; the client certificate and key for mutual TLS | your `client_id` for the widgets | | RabbitMQ: user and password | nothing new — player tokens (§3.2) are signed with the operator signing key you already hold for Operator API v2; ask your manager to rotate it if you need a fresh one | | the IP of every broker (in the form) — we allow our outbound traffic to exactly these | your `source_id` and signing secret per transport (§6, §8.1) | | | the secret we sign reward notifications with (§9) — after you have given us your reward endpoint URL (`rewards.endpoint_url`, §9.3); it is issued for that URL | | | a public API key with `read:catalog`, if you copy GA's catalogue (§8.2) | | | our outbound IP addresses, if your broker or reward endpoint admits listed addresses only | Production gets its own credentials and its own secrets. ## 10.3 Sandbox and reconciliation Put the widgets on a test page, sign in a test player, and — if your games are not on GA — send your test catalogue and events of test players of your brands to the sandbox. Then check with us: - [ ] every placeholder shows its widget, including after your page navigates away and back; - [ ] a signed-in test player sees their own progress, and a signed-out visitor sees the guest view; - [ ] a click on a game launches exactly that game (with `sdk-modal` the game opens in the SDK modal; with `host-handled` `ig:game-launch` reaches your page); - [ ] with your Content-Security-Policy on, the browser console shows no blocked widget resource; - [ ] a test reward reaches your reward endpoint, and the same delivery sent again is credited once; - [ ] your catalogue is in GA Promo, and every `game.id` of your feed is in it; - [ ] a webhook batch answers `200` with `accepted`, and the same batch again answers `duplicate`; - [ ] a broker event appears in our counts; - [ ] every round has its bets and exactly one `settled` whose `amount` is the sum of the bets; - [ ] a refund carries the `round_id` of its bet; - [ ] no `rejected` events, or each one explained; - [ ] per brand and day, your counts of bets and settled rounds equal ours; - [ ] the games in the feed are only the ones that run through you. Then we switch production on and repeat the count check after the first day. # Appendix A. Reasons | Verdict | Reason | Meaning | Your action | | :--- | :--- | :--- | :--- | | `rejected` | `no_idempotency_key` | no usable `event_id`, or the event is not a JSON object; not stored | fix and resend | | `rejected` | `malformed` | wrong field type, `spec`, missing `kind`, `occurred_at`, or `round_id` of a round kind, `brand` or `player_ref` over 255 bytes | fix, send with a new `event_id` | | `rejected` | `op_unknown` | `kind` is none of `bet`, `win`, `settled`, `refund`, `deposit`, `withdrawal`, `login` | fix, send with a new `event_id` | | `rejected` | `no_player` | `player_ref` missing or empty | fix, send with a new `event_id` | | `rejected` | `bad_amount` | `amount` is not an exact decimal string of the currency (§5.3), or missing where it is required | fix, send with a new `event_id` | | `rejected` | `no_brand` | the connection carries several brands and `brand` is missing | add `brand` | | `rejected` | `brand_unexpected` | the connection carries one brand and `brand` is present | drop `brand` | | `rejected` | `brand_unmapped` | this `brand` is not registered for you | contact us; after we register it, resend with new `event_id`s | | `rejected` | `operator_inactive` | the brand is not active in GA | contact us | | `rejected` | `panic` | processing failed on our side | send us the `event_id` | | `skipped` | `currency_unknown` | `currency` is missing or not in Appendix C | use a listed code, or ask us to add one | | `skipped` | `zero_amount` | a `bet` or `settled` of zero | none | | `skipped` | `game_denied`, `game_not_allowed` | the game is outside the promotion rules of the brand | none | | `skipped` | `below_min_bet` | the bet is below the brand's minimum for promotions | none | | `skipped` | `game_unknown` | your catalogue holds games, and this `bet` or `settled` names none of its active ones (§8.1) | add the game to your catalogue; later events of it count | | `skipped` | `round_refunded` | the round was already refunded | none | | `duplicate` | any or none | already decided; a `reason` here, if present, is not a new verdict | none | # Appendix B. Numbers to Remember | | | | :--- | :--- | | Token endpoint answer | within 1.5 s, else the page shows the guest view | | Operator token `exp` | required; a few minutes | | Webhook timestamp window | ±300 s | | Webhook batch / body | 500 events / 1 MiB, unless agreed otherwise | | `503` retry hint | `Retry-After: 1` | | Wait for a free webhook slot | 0.5 s, then `503` | | `brand`, `player_ref` | up to 255 bytes of UTF-8 | | `amount` | exact decimal string; the value in minor units fits a signed 64-bit integer | | `settled` | exactly one per round, amount = sum of the bets | | RabbitMQ retry of an undecided message | 1 s, doubling, up to 30 s | | GA catalogue request timestamp | up to 5 min old, up to 30 s ahead | | GA catalogue diff `limit` | 1–1000, default 200 | # Appendix C. Currencies | Scale | Codes | | :--- | :--- | | 0 | `JPY` | | 2 | `USD`, `EUR`, `GBP`, `CHF`, `CAD`, `AUD`, `NZD`, `PLN`, `CZK`, `HUF`, `RON`, `BGN`, `SEK`, `NOK`, `DKK`, `TRY`, `UAH`, `RUB`, `BRL`, `MXN`, `INR`, `ZAR` | | 6 | `USDT`, `USDC`, `XRP`, `ADA`, `TRX` | | 8 | `BTC`, `LTC`, `DOGE` | | 9 | `SOL` | | 18 | `DAI`, `ETH`, `BNB`, `MATIC` | A currency not listed here is set aside (`skipped/currency_unknown`); ask us before you send one. # GA Poker Integration Guide PokerPro is GA's ready-made online poker: cash tables, tournaments, lobby, hand history, all run by GA. You put the poker client on your site in an iframe and manage players and their poker balance from your backend. This guide is **under construction**. ## Overview | Part | What it is | |---|---| | Poker client | GA's poker UI in an iframe or a new window. You open it by URL with the player's token | | Server API (S2S) | Your backend creates players and moves money into and out of their poker balance | Money model: the poker balance is **separate from your wallet**. You move money in with a deposit and back out with a withdrawal (see **Server API**). Play at the tables is settled on the poker balance; poker does not call your wallet per hand, and it does not go through the GA Operator API v2 wallet. Sandbox: `https://pokerpro.rexplay.site`. GA issues your S2S API key. **Under construction.** ## Embed the poker client Open the client by URL, in an iframe or a new window: ``` https://pokerpro.rexplay.site/?token=&embed=true&lang=en&returnUrl=https%3A%2F%2Fcasino.example%2Flobby ``` | Parameter | Required | Meaning | |---|---|---| | `token` | yes, for real play | The player token from `POST /players` (see **Server API**). The client logs the player in with it and opens the lobby. `sessionToken` is accepted as an alias | | `embed` | no | `true` when the client runs inside your page | | `lang` | no | `en` or `ru`. `locale` is accepted as an alias | | `returnUrl` | no | Where the "back to casino" button sends the player. `return_url` is accepted as an alias | | `tableId` | no | Open this table instead of the lobby | | `demo` | no | `true` opens a demo session without a token | ```html ``` ## Server API Base URL: `https://pokerpro.rexplay.site/api/s2s` (sandbox). Every call carries your key: | Header | Value | |---|---| | `Authorization` | `ApiKey `. The key names your tenant. Without it, or with an unknown key: `401` | | `Content-Type` | `application/json` | Answers are wrapped: `{"success": true, "data": { … }}`. A failure is `{"success": false, "data": null, "error": ": "}`, for example `"UNAUTHORIZED: Missing or invalid Authorization header. Expected: ApiKey "`. Codes: `VALIDATION_ERROR` (400), `UNAUTHORIZED` (401), `FORBIDDEN` (403), `NOT_FOUND` (404), `CONFLICT` (409). Amounts are integers in minor units: `"amount": 150` is 1.50. Decimals and zero are refused (`400`). `currency` is `RUB` (default) or `USD`. | Call | Body or query | `data` in the answer | |---|---|---| | `POST /players` | `externalId` (your player id, up to 255 chars), `username` (3–50 chars), optional `email` | `201`: `playerId`, `token` | | `POST /players/{playerId}/deposit` | `amount`, `currency`, `referenceId` (your id of the transfer, up to 255 chars) | `transactionId`, `status: "completed"`, `newBalance` | | `POST /players/{playerId}/withdraw` | Same as deposit | `transactionId`, `status: "pending"`, `amount`, `currency`. `400 Insufficient balance` when the poker balance is lower | | `GET /players/{playerId}/balance` | — | `playerId`, `available`, `reserved`, `total`, `currency`, `lastUpdatedAt` | | `GET /players/{playerId}/history?limit=50&offset=0` | `limit` 1–100 (default 50), `offset` | `transactions[]`: `transactionId`, `type`, `status`, `amount`, `currency`, `balanceAfter`, `referenceId`, `description`, `createdAt`; `totalCount` | - `playerId` comes from `POST /players`. Keep it next to your own player id. - `token` from `POST /players` logs the player into the poker client: put it into the client URL (see **Embed the poker client**). - `referenceId` is the idempotency key of a deposit: a repeated `referenceId` does not credit twice. ```bash curl -s -X POST https://pokerpro.rexplay.site/api/s2s/players \ -H "Authorization: ApiKey $POKERPRO_API_KEY" -H 'Content-Type: application/json' \ -d '{"externalId":"player-42","username":"player_42"}' # 201 {"success":true,"data":{"playerId":"","token":""}} curl -s -X POST https://pokerpro.rexplay.site/api/s2s/players//deposit \ -H "Authorization: ApiKey $POKERPRO_API_KEY" -H 'Content-Type: application/json' \ -d '{"amount":10000,"currency":"USD","referenceId":"dep-000123"}' # {"success":true,"data":{"transactionId":"dep-000123","status":"completed","newBalance":10000}} ``` **Under construction.** ## Events and webhooks ### Client events (in the browser) The client posts messages to the window that opened it (`window.parent`, `window.top`, `window.opener`). Every message is an object with `type` and `timestamp` (Unix ms): | `type` | Other fields | When | |---|---|---| | `POKERPRO_READY` | `version`, `isEmbedded`, `returnUrl` | The client has loaded | | `POKERPRO_BALANCE_UPDATE` | `balance` (string, minor units), `currency`, `userId` | The player's poker balance changed | | `POKERPRO_EXIT` | `returnUrl` | The player left the client | | `POKERPRO_RETURN_TO_OPERATOR` | `returnUrl` | The player pressed "back to casino" | Messages are sent with target origin `*`: check `event.origin` against `https://pokerpro.rexplay.site` before you trust one. ```js window.addEventListener('message', (event) => { if (event.origin !== 'https://pokerpro.rexplay.site') return; const msg = event.data; if (msg?.type === 'POKERPRO_BALANCE_UPDATE') showPokerBalance(msg.balance, msg.currency); if (msg?.type === 'POKERPRO_RETURN_TO_OPERATOR') closePoker(); }); ``` ### Webhooks (to your backend) **Under construction.**